views.client.spec.tsx 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525
  1. // @vitest-environment jsdom
  2. /**
  3. * View registration acceptance on the real framework stack: the plugin fiber
  4. * registers Trajectory into a real SlotRegistry view ring, tabs
  5. * switch inside ConversationRoot (renderSlot share driven by the same tab
  6. * projection apply uses) without collapsing chat, trajectory renders the
  7. * event ledger with its timing overview, and fiber disposal removes the tab.
  8. * Timeline projection and inclusive focus edge cases ride along.
  9. */
  10. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  11. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  12. import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
  13. import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
  14. import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
  15. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  16. import {
  17. EMPTY_CONVERSATION_SNAPSHOT, UiConversation,
  18. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  19. import type {
  20. ConversationBinding, ConversationSnapshot, ConversationViewSnapshotMap, ConvViewProps,
  21. InputActions, InputState, RequestView, ViewTab,
  22. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  23. import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client'
  24. import type {
  25. ChatSnapshot, LegacyConversationSlice,
  26. } from '@deepseek-ai/dsh-client-ui-chat/client'
  27. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  28. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  29. import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
  30. import type {
  31. SessionBinding, SessionListState, SessionProjectionMap, SessionSnapshot, UseProjection,
  32. } from '@deepseek-ai/dsh-api-session-controller/client'
  33. import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
  34. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  35. import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  36. import {
  37. ConversationSession, ConversationSessionHeader,
  38. type ConversationSessionHeaderProps, type ConversationSessionProps,
  39. } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
  40. import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
  41. import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
  42. import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
  43. import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
  44. import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
  45. import type { TrajectoryTurnModel } from '../src/client/layout.ts'
  46. import { TrajectoryTimeline as LocalizedTrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
  47. import {
  48. TrajectoryView, type TrajectoryViewInjected,
  49. } from '../src/client/TrajectoryView.tsx'
  50. import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
  51. import { EMPTY_TRAJECTORY_SNAPSHOT } from '../src/client/trajectory-snapshot-builder.ts'
  52. import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
  53. import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
  54. import { t as tTrajectory, tZh } from './locale.client.ts'
  55. // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  56. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
  57. const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
  58. function TrajectoryTimeline(
  59. props: Omit<ComponentProps<typeof LocalizedTrajectoryTimeline>, 't'>,
  60. ) {
  61. return <LocalizedTrajectoryTimeline {...props} t={tTrajectory} />
  62. }
  63. const SID = 's1' as SessionId
  64. const tConversation: ConversationSessionHeaderProps['t'] =
  65. key => (conversationZh as Record<string, string>)[key] ?? key
  66. const runtimes: SlotTestRuntime[] = []
  67. afterEach(async () => {
  68. cleanup()
  69. vi.restoreAllMocks()
  70. Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
  71. for (const runtime of runtimes.splice(0)) await runtime.dispose()
  72. })
  73. // The Conversation store persists under its declared key; clear so one case's active
  74. // view cannot rehydrate into the next.
  75. beforeEach(() => {
  76. localStorage.clear()
  77. })
  78. /** Node fixture: user prologue, two turns, one tool result inside turn 1. */
  79. const NODES: LegacyConversationSlice['nodes'] = [
  80. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  81. {
  82. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
  83. timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
  84. },
  85. {
  86. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
  87. content: [], isError: false, subCalls: [],
  88. },
  89. {
  90. kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
  91. timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
  92. },
  93. ]
  94. function historySnapshot(
  95. nodes: LegacyConversationSlice['nodes'],
  96. inspection: Partial<TrajectorySnapshot> = {},
  97. ): TrajectorySnapshot {
  98. return {
  99. eventNodes: nodes,
  100. eventLocations: new Map(),
  101. requests: [],
  102. callSchemas: new Map(),
  103. partial: null,
  104. runningCalls: [],
  105. ...inspection,
  106. }
  107. }
  108. function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot {
  109. return {
  110. sessionId: SID,
  111. pendingSubmissions: [],
  112. running: false,
  113. subagent: null,
  114. removed: false,
  115. openState: 'open',
  116. openError: null,
  117. hasMore: false,
  118. loadingOlder: false,
  119. promptError: null,
  120. blank: nodes.length === 0,
  121. lastAgentError: null,
  122. promptAttempted: nodes.length > 0,
  123. awaitingFirstTurn: false,
  124. }
  125. }
  126. function conversationSnapshot(
  127. trajectory: TrajectorySnapshot,
  128. ): ConversationSnapshot {
  129. return {
  130. views: EMPTY_CONVERSATION_SNAPSHOT.views,
  131. activeTargets: trajectory.eventNodes.length === 0
  132. ? new Set()
  133. : new Set(['trajectory']),
  134. }
  135. }
  136. function standaloneHistory(
  137. snapshot: TrajectorySnapshot,
  138. ): Pick<
  139. ComponentProps<typeof TrajectoryView>,
  140. 'useSession' | 'useTrajectory' | 'loadOlder'
  141. > {
  142. const session = createSnapshotStore(sessionSnapshot(snapshot.eventNodes))
  143. const trajectory = createSnapshotStore(snapshot)
  144. return {
  145. useSession: bindSnapshotSelector(session),
  146. useTrajectory: bindSnapshotSelector(trajectory),
  147. loadOlder: () => Promise.resolve(false),
  148. }
  149. }
  150. function standaloneDuration(): Pick<
  151. ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
  152. > {
  153. const duration = createSnapshotStore(false)
  154. return {
  155. useDuration: bindSnapshotSelector(duration),
  156. setActualDuration: (value) => { duration.set(value) },
  157. }
  158. }
  159. /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
  160. function emptySessions() {
  161. const store = createSnapshotStore<SessionListState>(
  162. { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
  163. return bindSnapshotSelector(store)
  164. }
  165. function emptyWorkspaces() {
  166. const store = createSnapshotStore<WorkspaceSnapshot>({
  167. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  168. })
  169. return bindSnapshotSelector(store)
  170. }
  171. function emptyProjection<Key extends Extract<keyof SessionProjectionMap, string>>(
  172. key: Key,
  173. ): SessionProjectionMap[Key] | undefined
  174. function emptyProjection<Key extends Extract<keyof SessionProjectionMap, string>, Selected>(
  175. key: Key,
  176. selector: (value: SessionProjectionMap[Key] | undefined) => Selected,
  177. eq?: (left: Selected, right: Selected) => boolean,
  178. ): Selected
  179. function emptyProjection<Key extends Extract<keyof SessionProjectionMap, string>, Selected>(
  180. _key: Key,
  181. selector?: (value: SessionProjectionMap[Key] | undefined) => Selected,
  182. ): SessionProjectionMap[Key] | Selected | undefined {
  183. return selector === undefined ? undefined : selector(undefined)
  184. }
  185. const useProjection: UseProjection = emptyProjection
  186. type StandaloneBaseProps = Omit<
  187. ComponentProps<typeof TrajectoryView>,
  188. 'useSession' | 'useTrajectory' | 'useDuration' | 'loadOlder' | 'setActualDuration'
  189. >
  190. /** Standalone view props: the session-scope standard kit the outlet would bake. */
  191. function standaloneProps(
  192. nodes: LegacyConversationSlice['nodes'],
  193. ): StandaloneBaseProps {
  194. const trajectory = historySnapshot(nodes)
  195. const input = createSnapshotStore<InputState>({
  196. draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
  197. })
  198. const inputActions: InputActions = {
  199. setDraft: () => {},
  200. addAttachments: () => false,
  201. removeAttachment: () => {},
  202. pruneAttachments: () => {},
  203. submit: () => {},
  204. }
  205. return {
  206. sessionId: SID,
  207. useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)),
  208. useSessions: emptySessions(),
  209. usePanelInfo, useResource,
  210. useSessionStatus: bindSnapshotSelector(
  211. createSnapshotStore<SessionStatusSnapshot>(new Map()),
  212. ),
  213. useSessionRetainInfo: () => undefined,
  214. useWorkspaces: emptyWorkspaces(),
  215. useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))),
  216. useInput: bindSnapshotSelector(input),
  217. inputActions,
  218. useProjection,
  219. viewRequest: null,
  220. openView: () => {},
  221. completeViewRequest: () => {},
  222. // Image seats the outlet would bake: standalone renders omit the gallery.
  223. renderSlot: () => null,
  224. SessionProvider: ({ children }) => <>{children}</>,
  225. loadImage: () => Promise.reject(new Error('standalone views load no images')),
  226. // The locale seat the outlet would inject for the declared namespace.
  227. t: tZh,
  228. }
  229. }
  230. type ConversationTargetSources = {
  231. [Target in Extract<keyof ConversationViewSnapshotMap, string>]:
  232. ObservableSnapshot<ConversationViewSnapshotMap[Target] | undefined>
  233. }
  234. /** Real-stack bench: root Context + real SlotRegistry ring + the plugin fiber. */
  235. async function bench(snapshot = historySnapshot(NODES)) {
  236. const runtime = await SlotTestRuntime.create()
  237. runtimes.push(runtime)
  238. const ctx = runtime.ctx
  239. const slots = runtime.slots
  240. const loadOlder = vi.fn(() => Promise.resolve())
  241. await runtime.sessions.add({
  242. id: SID,
  243. snapshot: { blank: false },
  244. session: { loadOlder },
  245. })
  246. const reference = runtime.sessions.retain(SID)
  247. await reference.ready
  248. const trajectoryStore = createSnapshotStore(snapshot)
  249. const conversationStore = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(snapshot))
  250. const uiConversation = new UiConversation(ctx, runtime.sessions)
  251. const { events, views } = uiConversation
  252. const targetSources: ConversationTargetSources = {
  253. chat: createSnapshotStore<ChatSnapshot | undefined>(undefined),
  254. trajectory: trajectoryStore,
  255. }
  256. const binding: ConversationBinding = {
  257. snapshot: conversationStore,
  258. activate: () => {},
  259. target: target => targetSources[target],
  260. }
  261. vi.spyOn(uiConversation, 'binding').mockReturnValue(binding)
  262. // The conversation entry's role: declare the ring, then seed the chat entry.
  263. await runtime.root.declare(
  264. { 'conversation.view': { kind: 'list', scope: 'session' } },
  265. (_p: { renderSlot?: unknown }) => null,
  266. )
  267. const chatBody = vi.fn(() => <div data-testid="chat-body" />)
  268. slots.register(
  269. { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
  270. // The locale plugin backs the locale-aware view tab label ('locale' in
  271. // inject); its settings scope needs a connection handle.
  272. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
  273. ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
  274. await runtime.mount({ inject: [...localeInject], apply: localeApply })
  275. const provide = vi.spyOn(ctx.uiSession, 'provide')
  276. const feature = await runtime.mount({ inject: [...inject], apply })
  277. const sourceDescriptor = provide.mock.calls[0]?.[0]
  278. if (sourceDescriptor === undefined) throw new Error('ui-trajectory did not provide its standard source')
  279. return {
  280. runtime, ctx, slots, feature, loadOlder, trajectoryStore, conversationStore,
  281. events, views, sourceDescriptor, reference,
  282. }
  283. }
  284. /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
  285. function tabsOf(slots: SlotRegistry): ViewTab[] {
  286. return slots.entries('conversation.view')
  287. .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
  288. }
  289. type ConvViewOwner = Pick<ConvViewProps, 'viewRequest' | 'openView' | 'completeViewRequest'>
  290. function isConvViewOwner(owner: object): owner is ConvViewOwner {
  291. return 'viewRequest' in owner
  292. && 'openView' in owner && typeof owner.openView === 'function'
  293. && 'completeViewRequest' in owner && typeof owner.completeViewRequest === 'function'
  294. }
  295. /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
  296. function mount(fixture: Awaited<ReturnType<typeof bench>>) {
  297. const { slots, trajectoryStore, conversationStore } = fixture
  298. const session = fixture.reference.binding.session
  299. const useSession = bindSnapshotSelector<SessionSnapshot>(session)
  300. const useTrajectory = bindSnapshotSelector<TrajectorySnapshot>(trajectoryStore)
  301. const useConversation = bindSnapshotSelector<ConversationSnapshot>(conversationStore)
  302. const useChat = bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT))
  303. const useSessions = emptySessions()
  304. const useSessionStatus = bindSnapshotSelector(
  305. createSnapshotStore<SessionStatusSnapshot>(new Map()),
  306. )
  307. const useWorkspaces = emptyWorkspaces()
  308. const conversation = createConversationStore().create()
  309. const useConversationViews = bindSnapshotSelector(
  310. createSnapshotStore<readonly ViewTab[]>(tabsOf(slots)),
  311. )
  312. const useInput = bindSnapshotSelector(createSnapshotStore<InputState>({
  313. draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
  314. }))
  315. const inputActions: InputActions = {
  316. setDraft: vi.fn(),
  317. addAttachments: vi.fn(() => false),
  318. removeAttachment: vi.fn(),
  319. pruneAttachments: vi.fn(),
  320. submit: vi.fn(),
  321. }
  322. const standardProps = {
  323. sessionId: SID,
  324. useSession,
  325. useTrajectory,
  326. useChat,
  327. useConversation,
  328. useConversationViews,
  329. useSessions,
  330. usePanelInfo, useResource,
  331. useSessionStatus,
  332. useSessionRetainInfo: () => undefined,
  333. useWorkspaces,
  334. useProjection,
  335. useInput,
  336. inputActions,
  337. }
  338. // Minimal outlet twin: resolve the ring entry by the `only` filter and
  339. // render it with the session standard kit (what SlotOutlet does for a
  340. // list-kind session slot, minus machinery).
  341. const renderSlot: ConversationSessionProps['renderSlot'] = (key, owner, opts): ReactNode => {
  342. const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
  343. if (entry === undefined) return null
  344. if (!isConvViewOwner(owner)) throw new Error('trajectory fixture expected Conversation view owner props')
  345. const View = entry.component as FC<ConvViewProps>
  346. const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
  347. const injected = injectEntry === undefined
  348. ? {}
  349. : injectEntry(SID)
  350. const injectedProps = 'hooks' in injected
  351. ? (() => {
  352. const trajectory = injected as TrajectoryViewInjected
  353. return {
  354. loadOlder: trajectory.loadOlder,
  355. setActualDuration: trajectory.setActualDuration,
  356. useDuration: bindSnapshotSelector(trajectory.hooks.duration),
  357. t: tZh,
  358. }
  359. })()
  360. : injected
  361. const viewProps: ConvViewProps = { ...owner, ...standardProps }
  362. return (
  363. <View
  364. {...viewProps}
  365. {...injectedProps}
  366. key={key}
  367. />
  368. )
  369. }
  370. return render(
  371. <>
  372. <ConversationSessionHeader
  373. {...standardProps}
  374. SessionProvider={({ children }) => children}
  375. useStore={bindSnapshotSelector(conversation)}
  376. actions={conversation.actions}
  377. renderSlot={() => null}
  378. open={vi.fn()}
  379. selectView={conversation.actions.setView}
  380. t={tConversation}
  381. />
  382. <ConversationSession
  383. {...standardProps}
  384. SessionProvider={({ children }) => children}
  385. useStore={bindSnapshotSelector(conversation)}
  386. actions={conversation.actions}
  387. renderSlot={renderSlot}
  388. bindDraftMirror={() => () => {}}
  389. openView={conversation.actions.openView}
  390. />
  391. </>,
  392. )
  393. }
  394. describe('plugin registration', () => {
  395. it('registers trajectory after chat on the ring', async () => {
  396. const b = await bench()
  397. expect(tabsOf(b.slots)).toEqual([
  398. { id: 'chat', label: 'Chat' },
  399. { id: 'trajectory', label: 'Trajectory' },
  400. ])
  401. })
  402. it('fiber disposal removes the tab and leaves chat standing', async () => {
  403. const b = await bench()
  404. expect(b.events.entries().length).toBeGreaterThan(0)
  405. expect(b.views.entries()).toHaveLength(1)
  406. await b.feature.dispose()
  407. expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
  408. expect(b.events.entries()).toEqual([])
  409. expect(b.views.entries()).toEqual([])
  410. })
  411. it('keeps one total standard source for a Session binding', async () => {
  412. const b = await bench()
  413. using reference = b.runtime.sessions.retain(SID)
  414. await reference.ready
  415. const binding = reference.binding
  416. const resolveSource = (owner: SessionBinding): ObservableSnapshot<TrajectorySnapshot> => {
  417. const contribution = b.sourceDescriptor.resolve(owner) as {
  418. hooks: { trajectory: ObservableSnapshot<TrajectorySnapshot> }
  419. }
  420. return contribution.hooks.trajectory
  421. }
  422. const source = b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.trajectory as
  423. ObservableSnapshot<TrajectorySnapshot>
  424. expect(resolveSource(binding)).toBe(source)
  425. expect(resolveSource(binding)).toBe(source)
  426. const optionalTrajectory = b.trajectoryStore as unknown as {
  427. set(value: TrajectorySnapshot | undefined): void
  428. }
  429. optionalTrajectory.set(undefined)
  430. expect(source.getSnapshot()).toBe(EMPTY_TRAJECTORY_SNAPSHOT)
  431. })
  432. it('shares one browser-wide duration preference across session injections', async () => {
  433. const b = await bench()
  434. const entry = b.slots.entries('conversation.view')
  435. .find(candidate => candidate.options.id === 'trajectory')
  436. expect(entry).toBeDefined()
  437. const injectEntry = entry!.inject as unknown as (
  438. sessionId: SessionId,
  439. ) => TrajectoryViewInjected
  440. const first = injectEntry(SID)
  441. await b.runtime.sessions.add({ id: 's2' })
  442. using reference = b.runtime.sessions.retain('s2' as SessionId)
  443. await reference.ready
  444. const second = injectEntry(reference.sessionId)
  445. expect(second.hooks.duration).toBe(first.hooks.duration)
  446. first.setActualDuration(true)
  447. expect(second.hooks.duration.getSnapshot()).toBe(true)
  448. expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
  449. expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
  450. })
  451. it('reports whether loading older history changed the Trajectory snapshot', async () => {
  452. const b = await bench()
  453. const entry = b.slots.entries('conversation.view')
  454. .find(candidate => candidate.options.id === 'trajectory')
  455. const injectEntry = entry!.inject as unknown as (
  456. sessionId: SessionId,
  457. ) => TrajectoryViewInjected
  458. const injected = injectEntry(SID)
  459. expect(await injected.loadOlder()).toBe(false)
  460. b.loadOlder.mockImplementationOnce(async () => {
  461. b.trajectoryStore.set(historySnapshot([...NODES]))
  462. })
  463. expect(await injected.loadOlder()).toBe(true)
  464. })
  465. })
  466. describe('tab switching in ConversationRoot', () => {
  467. it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
  468. const b = await bench()
  469. const view = mount(b)
  470. expect(screen.getByTestId('chat-body')).toBeTruthy()
  471. expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
  472. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  473. expect(screen.queryByText(/turns ·/)).toBeNull()
  474. expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
  475. expect(screen.queryByRole('columnheader')).toBeNull()
  476. expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
  477. expect(screen.getByRole('region', { name: '轨迹时间线' })).toBeTruthy()
  478. expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
  479. fireEvent.click(screen.getByRole('button', { name: '收起所有轮次' }))
  480. expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
  481. fireEvent.click(screen.getByRole('button', { name: '展开所有轮次' }))
  482. expect(screen.getByRole('row', { name: /用户/ })).toBeTruthy()
  483. expect(screen.queryByTestId('chat-body')).toBeNull()
  484. expect(b.loadOlder).not.toHaveBeenCalled()
  485. fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
  486. expect(b.loadOlder).not.toHaveBeenCalled()
  487. })
  488. it('labels the trajectory tab in the active locale', async () => {
  489. const b = await bench()
  490. const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label
  491. expect(labelOf()).toBe('Trajectory')
  492. const locale = b.ctx.get('locale') as { setLocale(id: string): void }
  493. locale.setLocale('zh')
  494. expect(labelOf()).toBe('轨迹')
  495. locale.setLocale('en')
  496. expect(labelOf()).toBe('Trajectory')
  497. })
  498. it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
  499. const b = await bench()
  500. mount(b)
  501. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  502. fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' })
  503. expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy()
  504. expect(screen.getByText('第 1 轮 · 第 1 步')).toBeTruthy()
  505. expect(screen.getByText('已完成')).toBeTruthy()
  506. expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy()
  507. fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
  508. expect(screen.queryByRole('complementary', { name: '事件详情' })).toBeNull()
  509. })
  510. it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
  511. const nodes: LegacyConversationSlice['nodes'] = [
  512. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  513. {
  514. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  515. blocks: [{ kind: 'text', text: 'before' }],
  516. },
  517. { kind: 'user', seq: 5, time: 5_000, content: [], source: null },
  518. {
  519. kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
  520. blocks: [{ kind: 'text', text: 'after' }],
  521. },
  522. ]
  523. const compaction: RequestView = {
  524. purpose: 'compaction',
  525. startSeq: 3,
  526. turn: null,
  527. step: 0,
  528. startedAt: 3_000,
  529. completedAt: 4_000,
  530. status: 'complete',
  531. summary: [{ type: 'text', text: 'standalone summary' }],
  532. }
  533. const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
  534. const view = mount(b)
  535. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  536. expect(screen.getByText('轮次之间')).toBeTruthy()
  537. expect(view.container.textContent).not.toContain('Turn null')
  538. fireEvent.click(screen.getByRole('button', { name: '请求 #2 · 压缩' }))
  539. expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy()
  540. expect(view.container.textContent).not.toContain('Turn null')
  541. })
  542. it('activates only the selected standalone compaction section', async () => {
  543. const nodes: LegacyConversationSlice['nodes'] = [
  544. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  545. {
  546. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  547. blocks: [{ kind: 'text', text: 'before first compaction' }],
  548. },
  549. { kind: 'user', seq: 5, time: 5_000, content: [], source: null },
  550. {
  551. kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
  552. blocks: [{ kind: 'text', text: 'between compactions' }],
  553. },
  554. { kind: 'user', seq: 9, time: 9_000, content: [], source: null },
  555. {
  556. kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1,
  557. blocks: [{ kind: 'text', text: 'after second compaction' }],
  558. },
  559. ]
  560. const compactions: RequestView[] = [
  561. {
  562. purpose: 'compaction',
  563. startSeq: 3,
  564. turn: null,
  565. step: 0,
  566. startedAt: 3_000,
  567. completedAt: 4_000,
  568. status: 'complete',
  569. summary: [{ type: 'text', text: 'first standalone summary' }],
  570. },
  571. {
  572. purpose: 'compaction',
  573. startSeq: 7,
  574. turn: null,
  575. step: 0,
  576. startedAt: 7_000,
  577. completedAt: 8_000,
  578. status: 'complete',
  579. summary: [{ type: 'text', text: 'second standalone summary' }],
  580. },
  581. ]
  582. const b = await bench(historySnapshot(nodes, { requests: compactions }))
  583. mount(b)
  584. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  585. const firstRequest = screen.getByRole('button', { name: '请求 #2 · 压缩' })
  586. const secondRequest = screen.getByRole('button', { name: '请求 #4 · 压缩' })
  587. const firstSection = firstRequest.closest('tr')?.querySelector('span')
  588. const secondSection = secondRequest.closest('tr')?.querySelector('span')
  589. expect(firstSection?.textContent).toBe('轮次之间')
  590. expect(secondSection?.textContent).toBe('轮次之间')
  591. fireEvent.click(firstRequest)
  592. expect(firstSection?.className).toMatch(/turnLabelActive/)
  593. expect(secondSection?.className).not.toMatch(/turnLabelActive/)
  594. expect(screen.getByText('请求 #2')).toBeTruthy()
  595. expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy()
  596. fireEvent.click(secondRequest)
  597. expect(firstSection?.className).not.toMatch(/turnLabelActive/)
  598. expect(secondSection?.className).toMatch(/turnLabelActive/)
  599. expect(screen.getByText('请求 #4')).toBeTruthy()
  600. expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy()
  601. })
  602. it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
  603. const b = await bench()
  604. mount(b)
  605. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  606. const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件')
  607. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  608. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  609. toJSON: () => ({}),
  610. })
  611. fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
  612. fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
  613. fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
  614. expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus'))
  615. .toBe('outside')
  616. const tablePane = screen.getByRole('table').parentElement
  617. expect(tablePane).not.toBeNull()
  618. fireEvent.click(tablePane as HTMLElement)
  619. expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus'))
  620. .toBeNull()
  621. fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
  622. fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
  623. fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
  624. expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus'))
  625. .toBe('outside')
  626. fireEvent.contextMenu(plot)
  627. expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus'))
  628. .toBe('outside')
  629. })
  630. it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
  631. const b = await bench()
  632. const view = mount(b)
  633. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  634. const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件')
  635. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  636. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  637. toJSON: () => ({}),
  638. })
  639. const toolSpan = view.container.querySelector<HTMLElement>(
  640. '[data-timeline-span="tool"]',
  641. )
  642. expect(toolSpan).not.toBeNull()
  643. const recordIndex = toolSpan?.dataset.timelineRecordIndex
  644. expect(recordIndex).toBeTruthy()
  645. fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
  646. expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
  647. expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
  648. fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
  649. fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
  650. fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
  651. expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
  652. fireEvent.pointerDown(toolSpan as HTMLElement, {
  653. button: 0, clientX: 50, pointerId: 2,
  654. })
  655. fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
  656. const selectedRow = view.container.querySelector<HTMLElement>(
  657. `tr[data-record-index="${recordIndex}"]`,
  658. )
  659. expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
  660. expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
  661. expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy()
  662. })
  663. it('empty window keeps the toolbar and reports no timing data', async () => {
  664. const b = await bench(historySnapshot([]))
  665. mount(b)
  666. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  667. expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
  668. expect(screen.getByText('无计时数据')).toBeTruthy()
  669. expect(screen.getByRole<HTMLButtonElement>('button', {
  670. name: '收起所有轮次',
  671. }).disabled).toBe(false)
  672. expect(screen.getByRole<HTMLButtonElement>('button', {
  673. name: '收起所有调用',
  674. }).disabled).toBe(false)
  675. expect(screen.queryByRole('row')).toBeNull()
  676. expect(screen.queryByText(/turns ·/)).toBeNull()
  677. })
  678. })
  679. describe('timeline projection', () => {
  680. const turns = [{
  681. turn: 1,
  682. groups: [{
  683. title: 'Step 1',
  684. cells: [
  685. { index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
  686. { index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
  687. { index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
  688. ],
  689. }],
  690. }] satisfies readonly TrajectoryTurnModel[]
  691. const longTurns = [{
  692. turn: 1,
  693. groups: [{
  694. title: 'Step 1',
  695. cells: Array.from({ length: 10 }, (_, index) => ({
  696. index,
  697. kind: 'message' as const,
  698. text: `record ${index}`,
  699. timeSeconds: 1,
  700. })),
  701. }],
  702. }] satisfies readonly TrajectoryTurnModel[]
  703. it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
  704. vi.useFakeTimers()
  705. try {
  706. const view = render(
  707. <TrajectoryTimeline
  708. turns={[{
  709. turn: 1,
  710. groups: [{
  711. title: 'Step 1',
  712. cells: [{
  713. index: 1,
  714. kind: 'message',
  715. text: 'assistant',
  716. startedAt: 1_000,
  717. timeSeconds: 2,
  718. assistantMetrics: {
  719. timingRecorded: true,
  720. stepStartTime: 1_000,
  721. firstTokenTime: 1_500,
  722. completedTime: 3_000,
  723. usageProvided: false,
  724. outputTokens: null,
  725. },
  726. }],
  727. }],
  728. }]}
  729. mode="duration"
  730. range={null}
  731. onRangeChange={vi.fn()}
  732. />,
  733. )
  734. const span = view.container.querySelector<HTMLElement>(
  735. '[data-timeline-span="message"]',
  736. )
  737. expect(span?.getAttribute('title')).toBeNull()
  738. expect(span?.getAttribute('data-assistant-timing')).toBe('true')
  739. expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
  740. fireEvent.mouseEnter(span as HTMLElement)
  741. act(() => { vi.advanceTimersByTime(499) })
  742. expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
  743. act(() => { vi.advanceTimersByTime(1) })
  744. const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
  745. expect(tooltip?.textContent).toContain('Total 2,000 ms')
  746. expect(tooltip?.textContent).toContain('TTFT 500 ms')
  747. expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
  748. } finally {
  749. vi.useRealTimers()
  750. }
  751. })
  752. it('marks an unloaded history prefix without inventing timeline duration', () => {
  753. const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
  754. const view = render(
  755. <TrajectoryTimeline
  756. turns={turns}
  757. mode="sequence"
  758. range={null}
  759. hasEarlierRecords
  760. onLoadEarlier={onLoadEarlier}
  761. onRangeChange={vi.fn()}
  762. />,
  763. )
  764. const boundary = screen.getByLabelText('Load earlier history')
  765. expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
  766. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  767. fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
  768. expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
  769. fireEvent.pointerEnter(boundary)
  770. expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
  771. fireEvent.focus(boundary)
  772. expect(screen.getByRole('tooltip').textContent)
  773. .toContain('Click to load earlier history')
  774. fireEvent.click(boundary)
  775. expect(onLoadEarlier).toHaveBeenCalledOnce()
  776. expect(screen.getByLabelText('Loading earlier history…')).toBeTruthy()
  777. view.rerender(
  778. <TrajectoryTimeline
  779. turns={turns}
  780. mode="sequence"
  781. range={null}
  782. onRangeChange={vi.fn()}
  783. />,
  784. )
  785. expect(screen.queryByLabelText('Load earlier history')).toBeNull()
  786. expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
  787. })
  788. it('cancels native scrolling across the timeline while zooming', () => {
  789. render(
  790. <TrajectoryTimeline
  791. turns={longTurns}
  792. mode="sequence"
  793. range={null}
  794. onRangeChange={vi.fn()}
  795. />,
  796. )
  797. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  798. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  799. x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
  800. toJSON: () => ({}),
  801. })
  802. expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
  803. expect(fireEvent.wheel(screen.getByText('Input'), {
  804. clientX: 20,
  805. deltaY: -100,
  806. })).toBe(false)
  807. })
  808. it('scales sequence gutters with narrow operation spans', () => {
  809. const view = render(
  810. <TrajectoryTimeline
  811. turns={longTurns}
  812. mode="sequence"
  813. range={null}
  814. onRangeChange={vi.fn()}
  815. />,
  816. )
  817. const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
  818. expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
  819. expect(span?.style.getPropertyValue('--trajectory-span-gap'))
  820. .toBe('min(0.8%, 1px)')
  821. })
  822. it('keeps dense sequence spans proportional before applying the pixel floor', () => {
  823. const denseTurns = [{
  824. turn: 1,
  825. groups: [{
  826. title: 'Step 1',
  827. cells: Array.from({ length: 400 }, (_, index) => ({
  828. index,
  829. kind: 'message' as const,
  830. text: `message ${index}`,
  831. timeSeconds: 1,
  832. })),
  833. }],
  834. }]
  835. const view = render(
  836. <TrajectoryTimeline
  837. turns={denseTurns}
  838. mode="sequence"
  839. range={null}
  840. onRangeChange={vi.fn()}
  841. />,
  842. )
  843. const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
  844. expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
  845. expect(span?.style.getPropertyValue('--trajectory-span-gap'))
  846. .toBe('min(0.02%, 1px)')
  847. })
  848. it('clears the selection without changing zoom on a zoomed right click', () => {
  849. const onRangeChange = vi.fn()
  850. const view = render(
  851. <TrajectoryTimeline
  852. turns={longTurns}
  853. mode="sequence"
  854. range={{ start: 2, end: 4 }}
  855. hasEarlierRecords
  856. onRangeChange={onRangeChange}
  857. />,
  858. )
  859. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  860. expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
  861. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  862. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  863. toJSON: () => ({}),
  864. })
  865. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  866. expect(screen.queryByLabelText('Load earlier history')).toBeNull()
  867. const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
  868. const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
  869. expect(domainWidth).not.toBe('100%')
  870. fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
  871. expect(fireEvent.contextMenu(plot)).toBe(false)
  872. fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
  873. expect(onRangeChange).toHaveBeenCalledOnce()
  874. expect(onRangeChange).toHaveBeenCalledWith(null)
  875. expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
  876. })
  877. it('clears the selection and suppresses the context menu at full zoom', () => {
  878. const onRangeChange = vi.fn()
  879. render(
  880. <TrajectoryTimeline
  881. turns={longTurns}
  882. mode="sequence"
  883. range={{ start: 2, end: 4 }}
  884. onRangeChange={onRangeChange}
  885. />,
  886. )
  887. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  888. fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
  889. expect(fireEvent.contextMenu(plot)).toBe(false)
  890. fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
  891. expect(onRangeChange).toHaveBeenCalledOnce()
  892. expect(onRangeChange).toHaveBeenCalledWith(null)
  893. })
  894. it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
  895. const onRangeChange = vi.fn()
  896. const view = render(
  897. <TrajectoryTimeline
  898. turns={longTurns}
  899. mode="sequence"
  900. range={{ start: 2, end: 4 }}
  901. onRangeChange={onRangeChange}
  902. />,
  903. )
  904. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  905. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  906. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  907. toJSON: () => ({}),
  908. })
  909. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  910. const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
  911. const before = domain?.style.getPropertyValue('--trajectory-domain-left')
  912. fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
  913. expect(plot.getAttribute('data-panning')).toBe('true')
  914. expect(fireEvent.contextMenu(plot)).toBe(false)
  915. fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
  916. fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
  917. expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
  918. expect(onRangeChange).not.toHaveBeenCalled()
  919. expect(plot.getAttribute('data-panning')).toBeNull()
  920. })
  921. it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
  922. const onRangeChange = vi.fn()
  923. const view = render(
  924. <TrajectoryTimeline
  925. turns={longTurns}
  926. mode="sequence"
  927. range={null}
  928. onRangeChange={onRangeChange}
  929. />,
  930. )
  931. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  932. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  933. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  934. toJSON: () => ({}),
  935. })
  936. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  937. view.rerender(
  938. <TrajectoryTimeline
  939. turns={longTurns}
  940. mode="sequence"
  941. range={null}
  942. selectedIndex={1}
  943. onRangeChange={onRangeChange}
  944. />,
  945. )
  946. await vi.waitFor(() => {
  947. const domain = view.container.querySelector<HTMLElement>(
  948. '[data-timeline-domain]',
  949. )
  950. expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
  951. })
  952. view.rerender(
  953. <TrajectoryTimeline
  954. turns={longTurns}
  955. mode="sequence"
  956. range={null}
  957. selectedIndex={8}
  958. onRangeChange={onRangeChange}
  959. />,
  960. )
  961. await vi.waitFor(() => {
  962. const domain = view.container.querySelector<HTMLElement>(
  963. '[data-timeline-domain]',
  964. )
  965. expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
  966. })
  967. })
  968. it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
  969. const onRangeChange = vi.fn()
  970. const view = render(
  971. <TrajectoryTimeline
  972. turns={longTurns}
  973. mode="sequence"
  974. range={null}
  975. onRangeChange={onRangeChange}
  976. />,
  977. )
  978. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  979. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  980. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  981. toJSON: () => ({}),
  982. })
  983. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  984. fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
  985. for (let index = 0; index < 24; index++) {
  986. fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
  987. }
  988. const draftSelection = view.container.querySelectorAll<HTMLElement>(
  989. '[data-dragging="true"]',
  990. )
  991. expect(draftSelection).toHaveLength(2)
  992. for (const overlay of draftSelection) {
  993. expect(Number.parseFloat(
  994. overlay.style.getPropertyValue('--trajectory-selection-left'),
  995. )).toBeLessThan(0)
  996. }
  997. fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
  998. const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
  999. | { start: number; end: number }
  1000. | undefined
  1001. const fullRange = deriveTrajectoryTimeline(longTurns)
  1002. expect(selectedRange).toBeDefined()
  1003. expect(fullRange).not.toBeNull()
  1004. expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
  1005. expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
  1006. expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
  1007. })
  1008. it('uses equal-width operation slots and stable semantic lanes', () => {
  1009. expect(deriveTrajectoryTimeline(turns)).toEqual({
  1010. start: 0,
  1011. end: 3,
  1012. spans: [
  1013. {
  1014. index: 1, isError: false, kind: 'message', label: 'assistant',
  1015. lane: 1, start: 0, end: 1,
  1016. },
  1017. {
  1018. index: 2, isError: false, kind: 'tool', label: 'bash',
  1019. lane: 2, start: 1, end: 2,
  1020. },
  1021. {
  1022. index: 3, isError: false, kind: 'user', label: 'unknown',
  1023. lane: 0, start: 2, end: 3,
  1024. },
  1025. ],
  1026. turnBoundaries: [{ turn: 1, time: 0 }],
  1027. })
  1028. })
  1029. it('marks error records directly on timeline spans', () => {
  1030. const errorTurns = [{
  1031. turn: 1,
  1032. groups: [{
  1033. title: 'Step 1',
  1034. cells: [{
  1035. index: 1,
  1036. kind: 'tool' as const,
  1037. text: 'failed tool',
  1038. timeSeconds: 0.1,
  1039. isError: true,
  1040. }],
  1041. }],
  1042. }] satisfies readonly TrajectoryTurnModel[]
  1043. const view = render(
  1044. <TrajectoryTimeline
  1045. turns={errorTurns}
  1046. mode="sequence"
  1047. range={null}
  1048. onRangeChange={() => {}}
  1049. />,
  1050. )
  1051. expect(view.container.querySelector(
  1052. '[data-timeline-span="tool"][data-error="true"]',
  1053. )).toBeTruthy()
  1054. })
  1055. it('ignores durations and idle gaps while retaining turn boundaries', () => {
  1056. const separatedTurns = [
  1057. {
  1058. turn: 1,
  1059. groups: [{
  1060. title: 'Step 1',
  1061. cells: [
  1062. { index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
  1063. { index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
  1064. ],
  1065. }],
  1066. },
  1067. {
  1068. turn: 2,
  1069. groups: [{
  1070. title: 'Step 1',
  1071. cells: [
  1072. { index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
  1073. ],
  1074. }],
  1075. },
  1076. ] satisfies readonly TrajectoryTurnModel[]
  1077. expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
  1078. start: 0,
  1079. end: 3,
  1080. spans: [
  1081. { index: 1, start: 0, end: 1 },
  1082. { index: 2, start: 1, end: 2 },
  1083. { index: 3, start: 2, end: 3 },
  1084. ],
  1085. turnBoundaries: [
  1086. { turn: 1, time: 0 },
  1087. { turn: 2, time: 2 },
  1088. ],
  1089. })
  1090. })
  1091. it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
  1092. const separatedTurns = [
  1093. {
  1094. turn: 1,
  1095. groups: [{
  1096. title: 'Step 1',
  1097. cells: [
  1098. { index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
  1099. { index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
  1100. ],
  1101. }],
  1102. },
  1103. {
  1104. turn: 2,
  1105. groups: [{
  1106. title: 'Step 1',
  1107. cells: [
  1108. { index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
  1109. ],
  1110. }],
  1111. },
  1112. ] satisfies readonly TrajectoryTurnModel[]
  1113. expect(deriveTrajectoryTimeline(separatedTurns, 'duration')).toMatchObject({
  1114. start: 1_000,
  1115. end: 4_000,
  1116. spans: [
  1117. { index: 1, start: 1_000, end: 2_000 },
  1118. { index: 2, start: 2_000, end: 3_000 },
  1119. { index: 3, start: 3_000, end: 4_000 },
  1120. ],
  1121. turnBoundaries: [
  1122. { turn: 1, time: 1_000 },
  1123. { turn: 2, time: 3_000 },
  1124. ],
  1125. })
  1126. expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
  1127. start: 1_000,
  1128. end: 41_000,
  1129. spans: [
  1130. { index: 1, start: 1_000, end: 2_000 },
  1131. { index: 2, start: 4_000, end: 5_000 },
  1132. { index: 3, start: 40_000, end: 41_000 },
  1133. ],
  1134. })
  1135. })
  1136. it('projects between-turn compaction without inventing a turn boundary', () => {
  1137. const withStandaloneCompaction = [
  1138. {
  1139. turn: 1,
  1140. groups: [{
  1141. title: 'Step 1',
  1142. cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
  1143. }],
  1144. },
  1145. {
  1146. turn: null,
  1147. groups: [{
  1148. title: 'Compaction 3',
  1149. cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
  1150. }],
  1151. },
  1152. {
  1153. turn: 2,
  1154. groups: [{
  1155. title: 'Step 1',
  1156. cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
  1157. }],
  1158. },
  1159. ] satisfies readonly TrajectoryTurnModel[]
  1160. expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
  1161. spans: [
  1162. { index: 1, start: 0, end: 1 },
  1163. { index: 2, start: 1, end: 2 },
  1164. { index: 3, start: 2, end: 3 },
  1165. ],
  1166. turnBoundaries: [
  1167. { turn: 1, time: 0 },
  1168. { turn: 2, time: 2 },
  1169. ],
  1170. })
  1171. })
  1172. it('empty inputs produce no model and the standalone view reports its empty form', () => {
  1173. expect(deriveTrajectoryTimeline([])).toBeNull()
  1174. render(createElement(
  1175. TrajectoryView,
  1176. {
  1177. ...standaloneProps([]),
  1178. ...standaloneHistory(historySnapshot([])),
  1179. ...standaloneDuration(),
  1180. },
  1181. ))
  1182. expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
  1183. expect(screen.queryByRole('row')).toBeNull()
  1184. })
  1185. })
  1186. describe('TrajectoryView state', () => {
  1187. it('reveals resident history one bounded page at a time', async () => {
  1188. const nodes: LegacyConversationSlice['nodes'] = Array.from({ length: 5_000 }, (_, index) => ({
  1189. kind: 'user' as const,
  1190. seq: index + 1,
  1191. time: index + 1,
  1192. content: [{ type: 'text' as const, text: `prompt ${String(index + 1)}` }],
  1193. source: null,
  1194. }))
  1195. const trajectory = createSnapshotStore(historySnapshot([]))
  1196. const loadOlder = vi.fn(() => Promise.resolve(false))
  1197. render(
  1198. <TrajectoryView
  1199. {...standaloneProps([])}
  1200. {...standaloneHistory(historySnapshot(nodes))}
  1201. {...standaloneDuration()}
  1202. useTrajectory={bindSnapshotSelector(trajectory)}
  1203. loadOlder={loadOlder}
  1204. />,
  1205. )
  1206. act(() => { trajectory.set(historySnapshot(nodes)) })
  1207. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
  1208. fireEvent.click(screen.getAllByRole('button', { name: '加载更早的历史' }).at(-1)!)
  1209. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('101')
  1210. expect(loadOlder).not.toHaveBeenCalled()
  1211. act(() => { trajectory.set(historySnapshot([...nodes, {
  1212. kind: 'user', seq: 5_001, time: 5_001,
  1213. content: [{ type: 'text', text: 'appended prompt' }], source: null,
  1214. }])) })
  1215. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('102')
  1216. })
  1217. it('keeps a replacement window bounded when its predecessor tail is absent', () => {
  1218. const nodes = (start: number): LegacyConversationSlice['nodes'] => Array.from(
  1219. { length: 100 },
  1220. (_, index) => ({
  1221. kind: 'user' as const,
  1222. seq: start + index,
  1223. time: start + index,
  1224. content: [],
  1225. source: null,
  1226. }),
  1227. )
  1228. const trajectory = createSnapshotStore(historySnapshot(nodes(1)))
  1229. render(
  1230. <TrajectoryView
  1231. {...standaloneProps([])}
  1232. {...standaloneHistory(historySnapshot([]))}
  1233. {...standaloneDuration()}
  1234. useTrajectory={bindSnapshotSelector(trajectory)}
  1235. />,
  1236. )
  1237. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
  1238. act(() => { trajectory.set(historySnapshot(nodes(201))) })
  1239. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
  1240. })
  1241. it('keeps resident request numbering and cumulative usage outside the layout page', async () => {
  1242. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  1243. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  1244. configurable: true,
  1245. value: vi.fn(),
  1246. })
  1247. const visibleNodes: LegacyConversationSlice['nodes'] = Array.from(
  1248. { length: 10 },
  1249. (_, index) => ({
  1250. kind: 'assistant' as const,
  1251. seq: index * 2 + 44,
  1252. time: index * 2 + 44,
  1253. turn: index + 2,
  1254. step: 1,
  1255. blocks: [{ kind: 'text' as const, text: `visible response ${String(index + 1)}` }],
  1256. }),
  1257. )
  1258. const nodes: LegacyConversationSlice['nodes'] = [{
  1259. kind: 'assistant', seq: 2, time: 2, turn: 1, step: 1,
  1260. blocks: [{ kind: 'tool-call', callId: 'boundary-call', name: 'bash', argsRaw: '{}' }],
  1261. }, {
  1262. kind: 'tool-result', seq: 3, time: 3, callId: 'boundary-call',
  1263. call: { name: 'bash', argsRaw: '{}' }, callTime: 2,
  1264. content: [], isError: false, subCalls: [],
  1265. }, ...Array.from({ length: 39 }, (_, index) => ({
  1266. kind: 'compaction' as const,
  1267. seq: index + 4,
  1268. time: index + 4,
  1269. summary: null,
  1270. summaryEventSeq: null,
  1271. shadowedItemCount: null,
  1272. shadowedTokenCount: null,
  1273. })), ...visibleNodes]
  1274. const requests: readonly RequestView[] = [{
  1275. purpose: 'assistant', startSeq: 1, resultSeq: 2, startedAt: 1, completedAt: 2,
  1276. status: 'complete', turn: 1, step: 1, usage: { inputTokens: 1 },
  1277. }, ...visibleNodes.map((node, index) => ({
  1278. purpose: 'assistant' as const,
  1279. startSeq: node.seq - 1,
  1280. resultSeq: node.seq,
  1281. startedAt: node.seq - 1,
  1282. completedAt: node.seq,
  1283. status: 'complete' as const,
  1284. turn: index + 2,
  1285. step: 1,
  1286. usage: { inputTokens: 1 },
  1287. }))]
  1288. render(
  1289. <TrajectoryView
  1290. {...standaloneProps([])}
  1291. {...standaloneHistory(historySnapshot(nodes, { requests }))}
  1292. {...standaloneDuration()}
  1293. />,
  1294. )
  1295. await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #1' })).toBeTruthy() })
  1296. await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #11' })).toBeTruthy() })
  1297. fireEvent.click(screen.getByRole('button', { name: '请求 #11' }))
  1298. fireEvent.click(screen.getByRole('tab', { name: '用量' }))
  1299. expect(screen.getByText('会话累计').closest('section')?.textContent).toContain('11 tok')
  1300. })
  1301. it.each([
  1302. ['root', {
  1303. kind: 'assistant' as const,
  1304. seq: 3,
  1305. time: 3,
  1306. turn: 1,
  1307. step: 1,
  1308. blocks: [{ kind: 'tool-call' as const, callId: 'hidden-root', name: 'bash', argsRaw: '{}' }],
  1309. }, 'hidden-root'],
  1310. ['nested', {
  1311. kind: 'tool-result' as const,
  1312. seq: 3,
  1313. time: 3,
  1314. callId: 'hidden-root',
  1315. call: { name: 'run_code', argsRaw: '{}' },
  1316. callTime: 2,
  1317. content: [],
  1318. isError: false,
  1319. subCalls: [{
  1320. callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}',
  1321. turn: 1, step: 1, time: 3, subCalls: [],
  1322. }],
  1323. }, 'hidden-child'],
  1324. ])('reveals a hidden resident %s call for cross-view inspection', (_kind, target, focus) => {
  1325. const nodes: LegacyConversationSlice['nodes'] = [
  1326. { kind: 'user', seq: 1, time: 1, content: [], source: null },
  1327. {
  1328. kind: 'tool-result', seq: 2, time: 2, callId: 'unrelated', call: null, callTime: null,
  1329. content: [], isError: false, subCalls: [],
  1330. },
  1331. target,
  1332. ...Array.from({ length: 50 }, (_, index) => ({
  1333. kind: 'user' as const,
  1334. seq: index + 4,
  1335. time: index + 4,
  1336. content: [{ type: 'text' as const, text: `later ${String(index + 1)}` }],
  1337. source: null,
  1338. })),
  1339. ]
  1340. const completeViewRequest = vi.fn()
  1341. render(
  1342. <TrajectoryView
  1343. {...standaloneProps([])}
  1344. {...standaloneHistory(historySnapshot(nodes))}
  1345. {...standaloneDuration()}
  1346. viewRequest={{ view: 'trajectory', focus }}
  1347. completeViewRequest={completeViewRequest}
  1348. />,
  1349. )
  1350. expect(completeViewRequest).toHaveBeenCalledOnce()
  1351. })
  1352. it('persists the duration preference through the runtime snapshot-store seam', () => {
  1353. const firstDuration = createTrajectoryDurationStore()
  1354. const commonProps = {
  1355. ...standaloneProps(NODES),
  1356. ...standaloneHistory(historySnapshot(NODES)),
  1357. }
  1358. const first = render(
  1359. <TrajectoryView
  1360. {...commonProps}
  1361. useDuration={bindSnapshotSelector(firstDuration)}
  1362. setActualDuration={(value) => { firstDuration.set(value) }}
  1363. />,
  1364. )
  1365. const duration = screen.getByRole('button', { name: '使用实际时长' })
  1366. expect(duration.getAttribute('aria-pressed')).toBe('false')
  1367. fireEvent.click(duration)
  1368. expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
  1369. first.unmount()
  1370. const restoredDuration = createTrajectoryDurationStore()
  1371. render(
  1372. <TrajectoryView
  1373. {...commonProps}
  1374. useDuration={bindSnapshotSelector(restoredDuration)}
  1375. setActualDuration={(value) => { restoredDuration.set(value) }}
  1376. />,
  1377. )
  1378. expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed'))
  1379. .toBe('true')
  1380. })
  1381. it('keeps ledger and timeline selection on the same event after prepend', () => {
  1382. const older: LegacyConversationSlice['nodes'][number] = {
  1383. kind: 'user', seq: 1, time: 1_000,
  1384. content: [{ type: 'text', text: 'older prompt' }], source: null,
  1385. }
  1386. const current: LegacyConversationSlice['nodes'][number] = {
  1387. kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
  1388. blocks: [{ kind: 'text', text: 'selected current response' }],
  1389. }
  1390. const store = createSnapshotStore(historySnapshot([current]))
  1391. const view = render(
  1392. <TrajectoryView
  1393. {...standaloneProps([])}
  1394. {...standaloneHistory(historySnapshot([]))}
  1395. {...standaloneDuration()}
  1396. useTrajectory={bindSnapshotSelector(store)}
  1397. loadOlder={vi.fn(() => Promise.resolve(false))}
  1398. />,
  1399. )
  1400. fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
  1401. act(() => { store.set(historySnapshot([older, current])) })
  1402. const row = screen.getByRole('row', { name: /selected current response/ })
  1403. expect(row.getAttribute('aria-selected')).toBe('true')
  1404. const currentIndex = row.getAttribute('data-record-index')
  1405. expect(view.container.querySelector(
  1406. `[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
  1407. )).toBeTruthy()
  1408. })
  1409. })
  1410. describe('node half', () => {
  1411. it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
  1412. expect(() => { nodeApply() }).not.toThrow()
  1413. })
  1414. })