feat(web): add workspace-aware session flow
This commit is contained in:
@@ -16,7 +16,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
@@ -32,6 +32,7 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const sessions = ctx.sessions
|
||||
const workspaces = ctx.workspaces
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
@@ -86,7 +87,9 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
open: (target: SessionId) => { sessions.open(target) },
|
||||
open: (sessionId) => { sessions.open(sessionId) },
|
||||
updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) },
|
||||
retrySessionPrompt: () => { scoped.retryPendingPrompt() },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
@@ -103,13 +106,16 @@ export function apply(ctx: Context): void {
|
||||
label: 'Chat',
|
||||
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
|
||||
}),
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
return {
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
@@ -133,20 +139,11 @@ export function apply(ctx: Context): void {
|
||||
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } },
|
||||
inject: (): EmptyStateInjected => ({
|
||||
// ctx.get, not ctx.conversation: the service mounts on this plugin's
|
||||
// own child fiber, so it is not in the inject topology the property
|
||||
// proxy enforces; get reads the global store and stays loud on a torn
|
||||
// boot through the optional-chain throw below.
|
||||
startSession: (opts) => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation.startSession(opts)
|
||||
},
|
||||
createWorkspaceSession: async (name) => {
|
||||
const id = await sessions.createWorkspace(name)
|
||||
sessions.open(id)
|
||||
},
|
||||
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
|
||||
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
|
||||
sendSession: () => { workspaces.sendSession() },
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { RefObject } from 'react'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
@@ -30,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* zero owner changes.
|
||||
*/
|
||||
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
|
||||
/** Shared Workspace picker hole used by the page-local Session Intent hero. */
|
||||
'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +97,12 @@ export interface ConversationInjected {
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
open(id: SessionId): void
|
||||
/** Select a real Session through the runtime navigation owner. */
|
||||
open(sessionId: SessionId): void
|
||||
/** Update the scoped Session's retained prompt. */
|
||||
updateSessionPrompt(text: string): void
|
||||
/** Retry the scoped Session's retained prompt. */
|
||||
retrySessionPrompt(): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,16 +148,24 @@ export interface DetailsInjected {
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
|
||||
|
||||
/** Injected share of the no-session empty-state slot. */
|
||||
export interface EmptyStateInjected {
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
/**
|
||||
* Create a workspace folder under the host cwd, mint a session there, and
|
||||
* open it (Create-new modal success path).
|
||||
*/
|
||||
createWorkspaceSession(name: string): Promise<void>
|
||||
/** Owner share common to the empty hero's Workspace picker. */
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
onPick(workspaceId: WorkspaceId): void
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
|
||||
/** Runtime-owned actions injected into the empty-state occupant. */
|
||||
export interface EmptyStateInjected {
|
||||
/** Replace the current Session intent, optionally preserving a prompt while retargeting. */
|
||||
startSession(workspaceId?: WorkspaceId, prompt?: string): void
|
||||
/** Update the current Session intent's controlled prompt. */
|
||||
updateSessionPrompt(text: string): void
|
||||
/** Materialize and send the current Session intent. */
|
||||
sendSession(): void
|
||||
}
|
||||
|
||||
/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */
|
||||
export type EmptyStateSlotProps =
|
||||
PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected
|
||||
|
||||
@@ -15,7 +15,7 @@ export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
|
||||
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Scope-addressed conversation send, cancel, and empty-state session startup.
|
||||
* Scope-addressed conversation send, cancel, history, and retained-prompt orchestration.
|
||||
*
|
||||
* Scope addressing rides the cordis Service tracker: property access through
|
||||
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
||||
@@ -44,37 +44,27 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state first-send chain (root-context method; does not read scope):
|
||||
* create the session, navigate to it, then send through the new scope.
|
||||
* The create → open ordering is safe: the manager merges the new summary
|
||||
* synchronously before create() resolves, so the list store is projected by
|
||||
* the time open() validates against it (manager notification batching is
|
||||
* microtask-based; SessionsService projects on the same flush that create
|
||||
* awaited through the RPC round trip).
|
||||
* @param opts - project directory, prompt text, and send mode.
|
||||
*/
|
||||
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
|
||||
const sessions = this.requireSessions()
|
||||
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
|
||||
// The manager notifier flushes per microtask; one await guarantees the
|
||||
// list-store projection landed before sessions.open validates against it.
|
||||
await Promise.resolve()
|
||||
sessions.open(id)
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
|
||||
// ctx.get, not scoped.conversation: property access walks the fiber
|
||||
// topology (a scope fiber never injects services), while get reads the
|
||||
// global store and still binds this service to the scoped ctx.
|
||||
const scopedConversation = scoped.get('conversation')
|
||||
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
|
||||
await scopedConversation.send(opts.text, opts.mode)
|
||||
/** Pull one older history page for the scoped Session. */
|
||||
async loadOlder(): Promise<void> {
|
||||
await this.scopedSession('loadOlder').loadOlder()
|
||||
}
|
||||
|
||||
/** Update the scoped Session's retained pending prompt. */
|
||||
updatePendingPrompt(text: string): void {
|
||||
this.scopedSession('updatePendingPrompt').updatePendingPrompt(text)
|
||||
}
|
||||
|
||||
/** Retry the scoped Session's retained pending prompt. */
|
||||
retryPendingPrompt(): void {
|
||||
this.scopedSession('retryPendingPrompt').retryPendingPrompt()
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
const binding = this.requireSessions().binding(id)
|
||||
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
|
||||
return binding.session
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
|
||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/** Full props = the automatic shares & injected share — composed by reference
|
||||
@@ -37,8 +38,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
|
||||
views, send, stop, open,
|
||||
sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain,
|
||||
views, send, stop, open, updateSessionPrompt, retrySessionPrompt,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -48,16 +49,60 @@ export function ConversationRoot({
|
||||
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
|
||||
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const draft = useStore(s => s.draft)
|
||||
const running = useSession(s => s.running)
|
||||
const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined)
|
||||
const storedDraft = useStore(s => s.draft)
|
||||
const draft = pendingPrompt?.text ?? storedDraft
|
||||
const sessionRunning = useSession(s => s.running)
|
||||
const running = sessionRunning || pendingPrompt?.phase === 'sending'
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const workspaceTitle = useWorkspaces(state =>
|
||||
state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title)
|
||||
const error: InputBarError | null = pendingPrompt?.error !== undefined
|
||||
? {
|
||||
op: pendingPrompt.retry === 'connect' ? 'session' : 'send',
|
||||
message: pendingPrompt.retry === 'connect'
|
||||
? `Workspace attach failed: ${pendingPrompt.error}`
|
||||
: `Message send failed: ${pendingPrompt.error}`,
|
||||
}
|
||||
: promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
|
||||
const status = pendingPrompt?.phase === 'sending'
|
||||
? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…'
|
||||
: undefined
|
||||
const setDraft = (text: string): void => {
|
||||
if (pendingPrompt === undefined) actions.setDraft(text)
|
||||
else updateSessionPrompt(text)
|
||||
}
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
if (pendingPrompt === undefined) send(draft, mode)
|
||||
else retrySessionPrompt()
|
||||
}
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
// Blank-session guidance: phase-derived (the runtime snapshot owns the
|
||||
// predicate — see ComposerPhase). Only `blank` renders the hero; `engaging`
|
||||
// and `active` fall through to the conversation view, so an in-flight
|
||||
// first send never bounces back here. Gated on the OPEN window: phase has
|
||||
// no jurisdiction over loading/error frames (ChatView renders those).
|
||||
if (openState === 'open' && composerPhase === 'blank') {
|
||||
return (
|
||||
<EmptyHero
|
||||
workspaceRow={<WorkspaceChip label={workspaceTitle ?? workspaceLabel(cwd ?? '')} locked />}
|
||||
draft={draft}
|
||||
disabled={removed || pendingPrompt?.phase === 'sending'}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The default composer doubles as the chain's all-decline fallback: a
|
||||
// pending wait with no registered takeover must still leave the input usable.
|
||||
@@ -67,9 +112,10 @@ export function ConversationRoot({
|
||||
running={running}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
variant="composer"
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
onStop={stop}
|
||||
/>
|
||||
)
|
||||
@@ -78,7 +124,7 @@ export function ConversationRoot({
|
||||
<div className={css.root}>
|
||||
<header className={css.header}>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="会话层级">
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((s, i) => {
|
||||
const last = i === ancestry.length - 1
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// EmptyHero: the shared NEW SESSION hero (fish headline + glow + workspace
|
||||
// row + hero InputBar), extracted from EmptyState so the bound guidance
|
||||
// state (a current session with zero messages, ConversationRoot) renders the
|
||||
// same layout without the picker wiring. Hosts own the workspace-row content
|
||||
// and the send wiring; modals ride `children` after the stack.
|
||||
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import {
|
||||
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
|
||||
/**
|
||||
* Basename label for the workspace chip / menu rows (the shared derivation);
|
||||
* empty → the design's "New Workspace" placeholder copy; separator-only
|
||||
* paths echo the raw cwd.
|
||||
* @param cwd - workspace directory path ('' for none).
|
||||
* @returns chip label.
|
||||
*/
|
||||
export function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = workspaceTitleOf(cwd)
|
||||
return base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace chip (folder + label + chevron). Locked form (bound guidance
|
||||
* state): no chevron, no menu affordance, clicks disabled — the bound
|
||||
* session's cwd is final.
|
||||
* @param props.label - chip label (see {@link workspaceLabel}).
|
||||
* @param props.locked - read-only echo form.
|
||||
* @param props.menuOpen - menu expansion echo (interactive form only).
|
||||
* @param props.onClick - menu toggle (interactive form only).
|
||||
* @returns the chip button element.
|
||||
*/
|
||||
export function WorkspaceChip({ buttonRef, label, locked = false, menuOpen = false, onClick }: {
|
||||
buttonRef?: RefObject<HTMLButtonElement>
|
||||
label: string
|
||||
locked?: boolean
|
||||
menuOpen?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label={locked ? 'Current workspace' : 'Choose workspace'}
|
||||
{...(locked ? {} : { 'aria-haspopup': 'menu' as const, 'aria-expanded': menuOpen })}
|
||||
disabled={locked}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{label}</span>
|
||||
{!locked && <IconChevronDownOutline14 className={css.chevron} size={12} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Hero-card props: both hosts supply the workspace row and their send wiring. */
|
||||
export interface EmptyHeroProps {
|
||||
/** Workspace-row content (Menu-wrapped chip in EmptyState; bare locked chip in guidance). */
|
||||
workspaceRow: ReactNode
|
||||
draft: string
|
||||
disabled: boolean
|
||||
/** Composer placeholder override (EmptyState's pick-a-workspace hint); defaults to the hero copy. */
|
||||
placeholder?: string
|
||||
error: InputBarError | null
|
||||
status?: string
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onAdd?: () => void
|
||||
/** Overlay content after the stack (EmptyState's modals). */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hero card.
|
||||
* @param props - see {@link EmptyHeroProps}.
|
||||
* @returns the centered hero element tree.
|
||||
*/
|
||||
export function EmptyHero({
|
||||
workspaceRow,
|
||||
draft,
|
||||
disabled,
|
||||
placeholder,
|
||||
error,
|
||||
status,
|
||||
onDraftChange,
|
||||
onSend,
|
||||
onAdd,
|
||||
children,
|
||||
}: EmptyHeroProps) {
|
||||
// Stable filter id so multiple hero mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className={css.workspaceRow}>{workspaceRow}</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
variant="hero"
|
||||
placeholder={placeholder ?? 'Describe what you want to build'}
|
||||
onDraftChange={onDraftChange}
|
||||
onSend={onSend}
|
||||
{...(onAdd === undefined ? {} : { onAdd })}
|
||||
addLabel="Create workspace"
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -100,11 +100,17 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace:hover,
|
||||
.workspace:not(:disabled):hover,
|
||||
.workspace[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Locked form (bound guidance state): a static echo — no hover feedback, no
|
||||
pointer affordance; label keeps full contrast. */
|
||||
.workspace:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.folder {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -121,22 +127,21 @@
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Workspace menu width tracks the longest basename in the Figma frame. */
|
||||
.workspaceMenu :global([role='menu']) {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
|
||||
/* Dialog field: 44 tall on the modal's 332 content column, r22, hairline
|
||||
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
|
||||
keeps the resting border (design shows no focus ring). */
|
||||
.modalInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -144,10 +149,6 @@
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.modalInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.modalInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
@@ -1,305 +1,78 @@
|
||||
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
|
||||
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
|
||||
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
|
||||
// composer uses (empty→content is a position move, never a swap). Project
|
||||
// options derive in-component from useSessions; Create new runs
|
||||
// createWorkspaceSession (host mkdir + session.create + open).
|
||||
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
FishLogo,
|
||||
IconChevronDownOutline14,
|
||||
IconFolderClose16,
|
||||
IconFolderOpen16,
|
||||
IconPlusOutline16,
|
||||
Menu,
|
||||
Modal,
|
||||
type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
/** Page-local Session Intent hero. */
|
||||
import { useRef, useState } from 'react'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
import { EmptyHero, WorkspaceChip } from './EmptyHero.tsx'
|
||||
|
||||
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
|
||||
const NEW_WORKSPACE = '::new-workspace'
|
||||
/** Submenu: path modal (figma 451:18655 copy). */
|
||||
const USE_EXISTING = '::use-existing'
|
||||
/** Submenu: create-workspace modal → mkdir + default session. */
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
/** Which full-page dialog is open (null = none). */
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
|
||||
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||
/** Full props composed from runtime projections, injected actions, and the declared picker slot. */
|
||||
export type EmptyStateProps = EmptyStateSlotProps
|
||||
|
||||
/** Deduped cwd set in list order (pure derivation over the sessions list). */
|
||||
function deriveCwds(state: SessionListState): readonly string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const id of state.ids) {
|
||||
const cwd = state.byId[id]?.cwd
|
||||
if (cwd !== undefined && cwd !== '') seen.add(cwd)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
export function EmptyState({
|
||||
useSessions,
|
||||
useWorkspaces,
|
||||
startSession,
|
||||
updateSessionPrompt,
|
||||
sendSession,
|
||||
renderSlot,
|
||||
}: EmptyStateProps) {
|
||||
const intent = useSessions(state => state.intent)
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
if (intent === undefined) return null
|
||||
const workspaceId = intent.target.kind === 'workspace' ? intent.target.workspaceId : undefined
|
||||
const workspace = workspaceId === undefined
|
||||
? undefined
|
||||
: workspaces.find(item => item.workspaceId === workspaceId)
|
||||
const workspaceLabel = intent.target.kind === 'workspace-intent'
|
||||
? workspaceSnapshot.intent?.name ?? 'Workspace unavailable'
|
||||
: workspace?.title ?? 'Workspace unavailable'
|
||||
const workspaceIntent = workspaceSnapshot.intent
|
||||
const busy = intent.phase === 'connecting' || workspaceIntent?.phase === 'creating'
|
||||
const status = workspaceIntent?.phase === 'creating'
|
||||
? 'Creating workspace…'
|
||||
: intent.phase === 'connecting'
|
||||
? 'Creating session…'
|
||||
: workspaceSnapshot.phase === 'pending'
|
||||
? 'Loading workspaces…'
|
||||
: undefined
|
||||
const error: InputBarError | null = workspaceIntent?.error !== undefined
|
||||
? { op: 'workspace', message: `Workspace creation failed: ${workspaceIntent.error}` }
|
||||
: intent.error === undefined
|
||||
? null
|
||||
: { op: 'session', message: `Session creation failed: ${intent.error.message}` }
|
||||
|
||||
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
|
||||
function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
const [cwd, setCwd] = useState('')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [modalKind, setModalKind] = useState<ModalKind>(null)
|
||||
const [pathDraft, setPathDraft] = useState('')
|
||||
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<InputBarError | null>(null)
|
||||
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
const text = draft.trim()
|
||||
/* v8 ignore next -- defensive: InputBar disables send while empty. */
|
||||
if (text === '' || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
const chosen = cwd.trim()
|
||||
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
.catch((reason: unknown) => {
|
||||
// The empty state survives failure with the draft intact (no session
|
||||
// exists to carry promptError; this is the only local error surface).
|
||||
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
|
||||
setSending(false)
|
||||
})
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...cwds.map(c => ({
|
||||
id: c,
|
||||
label: workspaceLabel(c),
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
})),
|
||||
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
|
||||
{
|
||||
id: NEW_WORKSPACE,
|
||||
label: 'New Workspace',
|
||||
icon: <IconPlusOutline16 size={16} />,
|
||||
submenu: [
|
||||
{ id: USE_EXISTING, label: 'Use a existing folder' },
|
||||
{ id: CREATE_NEW, label: 'Create new' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const openPathModal = (): void => {
|
||||
setPathDraft(cwd)
|
||||
setModalError(null)
|
||||
setModalKind('path')
|
||||
}
|
||||
|
||||
const openCreateModal = (): void => {
|
||||
setWorkspaceName('New WorkSpace')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
}
|
||||
|
||||
const confirmPath = (): void => {
|
||||
const next = pathDraft.trim()
|
||||
if (next === '') return
|
||||
setCwd(next)
|
||||
setModalKind(null)
|
||||
}
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
setModalError(null)
|
||||
createWorkspaceSession(workspaceName)
|
||||
.catch((reason: unknown) => {
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setCreating(false)
|
||||
})
|
||||
// Success swaps this slot out for the new session body — no local cleanup.
|
||||
}
|
||||
|
||||
const modalBusy = creating
|
||||
const isPath = modalKind === 'path'
|
||||
const isCreate = modalKind === 'create'
|
||||
const workspaceRow = (
|
||||
<>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={workspaceLabel}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
/>
|
||||
{renderSlot('conversation.empty.workspace', {
|
||||
open: pickerOpen,
|
||||
anchorRef: pickerAnchor,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
startSession(workspaceId, intent.prompt)
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className={css.workspaceRow}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
{...(cwd !== '' ? { selectedId: cwd } : {})}
|
||||
items={items}
|
||||
side="top"
|
||||
className={css.workspaceMenu!}
|
||||
onSelect={(id) => {
|
||||
if (id === USE_EXISTING) {
|
||||
setMenuOpen(false)
|
||||
openPathModal()
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
setMenuOpen(false)
|
||||
openCreateModal()
|
||||
return
|
||||
}
|
||||
setCwd(id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label="项目目录"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => { setMenuOpen(!menuOpen) }}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build, enter for / commands"
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={isPath}
|
||||
onClose={closeModal}
|
||||
title="Enter an existing folder path"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={pathDraft.trim() === ''}
|
||||
onClick={confirmPath}
|
||||
>
|
||||
Open Folder
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={pathDraft}
|
||||
aria-label="Folder path"
|
||||
autoFocus
|
||||
placeholder="ex. User/Documents/Harness/Space"
|
||||
onChange={(e) => { setPathDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmPath()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isCreate}
|
||||
onClose={closeModal}
|
||||
title="Create new workspace"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={modalBusy || workspaceName.trim() === ''}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={modalBusy}
|
||||
onChange={(e) => { setWorkspaceName(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
<EmptyHero
|
||||
workspaceRow={workspaceRow}
|
||||
draft={intent.prompt}
|
||||
disabled={busy}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
error={error}
|
||||
onDraftChange={updateSessionPrompt}
|
||||
onSend={() => { sendSession() }}
|
||||
onAdd={() => { setPickerOpen(true) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,18 +19,27 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
.error,
|
||||
.status {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,7 +9,7 @@ import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
op: 'workspace' | 'session' | 'send' | 'stop'
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -18,12 +18,17 @@ export interface InputBarProps {
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
error: InputBarError | null
|
||||
/** Observable async phase for browser fixtures and assistive technology. */
|
||||
status?: string
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
@@ -47,7 +52,8 @@ const MODEL_OPTIONS: readonly SelectOption[] = [
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
|
||||
draft, running, disabled, error, status, variant, placeholder, accessory,
|
||||
onDraftChange, onSend, onStop, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
@@ -98,7 +104,7 @@ export function InputBar({
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const primaryLabel = running ? '停止' : '发送'
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const onPrimary = (): void => {
|
||||
if (running) {
|
||||
onStop()
|
||||
@@ -129,11 +135,8 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{error !== null && (
|
||||
<div className={css.error}>
|
||||
{error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
|
||||
</div>
|
||||
)}
|
||||
{status !== undefined && <div className={css.status} role="status">{status}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error.message}</div>}
|
||||
<div className={css.card}>
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
|
||||
@@ -145,7 +148,7 @@ export function InputBar({
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')}
|
||||
placeholder={placeholder ?? (disabled ? 'Session unavailable' : running ? 'Generating a response…' : 'Message the agent')}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -159,10 +162,11 @@ export function InputBar({
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label="添加"
|
||||
title="添加"
|
||||
aria-label={addLabel}
|
||||
title={addLabel}
|
||||
disabled={locked}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
@@ -177,7 +181,7 @@ export function InputBar({
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
title={primaryLabel}
|
||||
disabled={!running && (empty || disabled)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
|
||||
Reference in New Issue
Block a user