skeleton.spec.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  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 { 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 { createChatStore } from '../src/client/stores.ts'
  15. import { SessionInputShell } from '../src/client/input/facade.ts'
  16. import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
  17. import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
  18. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  19. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  20. import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
  21. /** Machine-backed wiring over a sink spy. */
  22. function fakeWiring() {
  23. const sink = vi.fn()
  24. const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink })
  25. return { wiring: shell, sink, shell }
  26. }
  27. afterEach(cleanup)
  28. beforeEach(() => { localStorage.clear() })
  29. const sid = (id: string) => id as SessionId
  30. const wid = (id: string) => id as WorkspaceId
  31. const SID = sid('s1')
  32. function workspace(id = 'w1'): WorkspaceView {
  33. return {
  34. workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
  35. createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  36. }
  37. }
  38. const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
  39. items, state: 'idle', phase: 'ready', error: null,
  40. baselinesReady: true, recentWorkspaceId: undefined,
  41. })
  42. function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
  43. return {
  44. sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
  45. pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
  46. openState: 'open', openError: null, hasMore: false, loadingOlder: false,
  47. promptError: null, blank: false, lastAgentError: null,
  48. ...overrides,
  49. }
  50. }
  51. function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
  52. const root = sid('root')
  53. const sessions = createSnapshotStore<SessionListState>({
  54. ids: [root, SID],
  55. byId: {
  56. [root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
  57. [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
  58. },
  59. current: SID,
  60. phase: 'ready',
  61. })
  62. const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
  63. const session = createSnapshotStore<ConversationSnapshot>(snapshot)
  64. const useSession = bindSnapshotSelector(session)
  65. const chat = createChatStore().create()
  66. chat.actions.setDraft('ordinary draft')
  67. const { wiring, sink } = fakeWiring()
  68. const useInput = bindSnapshotSelector(wiring.state)
  69. const inputActions = wiring.actions
  70. const stop = vi.fn()
  71. const open = vi.fn()
  72. const retargetWorkspace = vi.fn()
  73. const slotCalls: string[] = []
  74. let pickerOwner: unknown
  75. const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
  76. slotCalls.push(key)
  77. if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
  78. if (key === 'conversation.session') {
  79. return (
  80. <ConversationSession
  81. sessionId={SID}
  82. SessionProvider={({ children }) => children(SID)}
  83. useSession={useSession}
  84. useSessions={props.useSessions}
  85. useWorkspaces={props.useWorkspaces}
  86. useInput={useInput}
  87. inputActions={inputActions}
  88. useStore={bindSnapshotSelector(chat)}
  89. actions={chat.actions}
  90. renderSlot={renderSlot as never}
  91. views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
  92. bindDraftMirror={write => wiring.bindMirror(write)}
  93. open={open}
  94. />
  95. )
  96. }
  97. if (key === 'conversation.composer.bar') {
  98. // The real entry, mounted the way the outlet composes it: standard kit
  99. // (shared with the root's props below) + this entry's inject + owner.
  100. const bar = owner as ComposerBarOwnerProps
  101. return (
  102. <InputBar
  103. sessionId={SID}
  104. SessionProvider={({ children }) => children(SID)}
  105. useSession={useSession}
  106. useSessions={props.useSessions}
  107. useWorkspaces={props.useWorkspaces}
  108. useInput={useInput}
  109. inputActions={inputActions}
  110. keyboard={wiring}
  111. stop={stop}
  112. renderSlot={(() => null) as InputBarProps['renderSlot']}
  113. {...bar}
  114. />
  115. )
  116. }
  117. return <div data-testid={`view-${opts?.only ?? key}`} />
  118. }) as ConversationRootProps['renderSlot']
  119. const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
  120. const props: ConversationRootProps = {
  121. sessionId: SID,
  122. SessionProvider: ({ children }) => children(SID),
  123. useSession,
  124. useSessions: bindSnapshotSelector(sessions),
  125. useWorkspaces: bindSnapshotSelector(workspaces),
  126. useInput,
  127. inputActions,
  128. renderSlot,
  129. renderSlotChain,
  130. selectWorkspace: retargetWorkspace,
  131. }
  132. const view = render(<ConversationRoot {...props} />)
  133. return {
  134. view, chat, sink, open, retargetWorkspace, session, slotCalls,
  135. pickerOwner: () => pickerOwner,
  136. rerender: () => { view.rerender(<ConversationRoot {...props} />) },
  137. }
  138. }
  139. describe('ConversationRoot resident composer', () => {
  140. it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
  141. const b = mount(conversationSnapshot())
  142. const box = b.view.getByRole('textbox')
  143. expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
  144. fireEvent.change(box, { target: { value: 'ordinary revised' } })
  145. expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
  146. fireEvent.keyDown(box, { key: 'Enter' })
  147. expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
  148. fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
  149. expect(b.open).toHaveBeenCalledWith(sid('root'))
  150. })
  151. it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
  152. const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
  153. // Hero chrome present, view ring absent.
  154. expect(b.view.getByText("Let's start building")).toBeTruthy()
  155. expect(b.view.queryByTestId('view-chat')).toBeNull()
  156. // The same machine-backed textarea is live in the hero, and the
  157. // persistence mirror stays bound (ConversationSession mounts chrome-less
  158. // for blank sessions): hero typing reaches the chat store.
  159. const box = b.view.getByRole('textbox')
  160. fireEvent.change(box, { target: { value: 'draft in hero' } })
  161. expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
  162. // Picker: open through the chip; a pick switches to the other
  163. // workspace's blank session (draft carry is apply-layer wiring).
  164. fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
  165. const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
  166. expect(owner.open).toBe(true)
  167. owner.onPick(wid('second'))
  168. expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
  169. })
  170. it('textarea DOM identity survives the hero → active flip', () => {
  171. const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
  172. const before = b.view.getByRole('textbox')
  173. fireEvent.change(before, { target: { value: 'kept across flip' } })
  174. // First message landed: content exists, phase leaves blank.
  175. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
  176. b.rerender()
  177. const after = b.view.getByRole('textbox')
  178. expect(after).toBe(before)
  179. expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
  180. expect(b.view.queryByText("Let's start building")).toBeNull()
  181. expect(b.view.getByTestId('view-chat')).toBeTruthy()
  182. })
  183. it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
  184. const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
  185. const chip = b.view.getByRole('button', { name: 'Choose workspace' })
  186. expect((chip as HTMLButtonElement).disabled).toBe(false)
  187. expect(b.slotCalls).toContain('conversation.hero.workspace')
  188. })
  189. it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
  190. const b = mount(conversationSnapshot({
  191. promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never },
  192. }))
  193. expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)')
  194. expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull()
  195. })
  196. })