skeleton.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. // @vitest-environment jsdom
  2. // ConversationRoot skeleton behavior: the ONE resident composer across the
  3. // hero (blank session) and active phases — same textarea DOM node, machine-
  4. // owned draft, and the hero workspace picker (switching = retargetWorkspace).
  5. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  6. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  7. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  8. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  9. import type {
  10. ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
  11. } from '@deepseek-ai/dsh-client-runtime/client'
  12. import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
  13. import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
  14. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  15. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  16. import { createChatStore } from '../src/client/stores.ts'
  17. import { SessionInputShell } from '../src/client/input/facade.ts'
  18. import { zh } from '../src/client/locales.ts'
  19. import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
  20. import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
  21. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  22. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  23. import type {
  24. ComposerBarOwnerProps,
  25. } from '../src/client/contract/slots.ts'
  26. /** Machine-backed wiring over a sink spy. */
  27. function fakeWiring() {
  28. const sink = vi.fn()
  29. const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink })
  30. return { wiring: shell, sink, shell }
  31. }
  32. /** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
  33. class ResizeObserverStub {
  34. observe(): void {}
  35. unobserve(): void {}
  36. disconnect(): void {}
  37. }
  38. afterEach(() => {
  39. cleanup()
  40. vi.unstubAllGlobals()
  41. })
  42. beforeEach(() => {
  43. localStorage.clear()
  44. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  45. })
  46. // Mirrors the real lookup chain (conversation namespace, then common).
  47. const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh)
  48. const sid = (id: string) => id as SessionId
  49. const wid = (id: string) => id as WorkspaceId
  50. const SID = sid('s1')
  51. function workspace(id = 'w1'): WorkspaceView {
  52. return {
  53. workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
  54. createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  55. }
  56. }
  57. const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
  58. items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  59. baselinesReady: true, recentWorkspaceId: undefined,
  60. })
  61. function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
  62. return {
  63. sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
  64. pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
  65. openState: 'open', openError: null, hasMore: false, loadingOlder: false,
  66. promptError: null, blank: false, subagent: null, lastAgentError: null,
  67. ...overrides,
  68. }
  69. }
  70. function mount(
  71. snapshot: ConversationSnapshot,
  72. workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
  73. retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
  74. options: {
  75. /** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
  76. overlayTakeover?: boolean
  77. /** The session list summary's `blank` flag — independent of the snapshot's. */
  78. summaryBlank?: boolean
  79. /** Drop the session's summary row entirely (a session the list has not caught up with). */
  80. omitSummaryRow?: boolean
  81. /** Classify the selected child as a subagent instead of an ordinary fork. */
  82. summaryOrigin?: 'subagent'
  83. } = {},
  84. ) {
  85. const root = sid('root')
  86. const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
  87. const childRow = {
  88. id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
  89. running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
  90. ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
  91. }
  92. const listed = options.omitSummaryRow !== true
  93. const sessions = createSnapshotStore<SessionListState>({
  94. ids: listed ? [root, SID] : [root],
  95. byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
  96. current: SID,
  97. phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
  98. })
  99. const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
  100. const session = createSnapshotStore<ConversationSnapshot>(snapshot)
  101. const useSession = bindSnapshotSelector(session)
  102. const chat = createChatStore().create()
  103. chat.actions.setDraft('ordinary draft')
  104. const { wiring, sink } = fakeWiring()
  105. const useInput = bindSnapshotSelector(wiring.state)
  106. const inputActions = wiring.actions
  107. const stop = vi.fn()
  108. const open = vi.fn()
  109. const slotCalls: string[] = []
  110. let pickerOwner: unknown
  111. const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
  112. slotCalls.push(key)
  113. if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
  114. if (key === 'conversation.session') {
  115. return (
  116. <ConversationSession
  117. sessionId={SID}
  118. SessionProvider={({ children }) => children(SID)}
  119. useSession={useSession}
  120. useSessions={props.useSessions}
  121. useWorkspaces={props.useWorkspaces}
  122. useProjection={(() => undefined)}
  123. useInput={useInput}
  124. inputActions={inputActions}
  125. useStore={bindSnapshotSelector(chat)}
  126. actions={chat.actions}
  127. renderSlot={renderSlot as never}
  128. views={{
  129. list: () => [
  130. { id: 'chat', label: 'Chat' },
  131. { id: 'trajectory', label: 'Trajectory' },
  132. ],
  133. subscribe: () => () => {},
  134. version: () => 1,
  135. }}
  136. bindDraftMirror={write => wiring.bindMirror(write)}
  137. open={open}
  138. t={t}
  139. {...owner}
  140. />
  141. )
  142. }
  143. if (key === 'conversation.composer.bar') {
  144. // The real entry, mounted the way the outlet composes it: standard kit
  145. // (shared with the root's props below) + this entry's inject + owner.
  146. const bar = owner as ComposerBarOwnerProps
  147. return (
  148. <InputBar
  149. sessionId={SID}
  150. SessionProvider={({ children }) => children(SID)}
  151. useSession={useSession}
  152. useSessions={props.useSessions}
  153. useWorkspaces={props.useWorkspaces}
  154. useProjection={(() => undefined)}
  155. useInput={useInput}
  156. inputActions={inputActions}
  157. keyboard={wiring}
  158. resolveSubmitMode={() => 'queue'}
  159. toggleCommandMenu={vi.fn()}
  160. useNotices={bindSnapshotSelector(wiring.notices)}
  161. useLexicon={bindSnapshotSelector(wiring.lexicon)}
  162. useMenuLauncher={bindSnapshotSelector(createSnapshotStore<string | null>(null))}
  163. stop={stop}
  164. command={() => Promise.resolve(true)}
  165. t={t}
  166. renderSlot={(() => null) as InputBarProps['renderSlot']}
  167. {...bar}
  168. />
  169. )
  170. }
  171. return <div data-testid={`view-${opts?.only ?? key}`} />
  172. }) as ConversationRootProps['renderSlot']
  173. const renderSlotChain = ((_key, _owner, opts) => (
  174. options.overlayTakeover === true
  175. ? (
  176. <>
  177. <div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
  178. {opts?.fallback ?? null}
  179. </div>
  180. <div data-testid="composer-takeover">TAKEOVER</div>
  181. </>
  182. )
  183. : (opts?.fallback ?? null)
  184. )) as ConversationRootProps['renderSlotChain']
  185. const props: ConversationRootProps = {
  186. sessionId: SID,
  187. SessionProvider: ({ children }) => children(SID),
  188. useSession,
  189. useSessions: bindSnapshotSelector(sessions),
  190. useWorkspaces: bindSnapshotSelector(workspaces),
  191. useProjection: (() => undefined),
  192. useInput,
  193. inputActions,
  194. renderSlot,
  195. renderSlotChain,
  196. selectWorkspace: retargetWorkspace,
  197. t,
  198. }
  199. const view = render(<ConversationRoot {...props} />)
  200. return {
  201. view, chat, sink, retargetWorkspace, session, slotCalls, open,
  202. pickerOwner: () => pickerOwner,
  203. rerender: () => { view.rerender(<ConversationRoot {...props} />) },
  204. }
  205. }
  206. describe('ConversationRoot resident composer', () => {
  207. it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
  208. const b = mount(conversationSnapshot())
  209. const box = b.view.getByRole('textbox')
  210. expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
  211. fireEvent.change(box, { target: { value: 'ordinary revised' } })
  212. expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
  213. fireEvent.keyDown(box, { key: 'Enter' })
  214. expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
  215. expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
  216. expect(b.view.queryByText('Root')).toBeNull()
  217. })
  218. it('shows hierarchy only for subagents and opens their ordinary owner', () => {
  219. const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' })
  220. const root = b.view.getByRole('button', { name: 'Root' })
  221. expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
  222. fireEvent.click(root)
  223. expect(b.open).toHaveBeenCalledWith(sid('root'))
  224. })
  225. it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
  226. const b = mount(conversationSnapshot())
  227. const host = b.view.container.querySelector('[data-conversation-scroll]')
  228. const seat = b.view.container.querySelector('[data-composer-seat]')
  229. const header = b.view.container.querySelector('header')
  230. const textarea = b.view.container.querySelector('textarea')
  231. expect(host).not.toBeNull()
  232. expect(seat).not.toBeNull()
  233. expect(header).not.toBeNull()
  234. // Header is column chrome above the scrollport; the seat sticks inside it.
  235. expect(host?.contains(header)).toBe(false)
  236. expect(host?.contains(seat)).toBe(true)
  237. expect(seat?.contains(textarea)).toBe(true)
  238. })
  239. it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
  240. const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true })
  241. const seat = b.view.container.querySelector('[data-composer-seat]')
  242. const takeover = b.view.getByTestId('composer-takeover')
  243. const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
  244. expect(seat?.contains(takeover)).toBe(true)
  245. expect(seat?.contains(fallback)).toBe(true)
  246. })
  247. it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
  248. const b = mount(
  249. conversationSnapshot({ composerPhase: 'blank', blank: true }),
  250. [
  251. { ...workspace('one'), sessionIds: [SID] },
  252. { ...workspace('second'), title: 'Selected Folder' },
  253. ],
  254. )
  255. // Hero chrome present, view ring absent; scroll host already wraps the
  256. // resident composer so the blank → active flip does not remount it.
  257. const host = b.view.container.querySelector('[data-conversation-scroll]')
  258. const header = b.view.container.querySelector('header')
  259. expect(host).not.toBeNull()
  260. expect(header?.getAttribute('aria-hidden')).toBe('true')
  261. expect(b.view.getByText('开始构建吧')).toBeTruthy()
  262. expect(b.view.queryByTestId('view-chat')).toBeNull()
  263. // The same machine-backed textarea is live in the hero, and the
  264. // persistence mirror stays bound (ConversationSession mounts chrome-hidden
  265. // for blank sessions): hero typing reaches the chat store.
  266. const box = b.view.getByRole('textbox')
  267. expect(host?.contains(box)).toBe(true)
  268. fireEvent.change(box, { target: { value: 'draft in hero' } })
  269. expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
  270. // Picker: open through the chip; a pick switches to the other
  271. // workspace's blank session (draft carry is apply-layer wiring).
  272. fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
  273. const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
  274. expect(owner.open).toBe(true)
  275. act(() => { owner.onPick(wid('second')) })
  276. expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
  277. expect(b.view.getByText('Selected Folder')).toBeTruthy()
  278. })
  279. it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
  280. const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
  281. const root = b.view.container.querySelector('[data-phase]')
  282. expect(root?.getAttribute('data-phase')).toBe('settling')
  283. expect(b.view.queryByText('开始构建吧')).toBeNull()
  284. })
  285. it('settling phase: a session the list has no row for settles conservatively', () => {
  286. const b = mount(
  287. conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
  288. undefined,
  289. undefined,
  290. { omitSummaryRow: true },
  291. )
  292. const root = b.view.container.querySelector('[data-phase]')
  293. expect(root?.getAttribute('data-phase')).toBe('settling')
  294. })
  295. it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
  296. const b = mount(
  297. conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
  298. undefined,
  299. undefined,
  300. { summaryBlank: true },
  301. )
  302. // The summary already proves the outcome, so the settling hide would only
  303. // blank the column for the history round-trip.
  304. const root = b.view.container.querySelector('[data-phase]')
  305. expect(root?.getAttribute('data-phase')).toBe('hero')
  306. expect(b.view.getByText('开始构建吧')).toBeTruthy()
  307. expect(b.view.getByRole('textbox')).toBeTruthy()
  308. })
  309. it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
  310. const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
  311. const before = b.view.getByRole('textbox')
  312. fireEvent.change(before, { target: { value: 'kept across flip' } })
  313. // First message landed: content exists, phase leaves blank. Composer
  314. // already sat in the Session scrollport during hero, so the textarea
  315. // node and InputHub draft both survive.
  316. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
  317. b.rerender()
  318. const after = b.view.getByRole('textbox') as HTMLTextAreaElement
  319. expect(after).toBe(before)
  320. expect(after.value).toBe('kept across flip')
  321. expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
  322. expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
  323. expect(b.view.queryByText('开始构建吧')).toBeNull()
  324. expect(b.view.getByTestId('view-chat')).toBeTruthy()
  325. })
  326. it('keeps pending takeover interaction accessible outside the Chat view', () => {
  327. const b = mount(conversationSnapshot({ pending: [{} as never] }))
  328. act(() => { b.chat.actions.setView('trajectory') })
  329. expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
  330. expect(b.view.getByRole('textbox')).toBeTruthy()
  331. })
  332. it('rolls the pending workspace label back when switching fails', async () => {
  333. const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
  334. const b = mount(
  335. conversationSnapshot({ composerPhase: 'blank', blank: true }),
  336. [
  337. { ...workspace('one'), sessionIds: [SID] },
  338. { ...workspace('second'), title: 'Selected Folder' },
  339. ],
  340. selectWorkspace,
  341. )
  342. fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
  343. const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
  344. await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
  345. expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
  346. expect(b.view.queryByText('Selected Folder')).toBeNull()
  347. expect(b.view.getByText('one')).toBeTruthy()
  348. })
  349. it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
  350. const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
  351. const chip = b.view.getByRole('button', { name: '选择工作区' })
  352. expect((chip as HTMLButtonElement).disabled).toBe(false)
  353. expect(b.slotCalls).toContain('conversation.hero.workspace')
  354. })
  355. it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
  356. const b = mount(conversationSnapshot({
  357. promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never },
  358. }))
  359. expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)')
  360. expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull()
  361. })
  362. })