diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml new file mode 100644 index 0000000000..5f41ac4b70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db +2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md new file mode 100644 index 0000000000..d730f3e0b6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -0,0 +1,23 @@ +# Agent Note: New Session clears onto the empty-state launch + +Status: implemented + +English | [中文](2026-07-24-new-session-clears-to-empty-state.zh.md) + +## Problem + +Sidebar "New Session" created and opened a blank session immediately, so the center column showed `ConversationRoot` with an empty transcript and the resident composer. The Figma NEW SESSION screen (`EmptyState` + shared `InputBar` hero) only rendered when `sessions.current` was already undefined, so the launch page was unreachable from the primary creation control. + +## Decision + +`SessionsService.clear()` wipes the persisted selection and `list.current`. Top-level sidebar creation entries (`onCreate()` with no cwd — New Session and New Workspace) call `clear()` so `AppFrame` renders `conversation.empty`. The empty state's first send still runs `conversation.startSession` (create → open → send) and reuses the same `InputBar` component as the resident composer (`variant="hero"`). Per-project "+" (`onCreate(cwd)`) keeps create-then-open until the empty-state picker can accept a seeded cwd. + +## Alternatives considered + +**Keep create-then-open for New Session and add a second empty chrome inside ConversationRoot when the transcript is empty.** Rejected: that duplicates the launch InputBar and breaks the empty→content ruling that one InputBar moves position rather than swapping components. + +**Route New Session through a dedicated route or slot outside selection.** Rejected for this pass: `conversation.empty` already owns the launch UI; clearing `current` is the existing empty branch. + +## Consequences + +New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b9116971d0..324c574474 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -95,9 +95,10 @@ export class SessionsService { /** * Persisted selection cell (the durable half of `list.current`). Private on * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open}. Projection validates it against the live list - * instead of destructively pruning, so a selection survives transient list - * states (reconnect re-pull) and resurfaces when its session returns. + * SessionsService.open} / {@link SessionsService.clear}. Projection + * validates it against the live list instead of destructively pruning, so a + * selection survives transient list states (reconnect re-pull) and + * resurfaces when its session returns. */ private readonly selection: SnapshotStore<{ sessionId?: SessionId }> @@ -137,7 +138,7 @@ export class SessionsService { /** * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere (the sole selection write path). + * nowhere. * @param id - session id (must exist in the list store). */ open(id: SessionId): void { @@ -148,6 +149,17 @@ export class SessionsService { this.list.update((draft) => { draft.current = id }) } + /** + * Clear the current selection so the layout shows the no-session empty + * state. Wipes the persisted selection too — a reload stays on empty until + * the user opens or starts a session. Staging holds the previous occupant + * across the blank (same masked-gap rule as a transient list miss). + */ + clear(): void { + this.selection.set({}) + this.list.update((draft) => { draft.current = undefined }) + } + /** * Create a session on the host. * @param opts - creation options (project directory). diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 97f223548c..33cfce43bb 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone }) + it('clear() blanks list.current and the persisted selection', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + clear: () => { storage.clear() }, + }) + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + expect(storage.get('dsh.sessions.current')).toContain('s1') + b.svc.clear() + expect(b.svc.list.getSnapshot().current).toBeUndefined() + // Persisted wipe: a fresh service with the same storage stays on empty. + const again = bench() + await feedList(again, [{ id: 's1' }]) + expect(again.svc.list.getSnapshot().current).toBeUndefined() + }) + it('masks (not destroys) the selection while its session is off the list', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index fbe2f25c09..53e618b122 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero: headline over the shared InputBar card, centered in the - conversation column. The card is the same component as the composer — - only positioning lives here. */ +/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the + shared InputBar card. The input itself is InputBar — only stack geometry + lives here. */ .root { display: flex; @@ -11,16 +11,18 @@ padding: 24px; } -/* figma hero group 34:10409: headline block sits 36px above the input card. */ -.card { +/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +.stack { display: flex; flex-direction: column; - gap: 36px; + align-items: stretch; + gap: 40px; width: 100%; max-width: 776px; + overflow: visible; } -/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ .headline { display: flex; align-items: center; @@ -32,37 +34,98 @@ color: var(--dsw-alias-label-primary); } -/* figma 34:10412/10413: brand-blue vector. */ +/* figma fish fill rides business blue. */ .fish { flex: none; color: var(--dsw-alias-state-business-primary); } -.picker { +/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is + centered on this block so it stays under the picker + InputBar together. */ +.body { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + overflow: visible; +} + +/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ +.glow { + position: absolute; + left: 50%; + top: 50%; + z-index: 0; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.body > :not(.glow) { + position: relative; + z-index: 1; +} + +.workspaceRow { display: flex; align-items: center; min-width: 0; + /* Align with InputBar's left chrome (card pad 10 + attach). */ + padding-left: 10px; } -.select, -.customInput { - max-width: 320px; - padding: 4px 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-bg-base); - font-size: 13px; +/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +.workspace { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 4px 0 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 14px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.workspace:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.folder { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.workspaceLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); } .customInput { - width: 320px; + width: min(320px, 100%); + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; outline: none; + background: var(--dsw-alias-bg-base); + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); } .customInput:focus { - /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ border-color: var(--dsw-alias-state-business-primary); - color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index 420ff7a622..edcf0cbad2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,21 +1,27 @@ -// EmptyState (figma NEW SESSION screen): centered hero card built around the -// SAME InputBar component the resident composer uses (the empty→content -// transition is one component changing position, never a swap). Project -// picker: cwd set derived in-component from the standard useSessions hook -// (subscription is the framework's, derivation is a pure function — design -// §6) plus a free-form new-directory input; submit runs the startSession -// chain (create → open → send) in one service call. +// EmptyState (figma NEW SESSION screen): centered hero — fish + title, +// workspace picker row, then the SAME InputBar the resident composer uses +// (empty→content is a position move, never a swap). Project picker: cwd set +// derived in-component from useSessions plus a free-form new-directory path; +// submit runs startSession (create → open → send). -import { useMemo, useState } from 'react' -import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' +import { useId, useMemo, useState } from 'react' +import { + FishLogo, + IconChevronDownOutline14, + IconFolderOpen16, + Menu, + type MenuItem, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */ +/** Menu id for the free-form directory entry (not a filesystem path). */ const NEW_DIR = '::new-directory' +/** Menu id for the host default project directory (empty cwd on create). */ +const DEFAULT_DIR = '::default' /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -30,16 +36,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } +/** Basename for the workspace chip; 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 }: 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 [cwd, setCwd] = useState('') const [custom, setCustom] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) const [sending, setSending] = useState(false) const [error, setError] = useState(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() @@ -58,61 +74,105 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const picker = ( -
- {custom - ? ( - { setCwd(e.target.value) }} - /> - ) - : ( - { setCwd(e.target.value) }} + /> + ) + : ( + { setMenuOpen(false) }} + selectedId={selectedId} + items={items} + onSelect={(id) => { + if (id === NEW_DIR) { + setCustom(true) + setCwd('') + } else if (id === DEFAULT_DIR) { + setCustom(false) + setCwd('') + } else { + setCustom(false) + setCwd(id) + } + setMenuOpen(false) + }} + anchor={( + )} -
- ) + /> + ) return (
-
+
- {/* figma 34:10412: fish 34x25 leading the headline, gap 10. */} + {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} Let's start building
- {}} - /> +
+ {/* figma 313:14109: soft ellipse behind workspace + InputBar; width + tracks the card (1051/776) so blur scales in userSpace with it. */} + +
{workspace}
+ {}} + /> +
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1139c3c9c1..04e4f661c5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -119,12 +119,81 @@ min-height: 84px; } -/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */ +/* Toolbar: attach + Plan + Read-only on the left; model + send on the right + (figma Input_Bottom chrome). */ .row { display: flex; align-items: center; - justify-content: flex-end; - padding: 0 10px 10px 12px; + justify-content: space-between; + gap: 12px; + padding: 0 10px 10px 10px; + min-width: 0; +} + +.tools, +.trailing { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.trailing { + flex: none; + gap: 8px; +} + +/* Attach circle (figma + control): 28px, selector fill, primary glyph. */ +.add { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: var(--dsw-specific-selector); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.add:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.add:disabled { + opacity: 0.5; + cursor: default; +} + +/* Plan / Read-only / model — native state, no host wiring. -import { useEffect, useRef } from 'react' -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' +import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './InputBar.module.css' /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ @@ -24,13 +28,33 @@ export interface InputBarProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' placeholder?: string - /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ + /** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */ accessory?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void } +interface SelectOption { + id: string + label: string +} + +const PLAN_OPTIONS: readonly SelectOption[] = [ + { id: 'plan', label: 'Plan' }, + { id: 'agent', label: 'Agent' }, +] + +const READONLY_OPTIONS: readonly SelectOption[] = [ + { id: 'readonly', label: 'Read-only' }, + { id: 'readwrite', label: 'Read-write' }, +] + +const MODEL_OPTIONS: readonly SelectOption[] = [ + { id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' }, + { id: 'v4-pro', label: 'DeepSeek-V4-Pro' }, +] + export function InputBar({ draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, }: InputBarProps) { @@ -48,6 +72,11 @@ export function InputBar({ }, 10) } + // Placeholder chrome: selection is local until plan/mode/model seams land. + const [planId, setPlanId] = useState('plan') + const [readonlyId, setReadonlyId] = useState('readonly') + const [modelId, setModelId] = useState('v4-pro-high') + // Locked while running: the browser drops keystrokes AND focus on a disabled // textarea — no sending mid-turn, stop or wait. const locked = disabled || running @@ -88,6 +117,25 @@ export function InputBar({ if (!empty && !disabled) onSend('queue') } + const renderSelect = ( + aria: string, + value: string, + options: readonly SelectOption[], + onPick: (id: string) => void, + ): ReactNode => ( + + ) + return (
{error !== null && ( @@ -116,25 +164,42 @@ export function InputBar({
{`${draft}\n`}
- +
+ + {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
+
+ {renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} + +
diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 660127946b..6bf58f7d59 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -19,7 +19,10 @@ function setup(over?: Partial) { } const view = render() const textarea = view.container.querySelector('textarea')! - const button = view.container.querySelector('button')! + // aria-label (not role name): title also contains 发送/停止 and would double-match. + const button = view.container.querySelector( + `button[aria-label="${over?.running === true ? '停止' : '发送'}"]`, + )! return { view, textarea, button, props } } @@ -97,7 +100,7 @@ describe('running lock and primary button', () => { const textarea = view.container.querySelector('textarea')! expect(document.activeElement).toBe(textarea) textarea.blur() - fireEvent.mouseDown(view.container.querySelector('button')!) + fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!) expect(document.activeElement).toBe(textarea) }) @@ -129,3 +132,38 @@ describe('error strip and variants', () => { expect(view.container.querySelector('[class*="hero"]')).not.toBeNull() }) }) + +describe('placeholder chrome', () => { + it('renders attach / Plan / Read-only / model controls', () => { + const { view } = setup() + expect(view.getByLabelText('添加')).toBeTruthy() + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan') + expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high') + }) + + it('native select change updates the selected option', () => { + const { view } = setup() + const plan = view.getByLabelText('Plan mode') as HTMLSelectElement + fireEvent.change(plan, { target: { value: 'agent' } }) + expect(plan.value).toBe('agent') + const access = view.getByLabelText('Access mode') as HTMLSelectElement + fireEvent.change(access, { target: { value: 'readwrite' } }) + expect(access.value).toBe('readwrite') + }) + + it('model select can drop the High option', () => { + const { view } = setup() + const model = view.getByLabelText('Model') as HTMLSelectElement + fireEvent.change(model, { target: { value: 'v4-pro' } }) + expect(model.value).toBe('v4-pro') + expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro') + }) + + it('running locks the chrome selects and attach control', () => { + const { view } = setup({ running: true }) + expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true) + expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true) + }) +}) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 10dba860c0..b93b9c0b72 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -261,7 +261,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( { startSession={startSession} />, ) - const select = view.container.querySelector('select')! - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/proj', '::new-directory']) - fireEvent.change(select, { target: { value: '/proj' } }) - expect((select as HTMLSelectElement).value).toBe('/proj') - fireEvent.change(select, { target: { value: '::new-directory' } }) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/proj', 'New directory…']) + fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 55cb46be2c..b2636bfd4b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -28,7 +28,8 @@ const sid = (s: string): SessionId => s as SessionId afterEach(cleanup) beforeEach(() => { - localStorage.clear() + // jsdom normally provides localStorage; some host Node builds surface it as undefined. + globalThis.localStorage?.clear() }) /** Minimal conversation snapshot slice the skeleton reads. */ @@ -95,11 +96,13 @@ describe('EmptyState', () => { const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) render() - const select = screen.getByRole('combobox', { name: '项目目录' }) - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/w/app', '/w/lib', '::new-directory']) - fireEvent.change(select, { target: { value: '/w/app' } }) - const box = screen.getByPlaceholderText('Message to run task, plan and build') + const trigger = screen.getByRole('button', { name: '项目目录' }) + fireEvent.click(trigger) + const menu = screen.getByRole('menu') + expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) + fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' }) @@ -110,11 +113,12 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the select for a free-form input', () => { + it('new-directory option swaps the chip for a free-form input', () => { const { useSessions } = fakeSessions([]) render( Promise.resolve()} />) - fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } }) - const custom = screen.getByPlaceholderText(/目录路径/) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) + const custom = screen.getByPlaceholderText(/Directory path/) fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 7529bfe89c..c165455b1a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 3012a760af..a5ce65ef59 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -25,8 +25,8 @@ export type SidebarRootInjected = { /** Open (switch to) a session. */ onOpen: (id: SessionId) => void /** - * Create a session and open it; cwd targets a project group (the - * sidebar's three creation entries all land in the new session). + * New-session affordance: no cwd clears selection onto the empty-state + * launch; a cwd create-then-opens a session in that project group. */ onCreate: (cwd?: string) => void /** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index fe799a864f..be757ca183 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -27,9 +27,15 @@ export function apply(ctx: ClientContext): void { // list snapshot); layout keeps only panel geometry. onOpen: (id) => { ctx.sessions.open(id) }, onCreate: (cwd) => { - // Create-then-open: the sidebar's three creation entries all land - // in the new session (empty-state first-send stays with ui-conversation). - void ctx.sessions.create(cwd === undefined ? {} : { cwd }) + // Top-level New Session / New Workspace: clear selection so AppFrame + // shows conversation.empty (EmptyState + shared InputBar). Per-project + // "+" still create-then-opens into that cwd until workspace seeding + // reaches the empty-state picker. + if (cwd === undefined) { + ctx.sessions.clear() + return + } + void ctx.sessions.create({ cwd }) .then((id: SessionId) => { ctx.sessions.open(id) }) }, onToggleSidebar: () => { ctx.layout.toggleSidebar() }, diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 6b6b4f9474..44fdae18f8 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -26,7 +26,12 @@ async function bench() { byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, current: undefined, }) - const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() } + const sessions = { + list, + create: vi.fn(async () => sid('minted')), + open: vi.fn(), + clear: vi.fn(), + } const layout = { toggleSidebar: vi.fn() } ctx.provide('sessions', sessions) ctx.provide('layout', layout) @@ -91,14 +96,15 @@ describe('apply', () => { expect(sessions.open).toHaveBeenCalledWith('a') injected.onCreate() - expect(sessions.create).toHaveBeenCalledWith({}) + expect(sessions.clear).toHaveBeenCalledOnce() + expect(sessions.create).not.toHaveBeenCalled() + + injected.onCreate('/proj') + expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) // create-then-open lands after the create promise resolves. await Promise.resolve() await Promise.resolve() expect(sessions.open).toHaveBeenCalledWith('minted') - - injected.onCreate('/proj') - expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) }) it('teardown unregisters the slot entry', async () => {