feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions
@@ -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&apos;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&apos;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}