// @vitest-environment jsdom // ConversationRoot skeleton behavior: the ONE resident composer across the // hero (blank session) and active phases — same textarea DOM node, machine- // owned draft, and the hero workspace picker (switching = retargetWorkspace). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { en, zh } from '../src/client/locales.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx' import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { ComposerBarOwnerProps, } from '../src/client/contract/slots.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { const sink = vi.fn() const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) return { wiring: shell, sink, shell } } /** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ class ResizeObserverStub { observe(): void {} unobserve(): void {} disconnect(): void {} } afterEach(() => { cleanup() vi.unstubAllGlobals() }) beforeEach(() => { localStorage.clear() vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) // Mirrors the real lookup chain (conversation namespace, then common). const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const SID = sid('s1') function workspace(id = 'w1'): WorkspaceView { return { workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', } } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...overrides, } } function mount( snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), options: { /** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */ overlayTakeover?: boolean /** The session list summary's `blank` flag — independent of the snapshot's. */ summaryBlank?: boolean /** Drop the session's summary row entirely (a session the list has not caught up with). */ omitSummaryRow?: boolean /** Classify the selected child as a subagent instead of an ordinary fork. */ summaryOrigin?: 'subagent' } = {}, ) { const root = sid('root') const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 } const childRow = { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: options.summaryBlank ?? false, updatedAt: 2, ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), } const listed = options.omitSummaryRow !== true const sessions = createSnapshotStore({ ids: listed ? [root, SID] : [root], byId: { [root]: rootRow, ...listed && { [SID]: childRow } }, current: SID, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) const useSession = bindSnapshotSelector(session) const chat = createChatStore().create() chat.actions.setDraft('ordinary draft') const { wiring, sink } = fakeWiring() const useInput = bindSnapshotSelector(wiring.state) const inputActions = wiring.actions const stop = vi.fn() const open = vi.fn() const slotCalls: string[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { slotCalls.push(key) if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null } if (key === 'conversation.session.header') { return ( children(SID)} useSession={useSession} useSessions={props.useSessions} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot as never} views={{ list: () => [ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ], subscribe: () => () => {}, version: () => 1, }} open={open} t={t} /> ) } if (key === 'conversation.session') { return ( children(SID)} useSession={useSession} useSessions={props.useSessions} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot as never} views={{ list: () => [ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ], subscribe: () => () => {}, version: () => 1, }} bindDraftMirror={write => wiring.bindMirror(write)} /> ) } if (key === 'conversation.composer.bar') { // The real entry, mounted the way the outlet composes it: standard kit // (shared with the root's props below) + this entry's inject + owner. const bar = owner as ComposerBarOwnerProps return ( children(SID)} useSession={useSession} useSessions={props.useSessions} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} keyboard={wiring} resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} useMenuLauncher={bindSnapshotSelector(createSnapshotStore(null))} stop={stop} command={() => Promise.resolve(true)} t={t} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> ) } return
}) as ConversationRootProps['renderSlot'] const renderSlotChain = ((_key, _owner, opts) => ( options.overlayTakeover === true ? ( <>
{opts?.fallback ?? null}
TAKEOVER
) : (opts?.fallback ?? null) )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { sessionId: SID, SessionProvider: ({ children }) => children(SID), useSession, useSessions: bindSnapshotSelector(sessions), useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), useInput, inputActions, renderSlot, renderSlotChain, selectWorkspace: retargetWorkspace, t, } const view = render() return { view, chat, sink, retargetWorkspace, session, slotCalls, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } } describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render() expect(view.getByText('Let\'s start building')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) }) describe('ConversationRoot resident composer', () => { it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { const b = mount(conversationSnapshot()) const box = b.view.getByRole('textbox') expect((box as HTMLTextAreaElement).value).toBe('ordinary draft') fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) it('shows hierarchy only for subagents and opens their ordinary owner', () => { const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' }) const root = b.view.getByRole('button', { name: 'Root' }) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) fireEvent.click(root) expect(b.open).toHaveBeenCalledWith(sid('root')) }) it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => { const b = mount(conversationSnapshot()) const host = b.view.container.querySelector('[data-conversation-scroll]') const seat = b.view.container.querySelector('[data-composer-seat]') const header = b.view.container.querySelector('header') const textarea = b.view.container.querySelector('textarea') expect(host).not.toBeNull() expect(seat).not.toBeNull() expect(header).not.toBeNull() // Header is column chrome above the scrollport; the seat sticks inside it. expect(host?.contains(header)).toBe(false) expect(host?.contains(seat)).toBe(true) expect(seat?.contains(textarea)).toBe(true) }) it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true }) const seat = b.view.container.querySelector('[data-composer-seat]') const takeover = b.view.getByTestId('composer-takeover') const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]') expect(seat?.contains(takeover)).toBe(true) expect(seat?.contains(fallback)).toBe(true) }) it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true }), [ { ...workspace('one'), sessionIds: [SID] }, { ...workspace('second'), title: 'Selected Folder' }, ], ) // Hero chrome present, view ring absent; scroll host already wraps the // resident composer so the blank → active flip does not remount it. const host = b.view.container.querySelector('[data-conversation-scroll]') const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') expect(b.view.getByText('开始构建吧')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the // persistence mirror stays bound (ConversationSession mounts chrome-hidden // for blank sessions): hero typing reaches the chat store. const box = b.view.getByRole('textbox') expect(host?.contains(box)).toBe(true) fireEvent.change(box, { target: { value: 'draft in hero' } }) expect(b.chat.store.getSnapshot().draft).toBe('draft in hero') // Picker: open through the chip; a pick switches to the other // workspace's blank session (draft carry is apply-layer wiring). fireEvent.click(b.view.getByRole('button', { name: '选择工作区' })) const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } expect(owner.open).toBe(true) act(() => { owner.onPick(wid('second')) }) expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second')) expect(b.view.getByText('Selected Folder')).toBeTruthy() }) it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') expect(b.view.queryByText('开始构建吧')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }), undefined, undefined, { omitSummaryRow: true }, ) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') }) it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => { const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }), undefined, undefined, { summaryBlank: true }, ) // The summary already proves the outcome, so the settling hide would only // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') expect(b.view.getByText('开始构建吧')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const before = b.view.getByRole('textbox') fireEvent.change(before, { target: { value: 'kept across flip' } }) // First message landed: content exists, phase leaves blank. Composer // already sat in the resident scrollport during hero, so the textarea // node and InputHub draft both survive. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) b.rerender() const after = b.view.getByRole('textbox') as HTMLTextAreaElement expect(after).toBe(before) expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) expect(b.view.queryByText('开始构建吧')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) it('keeps pending takeover interaction accessible outside the Chat view', () => { const b = mount(conversationSnapshot({ pending: [{} as never] })) act(() => { b.chat.actions.setView('trajectory') }) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) it('rolls the pending workspace label back when switching fails', async () => { const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true }), [ { ...workspace('one'), sessionIds: [SID] }, { ...workspace('second'), title: 'Selected Folder' }, ], selectWorkspace, ) fireEvent.click(b.view.getByRole('button', { name: '选择工作区' })) const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void } await act(async () => { owner.onPick(wid('second')); await Promise.resolve() }) expect(selectWorkspace).toHaveBeenCalledWith(wid('second')) expect(b.view.queryByText('Selected Folder')).toBeNull() expect(b.view.getByText('one')).toBeTruthy() }) it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const chip = b.view.getByRole('button', { name: '选择工作区' }) expect((chip as HTMLButtonElement).disabled).toBe(false) expect(b.slotCalls).toContain('conversation.hero.workspace') }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { const b = mount(conversationSnapshot({ promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never }, })) expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)') expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull() }) })