skeleton.client.spec.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
  1. // @vitest-environment jsdom
  2. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  3. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4. import { createContext, useContext, type ReactNode } from 'react'
  5. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  6. import type { Context } from '@deepseek-ai/cordis'
  7. import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
  8. import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
  9. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  10. import {
  11. bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture,
  12. } from '@deepseek-ai/dsh-client-test-runtime'
  13. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  14. import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  15. import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
  16. import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
  17. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  18. import { EMPTY_CONVERSATION_SNAPSHOT } from '../src/client/contract/snapshot.ts'
  19. import type { ConversationSnapshot } from '../src/client/contract/snapshot.ts'
  20. import { createConversationStore } from '../src/client/stores.ts'
  21. import { SessionInputShell } from '../src/client/input/facade.ts'
  22. import { en, zh } from '../src/client/locales.ts'
  23. import { ConversationContent } from '../src/client/skeleton/ConversationContent.tsx'
  24. import { ConversationMainPanel } from '../src/client/skeleton/ConversationMainPanel.tsx'
  25. import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
  26. import { conversationPhase } from '../src/client/contract/snapshot.ts'
  27. import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
  28. import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx'
  29. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  30. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  31. import type {
  32. ComposerBarOwnerProps, ConversationContentInputProps, ConversationContentProps,
  33. ConversationHeaderLineageOwnerProps, ConversationSessionSlotProps, ConversationSlotProps,
  34. ConversationViewsProps,
  35. } from '../src/client/contract/slots.ts'
  36. import type { ViewTab } from '../src/client/contract/views.ts'
  37. // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  38. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
  39. const FactoryViewsTestContext = createContext<ConversationViewsProps | undefined>(undefined)
  40. function StableConversationViews() {
  41. const props = useContext(FactoryViewsTestContext)
  42. if (props === undefined) throw new Error('Factory views test context is missing')
  43. return <>{props.renderSlot('conversation.session', {})}</>
  44. }
  45. // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
  46. // caret with one once the surface is genuinely contenteditable).
  47. Range.prototype.getBoundingClientRect = () => ({
  48. top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
  49. })
  50. function fakeWiring() {
  51. const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
  52. const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
  53. return { wiring: shell, sink, shell }
  54. }
  55. /** jsdom has no ResizeObserver; the root publishes its width and the composer
  56. * seat its height through one. Observed targets are recorded so a case can
  57. * fire the callback against a chosen element. */
  58. const resizeObservers: { callback: ResizeObserverCallback; targets: Element[] }[] = []
  59. class ResizeObserverStub {
  60. targets: Element[] = []
  61. constructor(callback: ResizeObserverCallback) {
  62. resizeObservers.push({ callback, targets: this.targets })
  63. }
  64. observe(target: Element): void { this.targets.push(target) }
  65. unobserve(): void {}
  66. disconnect(): void { this.targets.length = 0 }
  67. }
  68. /** Fires every recorded observer whose target list includes the element. */
  69. function fireResize(el: Element): void {
  70. for (const entry of resizeObservers) {
  71. if (entry.targets.includes(el)) entry.callback([], undefined as never)
  72. }
  73. }
  74. afterEach(() => {
  75. cleanup()
  76. vi.unstubAllGlobals()
  77. resizeObservers.length = 0
  78. })
  79. beforeEach(() => {
  80. localStorage.clear()
  81. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  82. })
  83. const t: ConversationContentProps['t'] = makeTranslate(zh, commonZh)
  84. const sid = (id: string) => id as SessionId
  85. const wid = (id: string) => id as WorkspaceId
  86. const SID = sid('s1')
  87. type SessionSlotProps = ConversationSessionSlotProps
  88. const useChat: SessionSlotProps['useChat'] = () => { throw new Error('unused') }
  89. const useTrajectory: SessionSlotProps['useTrajectory'] = () => { throw new Error('unused') }
  90. function workspace(id = 'w1'): WorkspaceView {
  91. return {
  92. workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
  93. createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  94. }
  95. }
  96. const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
  97. items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  98. })
  99. function sessionSnapshotOf(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
  100. return { ...sessionFixture(SID), ...overrides }
  101. }
  102. function mount(
  103. snapshot: SessionSnapshot,
  104. workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
  105. retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
  106. options: {
  107. /** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
  108. overlayTakeover?: boolean
  109. /** The session list summary's `blank` flag — independent of the snapshot's. */
  110. summaryBlank?: boolean
  111. /** Drop the session's summary row entirely (a session the list has not caught up with). */
  112. omitSummaryRow?: boolean
  113. /** Classify the selected child as a subagent instead of an ordinary fork. */
  114. summaryOrigin?: 'subagent'
  115. /** Insert a first-level subagent between the root and selected child. */
  116. nestedSubagent?: boolean
  117. /** A composer block another plugin raised for this session. */
  118. composerBlock?: { reason: string }
  119. /** Mutable view ledger used by registration-order regressions. */
  120. viewTabs?: ViewTab[]
  121. } = {},
  122. ) {
  123. const root = sid('root')
  124. const parent = sid('parent')
  125. const rootRow = { id: root, displayTitle: 'Root', running: false, retainedBy: {}, blank: false, updatedAt: 1 }
  126. const parentRow = {
  127. id: parent, displayTitle: 'Parent', parentId: root, origin: 'subagent' as const,
  128. running: false, retainedBy: {}, blank: false, updatedAt: 2,
  129. }
  130. const childRow = {
  131. id: SID, displayTitle: 'Child', parentId: options.nestedSubagent === true ? parent : root,
  132. cwd: '/projects/one', running: false, retainedBy: { mainView: 1 }, blank: options.summaryBlank ?? false, updatedAt: 3,
  133. ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
  134. }
  135. const listed = options.omitSummaryRow !== true
  136. const sessions = createSnapshotStore<SessionListState>({
  137. ids: listed
  138. ? [root, ...options.nestedSubagent === true ? [parent] : [], SID]
  139. : [root],
  140. byId: {
  141. [root]: rootRow,
  142. ...listed && options.nestedSubagent === true && { [parent]: parentRow },
  143. ...listed && { [SID]: childRow },
  144. },
  145. phase: 'ready', subagentsByParent: {}, jobsBySession: {},
  146. })
  147. const workspaces = createSnapshotStore<WorkspaceSnapshot>(workspaceState(workspaceRows))
  148. const session = createSnapshotStore<SessionSnapshot>(snapshot)
  149. const useSession = bindSnapshotSelector(session)
  150. const conversation = createSnapshotStore<ConversationSnapshot>(EMPTY_CONVERSATION_SNAPSHOT)
  151. const useConversation = bindSnapshotSelector(conversation)
  152. const useSessionStatus = bindSnapshotSelector(
  153. createSnapshotStore<SessionStatusSnapshot>(new Map()),
  154. )
  155. const store = createConversationStore().create()
  156. store.actions.setDraft('ordinary draft')
  157. const { wiring, sink } = fakeWiring()
  158. const useInput = bindSnapshotSelector(wiring.state)
  159. const inputActions = wiring.actions
  160. const stop = vi.fn()
  161. const open = vi.fn()
  162. const slotCalls: string[] = []
  163. const lineageOwners: ConversationHeaderLineageOwnerProps[] = []
  164. const viewTabs = options.viewTabs ?? [
  165. { id: 'chat', label: 'Chat' },
  166. { id: 'trajectory', label: 'Trajectory' },
  167. ]
  168. const useConversationViews: SessionSlotProps['useConversationViews'] = selector => selector(viewTabs)
  169. /** Owner share handed to the two composer tool-row seats, per render. */
  170. const seatOwners: { key: string; owner: unknown }[] = []
  171. let pickerOwner: unknown
  172. const renderSlot = ((key: string, owner: object, opts?: { only?: string; fallback?: ReactNode }) => {
  173. slotCalls.push(key)
  174. if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
  175. seatOwners.push({ key, owner })
  176. }
  177. if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
  178. if (key === 'conversation.session.header.lineage') {
  179. lineageOwners.push(owner as ConversationHeaderLineageOwnerProps)
  180. return opts?.fallback ?? null
  181. }
  182. if (key === 'conversation.session.header') {
  183. return (
  184. <ConversationSessionHeader
  185. sessionId={SID}
  186. SessionProvider={({ children }) => children}
  187. useSession={useSession}
  188. useConversation={useConversation}
  189. useConversationViews={useConversationViews}
  190. useChat={useChat}
  191. useTrajectory={useTrajectory}
  192. useSessions={props.useSessions}
  193. usePanelInfo={props.usePanelInfo}
  194. useResource={useResource}
  195. useSessionStatus={useSessionStatus}
  196. useSessionRetainInfo={() => undefined}
  197. useWorkspaces={props.useWorkspaces}
  198. useProjection={(() => undefined)}
  199. useInput={useInput}
  200. inputActions={inputActions}
  201. useStore={bindSnapshotSelector(store)}
  202. actions={store.actions}
  203. renderSlot={renderSlot as never}
  204. open={open}
  205. selectView={(view) => { store.actions.setView(view) }}
  206. t={t}
  207. />
  208. )
  209. }
  210. if (key === 'conversation.session') {
  211. return (
  212. <ConversationSession
  213. sessionId={SID}
  214. SessionProvider={({ children }) => children}
  215. useSession={useSession}
  216. useConversation={useConversation}
  217. useConversationViews={useConversationViews}
  218. useChat={useChat}
  219. useTrajectory={useTrajectory}
  220. useSessions={props.useSessions}
  221. usePanelInfo={props.usePanelInfo}
  222. useResource={useResource}
  223. useSessionStatus={useSessionStatus}
  224. useSessionRetainInfo={() => undefined}
  225. useWorkspaces={props.useWorkspaces}
  226. useProjection={(() => undefined)}
  227. useInput={useInput}
  228. inputActions={inputActions}
  229. useStore={bindSnapshotSelector(store)}
  230. actions={store.actions}
  231. renderSlot={renderSlot as never}
  232. bindDraftMirror={write => wiring.bindMirror(write)}
  233. openView={(view, focus) => { store.actions.openView(view, focus) }}
  234. />
  235. )
  236. }
  237. if (key === 'conversation.composer.bar') {
  238. // The real entry, mounted the way the outlet composes it: standard kit
  239. // (shared with the root's props below) + this entry's inject + owner.
  240. const bar = owner as ComposerBarOwnerProps
  241. return (
  242. <InputBar
  243. sessionId={SID}
  244. SessionProvider={({ children }) => children}
  245. useResource={useResource}
  246. useSession={useSession}
  247. useConversation={useConversation}
  248. useSessions={props.useSessions}
  249. usePanelInfo={props.usePanelInfo}
  250. useSessionStatus={useSessionStatus}
  251. useSessionRetainInfo={() => undefined}
  252. useWorkspaces={props.useWorkspaces}
  253. useProjection={(() => undefined)}
  254. useInput={useInput}
  255. inputActions={inputActions}
  256. keyboard={wiring}
  257. addFiles={() => null}
  258. useFileUploads={bindSnapshotSelector(createSnapshotStore({}))}
  259. retryFileUpload={undefined}
  260. removeAttachment={() => {}}
  261. resolveDraftAttachments={() => []}
  262. toggleCommandMenu={vi.fn()}
  263. useBusyEnter={bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue'))}
  264. useNotices={bindSnapshotSelector(wiring.notices)}
  265. useLexicon={bindSnapshotSelector(wiring.lexicon)}
  266. useMenuLauncher={bindSnapshotSelector(createSnapshotStore<string | null>(null))}
  267. stop={stop}
  268. t={t}
  269. renderSlot={((key: string, seatOwner: object) => {
  270. // The bar's own seats: recorded so a case can assert what share
  271. // each tool-row control received.
  272. seatOwners.push({ key, owner: seatOwner })
  273. return null
  274. }) as InputBarProps['renderSlot']}
  275. {...bar}
  276. />
  277. )
  278. }
  279. return <div data-testid={`view-${opts?.only ?? key}`} />
  280. }) as ConversationContentProps['renderSlot']
  281. const renderSlotChain = ((_key, _owner, opts) => (
  282. options.overlayTakeover === true
  283. ? (
  284. <>
  285. <div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
  286. {opts?.fallback ?? null}
  287. </div>
  288. <div data-testid="composer-takeover">TAKEOVER</div>
  289. </>
  290. )
  291. : (opts?.fallback ?? null)
  292. )) as ConversationContentProps['renderSlotChain']
  293. const SessionProvider: ConversationContentProps['SessionProvider'] = ({ children }) => children
  294. const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: {
  295. slots?: Record<string, (props: never) => ReactNode>
  296. }) => {
  297. const common: ConversationViewsProps = {
  298. sessionId: SID,
  299. SessionProvider,
  300. useSession,
  301. useConversation,
  302. useChat,
  303. useTrajectory,
  304. useSessions: bindSnapshotSelector(sessions),
  305. usePanelInfo: selector => selector({ activePanelId: null }),
  306. useResource,
  307. useSessionStatus,
  308. useSessionRetainInfo: () => undefined,
  309. useWorkspaces: bindSnapshotSelector(workspaces),
  310. useProjection: (() => undefined),
  311. useComposerBlock: select => select(options.composerBlock),
  312. useInput,
  313. inputActions,
  314. renderSlot,
  315. renderSlotChain,
  316. renderFactorySlot,
  317. selectWorkspace: retargetWorkspace,
  318. t,
  319. }
  320. const useFactorySlot = ((name: string, fallback: (props: never) => ReactNode) => (
  321. name === 'views' ? StableConversationViews : factoryOptions?.slots?.[name] ?? fallback
  322. )) as ConversationContentProps['useFactorySlot']
  323. return (
  324. <FactoryViewsTestContext.Provider value={common}>
  325. <ConversationContent {...({ ...common, ...input, useFactorySlot })} />
  326. </FactoryViewsTestContext.Provider>
  327. )
  328. }) as ConversationSlotProps['renderFactorySlot']
  329. const props: ConversationSlotProps = {
  330. usePanelInfo: selector => selector({ activePanelId: null }),
  331. sessionId: SID,
  332. SessionProvider,
  333. useSession,
  334. useConversation,
  335. useSessions: bindSnapshotSelector(sessions),
  336. useSessionStatus,
  337. useSessionRetainInfo: () => undefined,
  338. useResource,
  339. useWorkspaces: bindSnapshotSelector(workspaces),
  340. useProjection: (() => undefined),
  341. useInput,
  342. inputActions,
  343. renderSlot,
  344. renderFactorySlot,
  345. }
  346. const view = render(<ConversationMainPanel {...props} />)
  347. return {
  348. view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open,
  349. pickerOwner: () => pickerOwner,
  350. rerender: () => { view.rerender(<ConversationMainPanel {...props} />) },
  351. }
  352. }
  353. describe('Hero chrome', () => {
  354. it('renders the English preview badge through the hero locale seat', () => {
  355. const renderSlot = vi.fn<HeroShellProps['renderSlot']>(() => null)
  356. const view = render(<HeroShell t={makeTranslate(en, commonEn)} renderSlot={renderSlot} />)
  357. expect(view.getByText('Into the Unknown')).toBeTruthy()
  358. expect(view.getByText('Preview')).toBeTruthy()
  359. expect(renderSlot).toHaveBeenCalledOnce()
  360. expect(renderSlot.mock.calls[0]?.[0]).toBe('conversation.hero.brand.mark')
  361. const brandMarkOwner = renderSlot.mock.calls[0]?.[1]
  362. if (brandMarkOwner === undefined || !('size' in brandMarkOwner) || !('className' in brandMarkOwner)) {
  363. throw new Error('hero brand-mark owner must provide size and className')
  364. }
  365. expect(brandMarkOwner.size).toBe(34)
  366. expect(brandMarkOwner.className).toBeTypeOf('string')
  367. expect(renderSlot.mock.calls[0]?.[2]?.fallback).toBeTruthy()
  368. })
  369. })
  370. describe('ConversationRoot resident composer', () => {
  371. it('does not redispatch composer child slots for an unrelated Session publication', () => {
  372. const b = mount(sessionSnapshotOf())
  373. const childKeys = new Set([
  374. 'conversation.input.overlay',
  375. 'conversation.input.left',
  376. 'conversation.input.right',
  377. 'conversation.composer.dock',
  378. ])
  379. const dispatchCount = () => b.slotCalls.filter(key => childKeys.has(key)).length
  380. const before = dispatchCount()
  381. act(() => {
  382. const current = b.session.getSnapshot()
  383. b.session.set({ ...current, hasMore: !current.hasMore })
  384. })
  385. expect(dispatchCount()).toBe(before)
  386. })
  387. it('renders the composer inert with the blocker\u2019s own reason', () => {
  388. const b = mount(sessionSnapshotOf(), undefined, undefined, {
  389. composerBlock: { reason: 'select a model first' },
  390. })
  391. const box = b.view.getByRole('textbox')
  392. // One disabled composer with the blocker's placeholder, never a second
  393. // tree: the DOM survives the block being raised and cleared.
  394. expect(box.getAttribute('aria-disabled')).toBe('true')
  395. expect(box.getAttribute('data-placeholder')).toBe('select a model first')
  396. fireEvent.keyDown(box, { key: 'Enter' })
  397. expect(b.sink).not.toHaveBeenCalled()
  398. // The model seat stays live. Locking it too would leave the composer
  399. // asking for the one thing it prevents — every block this contract has is
  400. // cleared by choosing a model.
  401. const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
  402. expect(seat('conversation.input.model')).toEqual({ locked: false })
  403. expect(seat('conversation.input.plan')).toEqual({ locked: true })
  404. })
  405. it('lets the no-workspace posture win over a block', () => {
  406. // Picking a workspace is the earlier prerequisite; naming a model first
  407. // would send the user somewhere they cannot act yet.
  408. const b = mount(sessionSnapshotOf({ blank: true }), [], undefined, {
  409. summaryBlank: true,
  410. composerBlock: { reason: 'select a model first' },
  411. })
  412. const box = b.view.getByRole('textbox')
  413. expect(box.getAttribute('aria-disabled')).not.toBe('true')
  414. expect(box.getAttribute('contenteditable')).not.toBe('true')
  415. expect(box.getAttribute('aria-haspopup')).toBe('menu')
  416. expect(box.getAttribute('data-placeholder')).not.toBe('select a model first')
  417. const modelSeat = b.seatOwners.filter(call => call.key === 'conversation.input.model').at(-1)?.owner
  418. expect(modelSeat).toEqual({ locked: true })
  419. })
  420. it('keeps composer text in the machine, mirrors to the Conversation store, and submits through the sink', () => {
  421. const b = mount(sessionSnapshotOf())
  422. const box = b.view.getByRole('textbox')
  423. expect(b.wiring.snapshot.draft).toBe('ordinary draft')
  424. act(() => { b.wiring.setDraft('ordinary revised') })
  425. expect(b.store.store.getSnapshot().draft).toBe('ordinary revised')
  426. fireEvent.keyDown(box, { key: 'Enter' })
  427. expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal))
  428. expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
  429. expect(b.view.queryByText('Root')).toBeNull()
  430. })
  431. it('shows hierarchy only for subagents and opens their ordinary owner', () => {
  432. const b = mount(sessionSnapshotOf(), undefined, undefined, { summaryOrigin: 'subagent' })
  433. const root = b.view.getByRole('button', { name: 'Root' })
  434. expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
  435. fireEvent.click(root)
  436. expect(b.open).toHaveBeenCalledWith(sid('root'))
  437. })
  438. it('keeps intermediate subagent breadcrumbs at the compact title size', () => {
  439. const b = mount(sessionSnapshotOf(), undefined, undefined, {
  440. summaryOrigin: 'subagent',
  441. nestedSubagent: true,
  442. })
  443. expect(b.view.getByRole('button', { name: 'Root' }).className).not.toContain('crumbSubagent')
  444. expect(b.view.getByRole('button', { name: 'Parent' }).className).toContain('crumbSubagent')
  445. expect(b.view.getByRole('button', { name: 'Child' }).className).toContain('crumbSubagent')
  446. expect(b.lineageOwners.slice(-2).map(owner => owner.lineageSessionId)).toEqual([
  447. sid('parent'),
  448. SID,
  449. ])
  450. expect(b.lineageOwners.at(-2)?.openTitle).toEqual(expect.any(Function))
  451. expect(b.lineageOwners.at(-1)?.openTitle).toBeUndefined()
  452. })
  453. it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
  454. const b = mount(sessionSnapshotOf())
  455. const host = b.view.container.querySelector('[data-conversation-scroll]')
  456. const seat = b.view.container.querySelector('[data-composer-seat]')
  457. const header = b.view.container.querySelector('header')
  458. const textarea = b.view.container.querySelector<HTMLDivElement>('[data-composer-input]')
  459. expect(host).not.toBeNull()
  460. expect(seat).not.toBeNull()
  461. expect(header).not.toBeNull()
  462. // Header is column chrome above the scrollport; the seat sticks inside it.
  463. expect(host?.contains(header)).toBe(false)
  464. expect(host?.contains(seat)).toBe(true)
  465. expect(seat?.contains(textarea)).toBe(true)
  466. expect(b.slotCalls).toContain('conversation.session.header.lineage')
  467. expect(b.slotCalls).toContain('conversation.session.header.leading')
  468. expect(b.slotCalls).toContain('conversation.session.header.actions')
  469. expect(b.slotCalls).toContain('conversation.session.header.utilities')
  470. expect(b.slotCalls).toContain('conversation.session.header.corner')
  471. })
  472. it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
  473. const b = mount(sessionSnapshotOf(), undefined, undefined, { overlayTakeover: true })
  474. const seat = b.view.container.querySelector('[data-composer-seat]')
  475. const takeover = b.view.getByTestId('composer-takeover')
  476. const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
  477. expect(seat?.contains(takeover)).toBe(true)
  478. expect(seat?.contains(fallback)).toBe(true)
  479. })
  480. it('hero phase: keeps sidebar controls accessible while hiding conversation chrome', () => {
  481. const b = mount(
  482. sessionSnapshotOf({ blank: true }),
  483. [
  484. { ...workspace('one'), sessionIds: [SID] },
  485. { ...workspace('second'), title: 'Selected Folder' },
  486. ],
  487. )
  488. // Hero chrome is present and the selected View slot remains absent.
  489. const host = b.view.container.querySelector('[data-conversation-scroll]')
  490. const header = b.view.container.querySelector('header')
  491. expect(host).not.toBeNull()
  492. expect(header?.getAttribute('aria-hidden')).not.toBe('true')
  493. expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy()
  494. expect(b.view.queryByRole('tablist')).toBeNull()
  495. expect(b.slotCalls).not.toContain('conversation.session.header.utilities')
  496. expect(b.slotCalls).not.toContain('conversation.session.header.actions')
  497. expect(b.view.getByText('探索未至之境')).toBeTruthy()
  498. expect(b.view.getByText('预览版')).toBeTruthy()
  499. expect(b.view.queryByTestId('view-chat')).toBeNull()
  500. // The same machine-backed textarea is live in the hero, and the
  501. // persistence mirror stays bound (ConversationSession mounts chrome-hidden
  502. // for blank sessions): hero typing reaches the Conversation store.
  503. const box = b.view.getByRole('textbox')
  504. expect(host?.contains(box)).toBe(true)
  505. act(() => { b.wiring.setDraft('draft in hero') })
  506. expect(b.store.store.getSnapshot().draft).toBe('draft in hero')
  507. // Picker: open through the chip; a pick switches to the other
  508. // workspace's blank session (draft carry is apply-layer wiring).
  509. fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
  510. const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
  511. expect(owner.open).toBe(true)
  512. act(() => { owner.onPick(wid('second')) })
  513. expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
  514. expect(b.view.getByText('Selected Folder')).toBeTruthy()
  515. })
  516. it('keeps a rejected first prompt engaging instead of returning to the Hero', () => {
  517. const failed = sessionSnapshotOf({
  518. blank: true,
  519. promptAttempted: true,
  520. awaitingFirstTurn: true,
  521. promptError: {
  522. op: 'send',
  523. error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }),
  524. },
  525. })
  526. expect(conversationPhase(failed, EMPTY_CONVERSATION_SNAPSHOT)).toBe('engaging')
  527. const b = mount(failed, undefined, undefined, { summaryBlank: true })
  528. expect(b.view.container.querySelector('[data-phase]')?.getAttribute('data-phase')).toBe('active')
  529. expect(b.view.queryByText('探索未至之境')).toBeNull()
  530. })
  531. it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
  532. const b = mount(sessionSnapshotOf({ blank: true, openState: 'loading' }))
  533. const root = b.view.container.querySelector('[data-phase]')
  534. expect(root?.getAttribute('data-phase')).toBe('settling')
  535. expect(b.view.queryByTestId('hero-headline')).toBeNull()
  536. })
  537. it('settling phase: a session the list has no row for settles conservatively', () => {
  538. const b = mount(
  539. sessionSnapshotOf({ blank: true, openState: 'loading' }),
  540. undefined,
  541. undefined,
  542. { omitSummaryRow: true },
  543. )
  544. const root = b.view.container.querySelector('[data-phase]')
  545. expect(root?.getAttribute('data-phase')).toBe('settling')
  546. })
  547. it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
  548. const b = mount(
  549. sessionSnapshotOf({ blank: true, openState: 'loading' }),
  550. undefined,
  551. undefined,
  552. { summaryBlank: true },
  553. )
  554. // The summary already proves the outcome, so the settling hide would only
  555. // blank the column for the history round-trip.
  556. const root = b.view.container.querySelector('[data-phase]')
  557. expect(root?.getAttribute('data-phase')).toBe('hero')
  558. expect(b.view.getByText('探索未至之境')).toBeTruthy()
  559. expect(b.view.getByRole('textbox')).toBeTruthy()
  560. })
  561. it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
  562. const b = mount(sessionSnapshotOf({ blank: true }))
  563. const before = b.view.getByRole('textbox')
  564. act(() => { b.wiring.setDraft('kept across flip') })
  565. // First message landed: content exists, phase leaves blank. Composer
  566. // already sat in the resident scrollport during hero, so the textarea
  567. // node and InputHub draft both survive.
  568. b.session.set(sessionSnapshotOf({ blank: false }))
  569. b.rerender()
  570. const after = b.view.getByRole('textbox')
  571. expect(after).toBe(before)
  572. expect(b.wiring.snapshot.draft).toBe('kept across flip')
  573. expect(b.store.store.getSnapshot().draft).toBe('kept across flip')
  574. expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
  575. expect(b.view.queryByTestId('hero-headline')).toBeNull()
  576. expect(b.view.getByTestId('view-chat')).toBeTruthy()
  577. })
  578. it('keeps the Chat fallback selected by id when a view is inserted before it', () => {
  579. const viewTabs: ViewTab[] = [
  580. { id: 'chat', label: 'Chat' },
  581. { id: 'trajectory', label: 'Trajectory' },
  582. ]
  583. const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs })
  584. // A removed dynamic view leaves its persisted id behind. The visible
  585. // fallback is Chat and must stay Chat when another lower-order view lands.
  586. act(() => { b.store.actions.setView('removed-view') })
  587. expect(b.view.getByTestId('view-chat')).toBeTruthy()
  588. viewTabs.unshift({ id: 'new-view', label: 'New view' })
  589. b.rerender()
  590. expect(b.view.getByTestId('view-chat')).toBeTruthy()
  591. expect(b.view.queryByTestId('view-new-view')).toBeNull()
  592. expect(b.view.getByRole('tab', { name: 'Chat' }).getAttribute('aria-selected')).toBe('true')
  593. expect(b.view.getByRole('tab', { name: 'New view' }).getAttribute('aria-selected')).toBe('false')
  594. })
  595. it('rolls the pending workspace label back when switching fails', async () => {
  596. const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
  597. const b = mount(
  598. sessionSnapshotOf({ blank: true }),
  599. [
  600. { ...workspace('one'), sessionIds: [SID] },
  601. { ...workspace('second'), title: 'Selected Folder' },
  602. ],
  603. selectWorkspace,
  604. )
  605. fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
  606. const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
  607. await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
  608. expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
  609. expect(b.view.queryByText('Selected Folder')).toBeNull()
  610. expect(b.view.getByText('one')).toBeTruthy()
  611. })
  612. it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
  613. const b = mount(sessionSnapshotOf({ blank: true }))
  614. const chip = b.view.getByRole('button', { name: '选择工作区' })
  615. expect((chip as HTMLButtonElement).disabled).toBe(false)
  616. expect(b.slotCalls).toContain('conversation.hero.workspace')
  617. // The agent-preset chip sits in the same row, for the same reason: both
  618. // choices are only open before the first message.
  619. expect(b.slotCalls).toContain('conversation.hero.agentPreset')
  620. })
  621. it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
  622. const b = mount(sessionSnapshotOf({
  623. promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never },
  624. }))
  625. expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)')
  626. expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull()
  627. })
  628. it('publishes the column width as a px variable for the shared width axis', () => {
  629. const b = mount(sessionSnapshotOf())
  630. const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement
  631. const root = content.parentElement as HTMLElement
  632. // jsdom offsetWidth is 0 until faked: the observer publishes whatever the
  633. // layout reports, and the CSS clamp() floors the axis at 680px either way.
  634. Object.defineProperty(content, 'offsetWidth', { value: 1200, configurable: true })
  635. act(() => { fireResize(content) })
  636. expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px')
  637. // No dragged preference: the user-width override stays absent so the
  638. // adaptive clamp term applies.
  639. expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('')
  640. })
  641. it('drag → persist → window clamp round-trip on a width handle', () => {
  642. const b = mount(sessionSnapshotOf())
  643. const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement
  644. const root = content.parentElement as HTMLElement
  645. Object.defineProperty(content, 'offsetWidth', { value: 1600, configurable: true })
  646. act(() => { fireResize(content) })
  647. const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement
  648. expect(handle).not.toBeNull()
  649. // jsdom lacks pointer capture: emulate per-element so hasPointerCapture
  650. // gates pass; the finally block restores the original descriptors so the
  651. // stubs cannot leak into later tests.
  652. const names = ['setPointerCapture', 'releasePointerCapture', 'hasPointerCapture'] as const
  653. const originals = names.map(name =>
  654. [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
  655. const captured = new Set<Element>()
  656. Element.prototype.setPointerCapture = function () { captured.add(this) }
  657. Element.prototype.releasePointerCapture = function () { captured.delete(this) }
  658. Element.prototype.hasPointerCapture = function () { return captured.has(this) }
  659. try {
  660. // Base resolves from the adaptive clamp: min(1600*0.64, 920) = 920.
  661. // Dragging the right handle outward by 25px widens by 2×25 = 50 → 970,
  662. // inside both bounds (max = 1600 − 176 = 1424 keeps the handles on-column).
  663. fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 })
  664. fireEvent.pointerUp(handle, { pointerId: 1, clientX: 825, clientY: 300 })
  665. expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px')
  666. expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
  667. // Window shrinks: the displayed width re-clamps (900 − 176 = 724) but the
  668. // preference stays.
  669. Object.defineProperty(content, 'offsetWidth', { value: 900, configurable: true })
  670. act(() => { fireResize(content) })
  671. expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px')
  672. expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
  673. // A press without travel (a real double-click delivers two such
  674. // press/release rounds) must not commit the clamped display value over
  675. // the stored preference.
  676. fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 })
  677. fireEvent.pointerUp(handle, { pointerId: 1, clientX: 800, clientY: 300 })
  678. expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
  679. expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px')
  680. // No reset affordance on the handle: double-click leaves the preference alone.
  681. fireEvent.doubleClick(handle)
  682. expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
  683. } finally {
  684. for (const [name, descriptor] of originals) {
  685. if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
  686. else Object.defineProperty(Element.prototype, name, descriptor)
  687. }
  688. }
  689. })
  690. it('forwards wheel scrolling from a width handle to the transcript', () => {
  691. const b = mount(sessionSnapshotOf())
  692. const scrollport = b.view.container.querySelector('[data-conversation-scroll]') as HTMLElement
  693. const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement
  694. const scrollBy = vi.fn()
  695. Object.defineProperty(scrollport, 'clientHeight', { value: 480, configurable: true })
  696. Object.defineProperty(scrollport, 'scrollBy', { value: scrollBy, configurable: true })
  697. scrollport.style.lineHeight = '20px'
  698. fireEvent.wheel(handle, { deltaY: 120, deltaMode: 0 })
  699. expect(scrollBy).toHaveBeenLastCalledWith({ top: 120 })
  700. fireEvent.wheel(handle, { deltaY: 3, deltaMode: 1 })
  701. expect(scrollBy).toHaveBeenLastCalledWith({ top: 60 })
  702. scrollport.style.lineHeight = 'normal'
  703. fireEvent.wheel(handle, { deltaY: 3, deltaMode: 1 })
  704. expect(scrollBy).toHaveBeenLastCalledWith({ top: 48 })
  705. fireEvent.wheel(handle, { deltaY: -1, deltaMode: 2 })
  706. expect(scrollBy).toHaveBeenLastCalledWith({ top: -480 })
  707. const calls = scrollBy.mock.calls.length
  708. fireEvent.wheel(handle, { deltaY: 0, deltaMode: 0 })
  709. fireEvent.wheel(handle, { ctrlKey: true, deltaY: 120, deltaMode: 0 })
  710. expect(scrollBy).toHaveBeenCalledTimes(calls)
  711. scrollport.removeAttribute('data-conversation-scroll')
  712. const nestedScrollport = document.createElement('div')
  713. nestedScrollport.setAttribute('data-conversation-scroll', '')
  714. const nestedScrollBy = vi.fn()
  715. Object.defineProperty(nestedScrollport, 'scrollBy', { value: nestedScrollBy, configurable: true })
  716. scrollport.append(nestedScrollport)
  717. fireEvent.wheel(handle, { deltaY: 120, deltaMode: 0 })
  718. expect(scrollBy).toHaveBeenCalledTimes(calls)
  719. expect(nestedScrollBy).not.toHaveBeenCalled()
  720. })
  721. it('starts width dragging only from the primary pointer button', () => {
  722. const b = mount(sessionSnapshotOf())
  723. const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement
  724. const captured = new Set<number>()
  725. Object.defineProperties(handle, {
  726. setPointerCapture: { configurable: true, value: (pointerId: number) => { captured.add(pointerId) } },
  727. releasePointerCapture: { configurable: true, value: (pointerId: number) => { captured.delete(pointerId) } },
  728. hasPointerCapture: { configurable: true, value: (pointerId: number) => captured.has(pointerId) },
  729. })
  730. fireEvent.pointerDown(handle, { pointerId: 1, button: 1, clientX: 800, clientY: 300 })
  731. expect(handle.hasAttribute('data-dragging')).toBe(false)
  732. fireEvent.pointerDown(handle, { pointerId: 2, button: 0, clientX: 800, clientY: 300 })
  733. expect(handle.hasAttribute('data-dragging')).toBe(true)
  734. fireEvent.pointerCancel(handle, { pointerId: 2 })
  735. })
  736. it('hero phase renders no width handles (no transcript to size)', () => {
  737. const b = mount(sessionSnapshotOf({ blank: true }))
  738. expect(b.view.container.querySelector('[data-width-handle]')).toBeNull()
  739. })
  740. })