chat-view.client.spec.tsx 125 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920
  1. // @vitest-environment jsdom
  2. import type { InboxState } from '@deepseek-ai/dsh-agent/types'
  3. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
  6. import { useEffect } from 'react'
  7. import type {
  8. AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
  9. ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
  10. LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode,
  11. ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData,
  12. TranscriptViewMode, UserMessageNode,
  13. } from '@deepseek-ai/dsh-client-ui-chat/client'
  14. import type {
  15. SessionListState, SessionSnapshot,
  16. } from '@deepseek-ai/dsh-api-session-controller/client'
  17. import type {
  18. ConversationLocationDataStore, ConversationTurnDataMap,
  19. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  20. import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
  21. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  22. import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  23. import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
  24. import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  25. import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
  26. import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
  27. import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
  28. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  29. import { createChatStore } from '../src/client/stores.ts'
  30. import { ChatView } from '../src/client/chat/ChatView.tsx'
  31. import { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx'
  32. import { useTurnDataValue } from '../src/client/chat/use-turn-data.ts'
  33. import { en, zh } from '../src/client/locale.ts'
  34. import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
  35. import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
  36. import {
  37. CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
  38. TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
  39. } from '../src/client/chat/MessageItem.tsx'
  40. import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
  41. import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
  42. import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
  43. import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
  44. import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
  45. import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
  46. import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
  47. // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  48. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
  49. afterEach(() => {
  50. cleanup()
  51. vi.unstubAllGlobals()
  52. })
  53. // Keyless create() persists under the bare declared key; clear between cases
  54. // so one harness's selection cannot rehydrate into the next.
  55. beforeEach(() => {
  56. localStorage.clear()
  57. })
  58. const SID = 's1' as SessionId
  59. type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
  60. interface TestSessionSnapshot extends SessionSnapshot {
  61. readonly testInbox?: InboxState
  62. }
  63. function sessionSnapshot(overrides: Partial<TestSessionSnapshot> = {}): TestSessionSnapshot {
  64. return {
  65. sessionId: SID,
  66. pendingSubmissions: [],
  67. running: false,
  68. removed: false,
  69. openState: 'open',
  70. openError: null,
  71. hasMore: false,
  72. loadingOlder: false,
  73. promptError: null,
  74. blank: false,
  75. subagent: null,
  76. lastAgentError: null,
  77. promptAttempted: true,
  78. awaitingFirstTurn: false,
  79. ...overrides,
  80. }
  81. }
  82. /** Scripted Session source: set() swaps the top-level object like the real Controller binding. */
  83. function makeSessionSource(init: Partial<TestSessionSnapshot> = {}) {
  84. let snap = sessionSnapshot(init)
  85. const subs = new Set<() => void>()
  86. return {
  87. set: (next: Partial<TestSessionSnapshot>) => {
  88. snap = { ...snap, ...next }
  89. for (const fn of [...subs]) fn()
  90. },
  91. source: {
  92. getSnapshot: () => snap,
  93. subscribe: (fn: () => void) => {
  94. subs.add(fn)
  95. return () => subs.delete(fn)
  96. },
  97. },
  98. }
  99. }
  100. type ChatSlice = Partial<LegacyConversationSlice> & {
  101. readonly turnUsages?: NonNullable<Parameters<typeof chatSnapshotFixture>[0]>['turnUsages']
  102. }
  103. type HarnessUpdate = ChatSlice & Partial<TestSessionSnapshot> & { readonly chat?: ChatSnapshot }
  104. /** Scripted Chat target source, independent from Session lifecycle state. */
  105. function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
  106. let snap = snapshot ?? chatSnapshotFixture(init)
  107. const subs = new Set<() => void>()
  108. return {
  109. set: (next: ChatSlice) => {
  110. snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
  111. for (const fn of [...subs]) fn()
  112. },
  113. replace: (next: ChatSnapshot) => {
  114. snap = next
  115. for (const fn of [...subs]) fn()
  116. },
  117. source: {
  118. getSnapshot: () => snap,
  119. subscribe: (fn: () => void) => {
  120. subs.add(fn)
  121. return () => subs.delete(fn)
  122. },
  123. },
  124. }
  125. }
  126. const user = (seq: number, text: string): UserMessageNode => ({
  127. kind: 'user',
  128. seq,
  129. time: seq * 1000,
  130. content: [{ type: 'text', text }] as never,
  131. source: null,
  132. })
  133. const userInTurn = (seq: number, text: string, turn: number): ConversationNode => ({
  134. ...user(seq, text),
  135. // The production Location index owns this association. The legacy fixture
  136. // accepts the extra coordinate so component tests can build the same view.
  137. turn,
  138. } as unknown as ConversationNode)
  139. const assistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
  140. kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text }],
  141. })
  142. const reasoningAssistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
  143. kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'reasoning', text }],
  144. })
  145. const context = (seq: number, text: string, turn?: number): ContextMessageNode & { turn?: number } => ({
  146. kind: 'context', seq, time: seq * 1_000, content: [{ type: 'text', text }], source: null,
  147. producer: { role: 'inject', label: null }, form: null,
  148. ...(turn === undefined ? {} : { turn }),
  149. })
  150. const steering = (seq: number, text: string, turn: number): SteeringMessageNode & { turn: number } => ({
  151. kind: 'steering', messageId: `steering-${String(seq)}` as SteeringMessageNode['messageId'],
  152. seq, time: seq * 1_000, turn, content: [{ type: 'text', text }], source: null,
  153. })
  154. const retry = (seq: number): ModelRetryNode => ({
  155. kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
  156. seq, time: seq * 1_000, turn: 1, step: 0,
  157. retryState: 'scheduled',
  158. provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
  159. retry: 1, maxRetries: 2, delayMs: 450,
  160. failure: { code: 'TRANSPORT', message: '连接被重置' },
  161. })
  162. const turnError = (seq: number, code?: string): TurnErrorNode => ({
  163. kind: 'turn-error', seq, time: seq * 1_000, turn: 1, step: 0,
  164. message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
  165. ...(code === undefined ? {} : { code }),
  166. })
  167. const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({
  168. kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0,
  169. })
  170. const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
  171. kind: 'tool-result', seq, time: seq * 1_000, callId,
  172. call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
  173. callTime: seq * 1_000 - 500,
  174. content: [], isError: false, subCalls: [],
  175. })
  176. const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
  177. callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
  178. })
  179. const command = (over: Partial<CommandNode> = {}): CommandNode => ({
  180. kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
  181. name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
  182. ...over,
  183. })
  184. const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummaryNode => ({
  185. kind: 'compaction', seq: 8, time: 8_000,
  186. summary: '## 压缩摘要\n\n保留的事实。',
  187. summaryEventSeq: 7,
  188. shadowedItemCount: 16,
  189. shadowedTokenCount: 11_309,
  190. ...over,
  191. })
  192. /** Empty sessions-list hook for the global standard-kit seat. */
  193. function emptySessions() {
  194. const store = createSnapshotStore<SessionListState>(
  195. { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
  196. return bindSnapshotSelector(store)
  197. }
  198. function emptyWorkspaces() {
  199. const store = createSnapshotStore<WorkspaceSnapshot>({
  200. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  201. })
  202. return bindSnapshotSelector(store)
  203. }
  204. function bindKeyedSnapshotSelector<Value>(
  205. resolve: (key: string) => ObservableSnapshot<Value>,
  206. ): KeyedSnapshotSelectorHook<Value> {
  207. const hooks = new WeakMap<object, SnapshotSelectorHook<Value>>()
  208. return ((key: string, selector?: (value: Value) => unknown, equal?: (left: unknown, right: unknown) => boolean) => {
  209. const source = resolve(key)
  210. let useValue = hooks.get(source)
  211. if (useValue === undefined) {
  212. useValue = bindSnapshotSelector(source)
  213. hooks.set(source, useValue)
  214. }
  215. return useValue(selector ?? ((value: Value) => value), equal)
  216. }) as KeyedSnapshotSelectorHook<Value>
  217. }
  218. function makeHarness(
  219. init: HarnessUpdate = {},
  220. sessionOverrides: Partial<TestSessionSnapshot> = {},
  221. chatSnapshot?: ChatSnapshot,
  222. ) {
  223. const {
  224. chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, turnUsages,
  225. ...sessionInit
  226. } = init
  227. const chatSlice: ChatSlice = {
  228. ...(nodes === undefined ? {} : { nodes }),
  229. ...(partial === undefined ? {} : { partial }),
  230. ...(runningCalls === undefined ? {} : { runningCalls }),
  231. ...(turnTimings === undefined ? {} : { turnTimings }),
  232. ...(turnEnds === undefined ? {} : { turnEnds }),
  233. ...(turnUsages === undefined ? {} : { turnUsages }),
  234. }
  235. const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
  236. const useTestSession = bindSnapshotSelector(session.source)
  237. const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
  238. const useChatNode = bindKeyedSnapshotSelector(
  239. key => chatSource.source.getSnapshot().nodes.source(key),
  240. )
  241. const useChatNodeProcess = bindKeyedSnapshotSelector(
  242. key => chatSource.source.getSnapshot().nodes.processSource(key),
  243. )
  244. const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
  245. const openSkill = vi.fn<(name: string) => void>()
  246. const loadOlder = vi.fn()
  247. const loadThrough = vi.fn<(seq: number) => Promise<void>>().mockResolvedValue(undefined)
  248. // Mutable outline holder: tests swap the value and drive a re-render via set().
  249. let outlineValue: unknown
  250. const openView = vi.fn<(view: string, focus: string) => void>()
  251. // In-memory scroll memory matching the apply.ts per-session map contract.
  252. let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
  253. const chatScroll: ChatViewSlotProps['chatScroll'] = {
  254. save: (position) => { savedScroll = position },
  255. read: () => savedScroll,
  256. }
  257. const forkAt = vi.fn()
  258. // Rows and the harness must observe the same chat-store instance.
  259. const chat = createChatStore().create()
  260. const transcriptView = createSnapshotStore<TranscriptViewMode>('compact')
  261. const t = makeTranslate(zh, commonZh)
  262. const toolOwners: Array<{
  263. callId: string
  264. toolName: string
  265. block: ToolCallBlock
  266. openFile: ChatNodeOwnerProps['openFile']
  267. inspectCall: ChatNodeOwnerProps['inspectCall']
  268. }> = []
  269. const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
  270. opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
  271. const renderTurnTailSlot = (() => null) as unknown as
  272. React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
  273. let nodeSlotOverride: React.ComponentProps<typeof ChatNodeSeat>['renderSlot'] | undefined
  274. const renderNodeSlot = ((key: string, owner: object, opts?: {
  275. fallback?: React.ReactNode
  276. hookContext?: unknown
  277. }) => {
  278. if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never)
  279. if (key !== 'conversation.chat.node') return opts?.fallback ?? null
  280. const nodeOwner = owner as RoutedChatNodeOwner
  281. const turnData = opts?.hookContext as
  282. ConversationLocationDataStore<ConversationTurnDataMap> | undefined
  283. const useTurnData: UseChatNodeTurnData = dataKey => useTurnDataValue(turnData, dataKey)
  284. const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
  285. { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
  286. )
  287. switch (nodeOwner.node.kind) {
  288. case 'user':
  289. case 'steering':
  290. return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
  291. case 'context':
  292. return <ContextMessageNodeView {...nodeProps<'context'>()} />
  293. case 'assistant-step':
  294. return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
  295. case 'command':
  296. return (
  297. <CommandNodeView
  298. {...nodeProps<'command'>()}
  299. renderSlot={renderCommandSlot}
  300. SessionProvider={props.SessionProvider}
  301. />
  302. )
  303. case 'manual-compaction':
  304. return <ManualCompactionNodeView {...nodeProps<'manual-compaction'>()} />
  305. case 'compaction':
  306. return <CompactionNodeView {...nodeProps<'compaction'>()} />
  307. case 'model-retry':
  308. return <RetryNodeView {...nodeProps<'model-retry'>()} />
  309. case 'turn-error':
  310. return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
  311. case 'turn-max-tokens':
  312. return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
  313. case 'turn-process':
  314. return <TurnProcessNodeView {...nodeProps<'turn-process'>()} />
  315. case 'system-prompt':
  316. return <SystemPromptNodeView {...nodeProps<'system-prompt'>()} />
  317. case 'turn-tail':
  318. return (
  319. <TurnTailNodeView
  320. {...nodeProps<'turn-tail'>()}
  321. renderSlot={renderTurnTailSlot}
  322. SessionProvider={props.SessionProvider}
  323. />
  324. )
  325. case 'unknown':
  326. return <UnknownNodeView {...nodeProps<'unknown'>()} />
  327. case 'tool-call': {
  328. const block = nodeOwner.node.data.root
  329. const toolName = 'kind' in block ? block.call?.name ?? '' : block.name
  330. const tool = {
  331. callId: block.callId,
  332. toolName,
  333. block,
  334. openFile: nodeOwner.openFile,
  335. inspectCall: nodeOwner.inspectCall,
  336. }
  337. toolOwners.push(tool)
  338. return (
  339. <div
  340. data-testid={`tool-seat-${tool.callId}`}
  341. data-chat-anchor-key={`call:${tool.callId}`}
  342. data-chat-call-id={tool.callId}
  343. >
  344. {tool.toolName || '(unnamed)'}:{tool.callId}
  345. </div>
  346. )
  347. }
  348. default:
  349. return opts?.fallback ?? null
  350. }
  351. }) as unknown as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
  352. const renderSlot = renderNodeSlot
  353. // SessionProvider seat arrives with the session-scope child declaration;
  354. // ChatView never invokes it (pass-through stub).
  355. const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
  356. const props: ChatViewSlotProps = {
  357. usePanelInfo: selector => selector({ activePanelId: null }),
  358. sessionId: SID,
  359. useSession: bindSnapshotSelector(session.source),
  360. useChat: bindSnapshotSelector(chatSource.source),
  361. useChatNode,
  362. useChatNodeProcess,
  363. useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
  364. useTrajectory: (() => { throw new Error('unused') }),
  365. useSessions: emptySessions(),
  366. useSessionRetainInfo: () => undefined,
  367. useResource,
  368. useSessionStatus: bindSnapshotSelector(
  369. createSnapshotStore<SessionStatusSnapshot>(new Map()),
  370. ),
  371. useWorkspaces: emptyWorkspaces(),
  372. useProjection: (key: string) => {
  373. const inbox = useTestSession(snapshot => snapshot.testInbox)
  374. return key === 'inbox' ? inbox : outlineValue
  375. },
  376. useInput: (() => { throw new Error('unused') }),
  377. inputActions: {
  378. setDraft: () => {},
  379. addAttachments: () => true,
  380. removeAttachment: () => {},
  381. pruneAttachments: () => {},
  382. submit: () => {},
  383. },
  384. useStore: bindSnapshotSelector(chat),
  385. actions: chat.actions,
  386. useTranscriptView: bindSnapshotSelector(transcriptView),
  387. renderSlot,
  388. SessionProvider: SessionProviderStub,
  389. viewRequest: null,
  390. openView,
  391. completeViewRequest: () => {},
  392. openFile,
  393. openSkill,
  394. openExternalLink: vi.fn(),
  395. loadOlder,
  396. loadThrough,
  397. loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
  398. chatScroll,
  399. forkAt,
  400. // Absent-service default; mention tests override with a real resolver.
  401. fileMentions: () => undefined,
  402. t,
  403. }
  404. const set = (next: HarnessUpdate): void => {
  405. const {
  406. chat: explicitChat, nodes, partial, runningCalls, turnTimings, turnEnds,
  407. ...sessionUpdate
  408. } = next
  409. if (explicitChat !== undefined) chatSource.replace(explicitChat)
  410. else if (nodes !== undefined || partial !== undefined || runningCalls !== undefined
  411. || turnTimings !== undefined || turnEnds !== undefined) {
  412. chatSource.set({
  413. ...(nodes === undefined ? {} : { nodes }),
  414. ...(partial === undefined ? {} : { partial }),
  415. ...(runningCalls === undefined ? {} : { runningCalls }),
  416. ...(turnTimings === undefined ? {} : { turnTimings }),
  417. ...(turnEnds === undefined ? {} : { turnEnds }),
  418. })
  419. }
  420. session.set(sessionUpdate)
  421. }
  422. return {
  423. set, setSession: session.set, setChat: chatSource.set, ChatView, props,
  424. openFile, openSkill, loadOlder, loadThrough, openView,
  425. setOutline: (value: unknown) => { outlineValue = value },
  426. chatScroll, forkAt, toolOwners,
  427. setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
  428. setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
  429. nodeSlotOverride = renderer
  430. },
  431. }
  432. }
  433. /** Simulate reader input (any device): a delivered position that deviates
  434. * from the observed-top ledger of programmatic writes. */
  435. function readerScroll(element: HTMLElement, top: number): void {
  436. element.scrollTop = top
  437. fireEvent.scroll(element)
  438. fireEvent(element, new Event('scrollend'))
  439. }
  440. function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
  441. return container.querySelector<HTMLButtonElement>('[data-turn-process]')
  442. }
  443. function withSystemPrompt(
  444. snapshot: ChatSnapshot,
  445. builder = new ChatSnapshotBuilder(),
  446. text = '# System',
  447. ): ChatSnapshot {
  448. const turn = snapshot.timeline.turns.get(1)
  449. if (turn === undefined) throw new Error('fixture lacks Turn 1')
  450. const prompt: ChatNode<'system-prompt'> = {
  451. key: 'fixture:system-prompt:1',
  452. id: '1',
  453. target: 'chat',
  454. kind: 'system-prompt',
  455. anchorSeq: 1,
  456. location: { kind: 'turn', turn },
  457. visibility: 'visible',
  458. data: { text },
  459. }
  460. return builder.replace({
  461. nodes: [prompt, ...snapshot.nodes.values()],
  462. timeline: snapshot.timeline,
  463. })
  464. }
  465. function renderedFlowKinds(container: HTMLElement): Array<string | undefined> {
  466. return [...container.querySelectorAll<HTMLElement>('[data-chat-flow-kind]')]
  467. .map(row => row.dataset.chatFlowKind)
  468. }
  469. function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
  470. let scrollHeight = initialHeight
  471. let scrollTop = 0
  472. Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
  473. Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
  474. Object.defineProperty(element, 'scrollTop', {
  475. configurable: true,
  476. get: () => scrollTop,
  477. set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
  478. })
  479. return {
  480. setHeight: (value: number) => { scrollHeight = value },
  481. setLayout: (height: number, top: number) => {
  482. scrollHeight = height
  483. scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
  484. },
  485. }
  486. }
  487. describe('Chat node rendering', () => {
  488. it('opens Markdown references to unmodified files with line navigation', () => {
  489. const h = makeHarness({
  490. nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)],
  491. turnEnds: new Map([[1, 2]]),
  492. })
  493. const view = render(<h.ChatView {...h.props} />)
  494. fireEvent.click(view.getByRole('button', { name: 'source' }))
  495. expect(h.openFile).toHaveBeenCalledWith('src/index.ts', { line: 24 })
  496. })
  497. it('threads the injected file-mention vocabulary into the closing prose only', () => {
  498. const wrote = (seq: number, callId: string): ToolResultNode => ({
  499. ...toolResult(seq, callId, 'write'),
  500. })
  501. const h = makeHarness({
  502. nodes: [
  503. user(1, 'build it'),
  504. assistant(2, 'writing `report.html` now', 1),
  505. wrote(3, 'w'),
  506. assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
  507. ],
  508. turnEnds: new Map([[1, 4]]),
  509. })
  510. h.props.fileMentions = owner => ({
  511. resolve: (value) => {
  512. if (value !== 'report.html') return undefined
  513. return {
  514. open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
  515. label: '打开 site/report.html',
  516. title: 'site/report.html',
  517. }
  518. },
  519. })
  520. const view = render(<h.ChatView {...h.props} />)
  521. // Exactly one live mention: the closing message links, the mid-turn
  522. // narration stays inert code, and the unknown file resolves to nothing.
  523. const mentions = view.container.querySelectorAll('code button')
  524. expect(mentions).toHaveLength(1)
  525. const mention = view.getByRole('button', { name: '打开 site/report.html' })
  526. expect(mention.getAttribute('title')).toBe('site/report.html')
  527. fireEvent.click(mention)
  528. // The vocabulary was built from the closing message's own owner currency.
  529. expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
  530. })
  531. it('formatRunDuration localizes units and floors partial seconds', () => {
  532. const t = makeTranslate(zh, commonZh)
  533. expect(formatRunDuration(0, t)).toBe('0秒')
  534. expect(formatRunDuration(-500, t)).toBe('0秒')
  535. expect(formatRunDuration(15_999, t)).toBe('15秒')
  536. expect(formatRunDuration(125_000, t)).toBe('2分05秒')
  537. // The hour rolls at exactly 3600s, never at 60 displayed minutes.
  538. expect(formatRunDuration(3_599_999, t)).toBe('59分59秒')
  539. expect(formatRunDuration(3_600_000, t)).toBe('1小时00分00秒')
  540. expect(formatRunDuration(3_903_000, t)).toBe('1小时05分03秒')
  541. expect(formatRunDuration(7_261_000, t)).toBe('2小时01分01秒')
  542. })
  543. it('formatRunDuration uses the English hour template', () => {
  544. const t = makeTranslate(en, commonEn)
  545. expect(formatRunDuration(3_903_000, t)).toBe('1h 05m 03s')
  546. })
  547. })
  548. describe('ChatView', () => {
  549. it('leaves the turn rail unrendered when an unrelated Chat update commits', () => {
  550. const snapshot = chatSnapshotFixture({
  551. nodes: [
  552. userInTurn(1, 'first prompt', 1),
  553. assistant(2, 'first response', 1),
  554. userInTurn(4, 'second prompt', 2),
  555. assistant(5, 'second response', 2),
  556. ],
  557. turnEnds: new Map([[1, 3], [2, 6]]),
  558. })
  559. const h = makeHarness({}, {}, snapshot)
  560. // The rail asks for its own accessible name once per render, so counting
  561. // that key counts renders without reaching into the component.
  562. let railRenders = 0
  563. const translate = h.props.t
  564. const counting = ((key: string, vars?: Record<string, unknown>) => {
  565. if (key === 'chat.turnNavigation.label') railRenders += 1
  566. return (translate as (k: string, v?: Record<string, unknown>) => string)(key, vars)
  567. }) as ChatViewSlotProps['t']
  568. render(<h.ChatView {...h.props} t={counting} />)
  569. const afterMount = railRenders
  570. expect(afterMount).toBeGreaterThan(0)
  571. act(() => { h.setTranscriptView('compact') })
  572. expect(railRenders).toBe(afterMount)
  573. })
  574. it('projects loaded turns into prompt and response navigation previews', () => {
  575. const snapshot = chatSnapshotFixture({
  576. nodes: [
  577. userInTurn(1, 'first prompt', 1),
  578. assistant(2, 'first response', 1),
  579. userInTurn(4, 'second prompt', 2),
  580. assistant(5, 'second response', 2),
  581. ],
  582. turnEnds: new Map([[1, 3], [2, 6]]),
  583. })
  584. expect(snapshot.navigation.items()).toEqual([
  585. { turn: 1, anchorKey: 'fixture:user:1', prompt: 'first prompt', response: 'first response' },
  586. { turn: 2, anchorKey: 'fixture:user:4', prompt: 'second prompt', response: 'second response' },
  587. ])
  588. const h = makeHarness({}, {}, snapshot)
  589. const view = render(<h.ChatView {...h.props} />)
  590. const navigation = view.getByRole('navigation', { name: '轮次导航' })
  591. expect(navigation.style.getPropertyValue('--turn-natural-height')).toBe('22px')
  592. const first = view.getByRole('button', { name: '跳转到第 1 轮' })
  593. const second = view.getByRole('button', { name: '跳转到第 2 轮' })
  594. expect(first.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('0px')
  595. expect(second.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('10px')
  596. expect(second.getAttribute('aria-current')).toBe('true')
  597. fireEvent.focus(first)
  598. const preview = view.getByRole('tooltip')
  599. expect(preview.textContent).toContain('first prompt')
  600. expect(preview.textContent).toContain('first response')
  601. })
  602. it('jumps to a turn anchor and reflows stable marks after an older page arrives', () => {
  603. const later = [
  604. userInTurn(4, 'second prompt', 2), assistant(5, 'second response', 2),
  605. userInTurn(7, 'third prompt', 3), assistant(8, 'third response', 3),
  606. ]
  607. const h = makeHarness({ nodes: later }, { hasMore: true })
  608. const view = render(<h.ChatView {...h.props} />)
  609. const second = view.getByRole('button', { name: '跳转到第 2 轮' })
  610. const secondPosition = second.parentElement as HTMLElement
  611. expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('0px')
  612. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  613. const metrics = installScrollMetrics(scroller, 1_000, 300)
  614. metrics.setLayout(1_000, 700)
  615. vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect)
  616. const secondRow = view.container.querySelector('[data-chat-flow-key="fixture:user:4"]') as HTMLElement
  617. vi.spyOn(secondRow, 'getBoundingClientRect').mockReturnValue({ top: -500, bottom: -440 } as DOMRect)
  618. fireEvent.click(second)
  619. expect(scroller.scrollTop).toBe(176)
  620. expect(second.getAttribute('aria-current')).toBe('true')
  621. act(() => {
  622. h.setChat({
  623. nodes: [userInTurn(1, 'first prompt', 1), assistant(2, 'first response', 1), ...later],
  624. turnTimings: new Map([[1, { startTime: 1_000 }], [2, { startTime: 4_000 }], [3, { startTime: 7_000 }]]),
  625. })
  626. })
  627. const movedSecond = view.getByRole('button', { name: '跳转到第 2 轮' })
  628. expect(movedSecond.parentElement).toBe(secondPosition)
  629. // Fixed pitch: the mark moves one slot down and never compresses.
  630. expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('10px')
  631. })
  632. it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
  633. const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
  634. const h = makeHarness({ nodes: later }, { hasMore: true })
  635. h.setOutline([
  636. { turn: 1, seq: 0, prompt: 'first prompt from outline', response: 'first answer from outline' },
  637. { turn: 2, seq: 4, prompt: 'second prompt from outline', response: '' },
  638. { turn: 3, seq: 8, prompt: 'third prompt', response: 'third response' },
  639. ])
  640. const view = render(<h.ChatView {...h.props} />)
  641. const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
  642. view.getByRole('button', { name: '加载并跳转到第 2 轮' })
  643. const third = view.getByRole('button', { name: '跳转到第 3 轮' })
  644. expect(third.getAttribute('aria-current')).toBe('true')
  645. fireEvent.focus(first)
  646. // An unloaded turn previews both sides from the outline.
  647. expect(view.getByRole('tooltip').textContent).toContain('first prompt from outline')
  648. expect(view.getByRole('tooltip').textContent).toContain('first answer from outline')
  649. fireEvent.click(first)
  650. expect(h.loadThrough).toHaveBeenCalledWith(0)
  651. expect(first.getAttribute('aria-busy')).toBe('true')
  652. // The fake loader never delivers rows: settlement repages once for the
  653. // unmoved head, then lands on the nearest rendered turn and un-busies.
  654. await act(async () => {})
  655. expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
  656. expect(first.getAttribute('aria-busy')).toBeNull()
  657. expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
  658. })
  659. it('a jump from the pinned tail releases bottom ownership so the follow snap cannot cancel it', async () => {
  660. const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
  661. const h = makeHarness({ nodes: later }, { hasMore: true })
  662. h.setOutline([
  663. { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
  664. { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
  665. ])
  666. let releaseJump: (() => void) | undefined
  667. h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
  668. const view = render(<h.ChatView {...h.props} />)
  669. // Pinned to the tail on open: the back-to-bottom control is absent.
  670. expect(view.queryByRole('button', { name: '回到底部' })).toBeNull()
  671. const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
  672. fireEvent.click(first)
  673. // The click itself leaves the tail...
  674. expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
  675. // ...so a non-reader scroll delivery at the floor (the first prepend's
  676. // compensation fires one) no longer snaps to the tail and cancel the jump.
  677. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement
  678. fireEvent.scroll(scroller)
  679. expect(first.getAttribute('aria-busy')).toBe('true')
  680. await act(async () => { releaseJump?.() })
  681. })
  682. it('holds a jump issued while a plain pull owns the pager and resumes it when the pull settles', async () => {
  683. const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
  684. const h = makeHarness({ nodes: later }, { hasMore: true, loadingOlder: true })
  685. h.setOutline([
  686. { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
  687. { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
  688. ])
  689. const view = render(<h.ChatView {...h.props} />)
  690. const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
  691. fireEvent.click(first)
  692. // The session-side guard refuses the busy-pager jump instantly, yet the
  693. // mark stays busy instead of degrading to the nearest loaded turn.
  694. await act(async () => {})
  695. expect(h.loadThrough.mock.calls).toEqual([[0]])
  696. expect(first.getAttribute('aria-busy')).toBe('true')
  697. // The plain pull settles: the flip re-settles the jump, which repages.
  698. act(() => { h.setSession({ loadingOlder: false }) })
  699. await act(async () => {})
  700. expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
  701. expect(first.getAttribute('aria-busy')).toBeNull()
  702. })
  703. it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
  704. const h = makeHarness(
  705. { nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
  706. { hasMore: true },
  707. )
  708. h.setOutline(Array.from({ length: 60 }, (_, index) => ({
  709. turn: index + 1,
  710. seq: index * 4,
  711. prompt: `p${String(index + 1)}`,
  712. response: '',
  713. })))
  714. const view = render(<h.ChatView {...h.props} />)
  715. const nav = view.getByRole('navigation', { name: '轮次导航' })
  716. // 60 marks at the fixed 10px pitch: the ladder keeps its natural height.
  717. expect(nav.style.getPropertyValue('--turn-natural-height')).toBe('602px')
  718. const scroller = nav.querySelector('[class*="scroller"]') as HTMLElement
  719. Object.defineProperty(scroller, 'scrollHeight', { value: 602, configurable: true })
  720. Object.defineProperty(scroller, 'clientHeight', { value: 300, configurable: true })
  721. scroller.scrollTop = 0
  722. fireEvent.scroll(scroller)
  723. expect(scroller.className).toContain('fadeBottom')
  724. expect(scroller.className).not.toContain('fadeTop')
  725. scroller.scrollTop = 150
  726. fireEvent.scroll(scroller)
  727. expect(scroller.className).toContain('fadeTop')
  728. expect(scroller.className).toContain('fadeBottom')
  729. expect(nav.style.getPropertyValue('--turn-scroll-top')).toBe('150px')
  730. // Pointer mapping subtracts the rail scroll: y=94 with scrollTop 150 is
  731. // natural offset 238px → the 25th mark.
  732. vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue({ top: 0 } as DOMRect)
  733. fireEvent.pointerMove(nav, { clientY: 94 })
  734. expect(view.getByRole('tooltip').textContent).toContain('p25')
  735. })
  736. it('lands a jump on its turn once the paged rows commit', async () => {
  737. const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
  738. const h = makeHarness({ nodes: later }, { hasMore: true })
  739. h.setOutline([
  740. { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
  741. { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
  742. ])
  743. let releaseJump: (() => void) | undefined
  744. h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
  745. const view = render(<h.ChatView {...h.props} />)
  746. fireEvent.click(view.getByRole('button', { name: '加载并跳转到第 1 轮' }))
  747. expect(h.loadThrough).toHaveBeenCalledWith(0)
  748. // The paged window commits: turn 1's rows and rail item enter the snapshot.
  749. act(() => {
  750. h.setChat({
  751. nodes: [userInTurn(0, 'first prompt', 1), assistant(1, 'first response', 1), ...later],
  752. turnTimings: new Map([[1, { startTime: 1_000 }], [3, { startTime: 8_000 }]]),
  753. })
  754. })
  755. const first = view.getByRole('button', { name: '跳转到第 1 轮' })
  756. expect(first.getAttribute('aria-current')).toBe('true')
  757. // The mark stays busy until the jump settles: the loader's completion
  758. // runs the final landing correction after the load-earlier button leaves.
  759. expect(first.getAttribute('aria-busy')).toBe('true')
  760. await act(async () => { releaseJump?.() })
  761. // Only the busy lifecycle is asserted after settlement: jsdom's zero
  762. // geometry makes the rAF active-turn resync read "at bottom" and hand the
  763. // mark to the last turn, so aria-current here is timing-dependent under
  764. // instrumentation; the landing position contract lives in the browser e2e.
  765. expect(first.getAttribute('aria-busy')).toBeNull()
  766. })
  767. it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
  768. const h = makeHarness({
  769. nodes: [{ ...toolResult(3, 'w1'), call: null }],
  770. })
  771. const view = render(<h.ChatView {...h.props} />)
  772. expect(view.getByTestId('tool-seat-w1')).toBeTruthy()
  773. expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
  774. })
  775. it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
  776. const h = makeHarness(
  777. { nodes: [user(9, 'first visible'), user(10, 'next visible')] },
  778. { hasMore: true },
  779. )
  780. const view = render(<h.ChatView {...h.props} />)
  781. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  782. const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
  783. const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
  784. let firstTop = 100
  785. let nextTop = 300
  786. vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
  787. () => ({ top: 0, bottom: 200 } as DOMRect),
  788. )
  789. vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
  790. () => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
  791. )
  792. vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
  793. () => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
  794. )
  795. Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
  796. Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
  797. readerScroll(scroller, 50)
  798. fireEvent.click(view.getByText('加载更早'))
  799. // The reader moves after the request starts; this, not the click-time
  800. // row, is the intent the arriving page must preserve.
  801. firstTop = -200
  802. nextTop = 60
  803. readerScroll(scroller, 90)
  804. Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
  805. nextTop = 560
  806. act(() => {
  807. h.setChat({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] })
  808. })
  809. expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
  810. })
  811. it('bounds no-anchor hit testing before using the mounted-row fallback', () => {
  812. const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
  813. const hitTest = vi.fn((): Element[] => [])
  814. Object.defineProperty(document, 'elementsFromPoint', {
  815. configurable: true,
  816. value: hitTest,
  817. })
  818. try {
  819. const h = makeHarness({ nodes: [user(1, 'visible row')] })
  820. const view = render(<h.ChatView {...h.props} />)
  821. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  822. const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
  823. installScrollMetrics(scroller, 4_000, 2_000)
  824. vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({
  825. top: 0, bottom: 2_000, left: 0, right: 1_000,
  826. } as DOMRect)
  827. vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({
  828. top: 100, bottom: 140, left: 0, right: 1_000,
  829. } as DOMRect)
  830. readerScroll(scroller, 100)
  831. expect(hitTest).toHaveBeenCalledTimes(1)
  832. expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:1')
  833. } finally {
  834. if (originalHitTest !== undefined) {
  835. Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
  836. } else {
  837. Reflect.deleteProperty(document, 'elementsFromPoint')
  838. }
  839. }
  840. })
  841. it('falls back to the first visible row when the viewport top hit-test misses', () => {
  842. const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
  843. const nodes = Array.from({ length: 16 }, (_, index) => user(20 + index, `row ${String(index)}`))
  844. const h = makeHarness(
  845. { nodes },
  846. { hasMore: true },
  847. )
  848. const view = render(<h.ChatView {...h.props} />)
  849. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  850. const rows = [...view.container.querySelectorAll<HTMLElement>('[data-chat-flow-key]')]
  851. let prepended = false
  852. let rowRectCalls = 0
  853. vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
  854. () => ({ top: 0, bottom: 200 } as DOMRect),
  855. )
  856. rows.forEach((row, index) => {
  857. vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => {
  858. rowRectCalls += 1
  859. const shift = prepended ? (index === 8 ? 400 : 500) : 0
  860. const top = 20 + (index - 8) * 60 + shift
  861. return { top, bottom: top + 40 } as DOMRect
  862. })
  863. })
  864. Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
  865. Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
  866. readerScroll(scroller, 50)
  867. const hitTest = vi.fn((_x: number, _y: number): Element[] => [])
  868. Object.defineProperty(document, 'elementsFromPoint', {
  869. configurable: true,
  870. value: hitTest,
  871. })
  872. try {
  873. rowRectCalls = 0
  874. fireEvent.click(view.getByText('加载更早'))
  875. expect(hitTest).toHaveBeenCalledTimes(1)
  876. expect(hitTest.mock.calls[0]?.[1]).toBe(1)
  877. expect(rowRectCalls).toBeLessThanOrEqual(6)
  878. Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
  879. prepended = true
  880. act(() => {
  881. h.setChat({ nodes: [assistant(2, 'older'), ...nodes] })
  882. })
  883. expect(scroller.scrollTop).toBe(450) // reader offset 50 + first visible row's 400px shift
  884. } finally {
  885. if (originalHitTest !== undefined) {
  886. Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
  887. } else {
  888. Reflect.deleteProperty(document, 'elementsFromPoint')
  889. }
  890. }
  891. })
  892. it('renders the fixture main line as independently keyed business nodes', () => {
  893. const h = makeHarness({
  894. nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
  895. })
  896. const view = render(<h.ChatView {...h.props} />)
  897. expect(view.getByText('do the thing')).toBeTruthy()
  898. expect(view.getByText('running tools')).toBeTruthy()
  899. expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a')
  900. expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b')
  901. expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
  902. key: row.getAttribute('data-chat-flow-key'),
  903. kind: row.getAttribute('data-chat-flow-kind'),
  904. }))).toEqual([
  905. { key: 'fixture:user:1', kind: 'user' },
  906. { key: 'fixture:turn-process:1', kind: 'turn-process' },
  907. { key: 'fixture:assistant:2', kind: 'assistant-step' },
  908. { key: 'fixture:tool:a', kind: 'tool-call' },
  909. { key: 'fixture:tool:b', kind: 'tool-call' },
  910. ])
  911. expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
  912. .toEqual(['a', 'b'])
  913. expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
  914. .toEqual([
  915. 'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2',
  916. 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
  917. ])
  918. })
  919. it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
  920. const writeText = vi.fn().mockResolvedValue(undefined)
  921. Object.defineProperty(navigator, 'clipboard', {
  922. configurable: true,
  923. value: { writeText },
  924. })
  925. const pending = {
  926. id: 'steer-occurrence' as never,
  927. role: 'user' as const,
  928. source: { kind: 'user' as const },
  929. content: [{ type: 'text' as const, text: 'interrupt now' }],
  930. preview: 'interrupt now',
  931. text: 'interrupt now',
  932. }
  933. const queued = {
  934. id: 'queued-occurrence' as never,
  935. role: 'user' as const,
  936. source: { kind: 'user' as const },
  937. content: [{ type: 'text' as const, text: 'later' }],
  938. preview: 'later',
  939. text: 'later',
  940. }
  941. const h = makeHarness(
  942. { nodes: [assistant(1, 'working')] },
  943. { testInbox: { 'next-turn': [queued], 'next-step': [pending] }, running: true },
  944. )
  945. const view = render(<h.ChatView {...h.props} />)
  946. expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull()
  947. expect(view.queryByText('later')).toBeNull()
  948. const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
  949. expect(pendingBubble).not.toBeNull()
  950. fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
  951. expect(writeText).toHaveBeenCalledWith('interrupt now')
  952. expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
  953. expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
  954. & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
  955. act(() => {
  956. h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
  957. h.setChat({
  958. nodes: [
  959. assistant(1, 'working'),
  960. {
  961. kind: 'steering', messageId: pending.id,
  962. seq: 2, time: 2_000,
  963. content: [{ type: 'text', text: 'interrupt now' }], source: null,
  964. },
  965. ],
  966. })
  967. })
  968. expect(view.getAllByText('interrupt now')).toHaveLength(1)
  969. expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
  970. // Only the durable steering bubble: the turn is still running, so its
  971. // assistant narration owns no footer yet, and a steering bubble never
  972. // carries a branch action.
  973. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
  974. const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
  975. expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
  976. act(() => {
  977. h.setSession({ running: false })
  978. h.setChat({ turnEnds: new Map([[1, 3]]) })
  979. })
  980. // The Turn Tail belongs to the closed Turn, independently of a later
  981. // steering bubble's placement in the Chat list.
  982. const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
  983. expect(branchButtons).toHaveLength(1)
  984. expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
  985. fireEvent.click(branchButtons[0]!)
  986. expect(h.forkAt).toHaveBeenCalledWith(1)
  987. })
  988. it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
  989. const pending = {
  990. id: 'steer-occurrence-later' as never,
  991. role: 'user' as const,
  992. source: { kind: 'user' as const },
  993. content: [{ type: 'text' as const, text: 'same steering' }],
  994. preview: 'same steering',
  995. text: 'same steering',
  996. }
  997. const h = makeHarness({
  998. nodes: [{
  999. kind: 'user', seq: 2, time: 2_000,
  1000. content: pending.content, source: null,
  1001. }],
  1002. }, { testInbox: { 'next-turn': [], 'next-step': [pending] }, running: true })
  1003. const view = render(<h.ChatView {...h.props} />)
  1004. expect(view.getAllByText('same steering')).toHaveLength(2)
  1005. expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
  1006. })
  1007. it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => {
  1008. const h = makeHarness(
  1009. { nodes: [assistant(1, 'working')] },
  1010. {
  1011. pendingSubmissions: [
  1012. {
  1013. requestId: 'req-1' as never, placement: 'transcript',
  1014. time: 5_000, text: '即发即显', attachments: [],
  1015. },
  1016. ],
  1017. },
  1018. )
  1019. const view = render(<h.ChatView {...h.props} />)
  1020. expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull()
  1021. // The durable node arrives while the echo is STILL in the session
  1022. // snapshot: the render-time rpcId dedupe keeps exactly one bubble.
  1023. act(() => {
  1024. h.setChat({
  1025. nodes: [
  1026. assistant(1, 'working'),
  1027. {
  1028. kind: 'user', seq: 2, time: 2_000,
  1029. content: [{ type: 'text', text: '即发即显' }] as never,
  1030. source: { kind: 'user', rpcId: 'req-1' },
  1031. },
  1032. ],
  1033. })
  1034. })
  1035. expect(view.getAllByText('即发即显')).toHaveLength(1)
  1036. expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
  1037. // The delayed snapshot retirement changes nothing visible.
  1038. act(() => { h.setSession({ pendingSubmissions: [] }) })
  1039. expect(view.getAllByText('即发即显')).toHaveLength(1)
  1040. })
  1041. it('renders a local steer echo as pending steering before Host image admission completes', () => {
  1042. const h = makeHarness(
  1043. { nodes: [assistant(1, 'working')] },
  1044. {
  1045. running: true,
  1046. pendingSubmissions: [{
  1047. requestId: 'req-steer' as never,
  1048. placement: 'steering',
  1049. time: 5_500,
  1050. text: '带图纠偏',
  1051. attachments: [{
  1052. type: 'image', value: { previewUrl: 'blob:steer-preview', name: 'steer.png' },
  1053. }],
  1054. }],
  1055. },
  1056. )
  1057. const view = render(<h.ChatView {...h.props} />)
  1058. const local = view.getByText('带图纠偏').closest('[data-submission-echo]')
  1059. expect(local?.hasAttribute('data-pending-steering')).toBe(true)
  1060. act(() => {
  1061. h.setSession({
  1062. testInbox: { 'next-turn': [], 'next-step': [{
  1063. id: 'steer-occurrence' as never,
  1064. content: [{ type: 'text', text: '带图纠偏' }],
  1065. role: 'user', source: { kind: 'user', rpcId: 'req-steer' as never },
  1066. }] },
  1067. })
  1068. })
  1069. expect(view.getAllByText('带图纠偏')).toHaveLength(1)
  1070. expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
  1071. expect(view.container.querySelector('[data-pending-steering]')).not.toBeNull()
  1072. })
  1073. it('keeps a queued echo out of the Chat flow before and after Host admission', () => {
  1074. const h = makeHarness(
  1075. { nodes: [assistant(1, 'working')] },
  1076. {
  1077. running: true,
  1078. pendingSubmissions: [
  1079. {
  1080. requestId: 'req-q' as never, placement: 'queued',
  1081. time: 6_000, text: '排队中', attachments: [],
  1082. },
  1083. ],
  1084. },
  1085. )
  1086. const view = render(<h.ChatView {...h.props} />)
  1087. expect(view.queryByText('排队中')).toBeNull()
  1088. act(() => {
  1089. h.setSession({
  1090. testInbox: { 'next-step': [], 'next-turn': [{
  1091. id: 'q-occurrence' as never,
  1092. content: [{ type: 'text' as const, text: '排队中' }],
  1093. role: 'user', source: { kind: 'user', rpcId: 'req-q' as never },
  1094. }] },
  1095. })
  1096. })
  1097. // The queued occurrence and its local predecessor both belong to the
  1098. // queue dock, never the Chat flow.
  1099. expect(view.queryByText('排队中')).toBeNull()
  1100. })
  1101. it('an image echo renders its previews through the message-image slot', () => {
  1102. const h = makeHarness(
  1103. { nodes: [] },
  1104. {
  1105. pendingSubmissions: [{
  1106. requestId: 'req-img' as never,
  1107. placement: 'transcript',
  1108. time: 7_000,
  1109. text: '',
  1110. attachments: [
  1111. {
  1112. type: 'image', value: { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
  1113. },
  1114. { type: 'image', value: { previewUrl: 'blob:echo-b' } },
  1115. ],
  1116. }],
  1117. },
  1118. )
  1119. const baseRenderSlot = h.props.renderSlot
  1120. const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
  1121. if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
  1122. const { images, compact } = owner as { images: readonly unknown[]; compact?: boolean }
  1123. return (
  1124. <div
  1125. data-testid="echo-image"
  1126. data-count={images.length}
  1127. data-compact={String(compact)}
  1128. data-first={JSON.stringify(images[0])}
  1129. />
  1130. )
  1131. }) as unknown as ChatViewSlotProps['renderSlot']
  1132. const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
  1133. const images = view.getAllByTestId('echo-image')
  1134. expect(images).toHaveLength(2)
  1135. expect(images.every(image => image.getAttribute('data-count') === '1')).toBe(true)
  1136. expect(images.every(image => image.getAttribute('data-compact') === 'true')).toBe(true)
  1137. expect(images[0]?.parentElement).toBe(images[1]?.parentElement)
  1138. expect(JSON.parse(images[0]?.getAttribute('data-first') ?? '{}')).toEqual({
  1139. preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
  1140. })
  1141. })
  1142. it('a mixed echo renders the Web file card between its selected images', () => {
  1143. const h = makeHarness(
  1144. { nodes: [] },
  1145. {
  1146. pendingSubmissions: [{
  1147. requestId: 'req-mixed' as never,
  1148. placement: 'transcript',
  1149. time: 7_500,
  1150. text: '',
  1151. attachments: [
  1152. { type: 'image', value: { previewUrl: 'blob:first', name: 'first.png' } },
  1153. {
  1154. type: 'file',
  1155. value: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 },
  1156. },
  1157. { type: 'image', value: { previewUrl: 'blob:last', name: 'last.png' } },
  1158. ],
  1159. }],
  1160. },
  1161. )
  1162. const baseRenderSlot = h.props.renderSlot
  1163. const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
  1164. if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
  1165. const { images, compact } = owner as {
  1166. images: ReadonlyArray<{ preview?: { name?: string } }>
  1167. compact?: boolean
  1168. }
  1169. return (
  1170. <div
  1171. data-testid={`images-${images[0]?.preview?.name ?? 'unknown'}`}
  1172. data-compact={String(compact)}
  1173. />
  1174. )
  1175. }) as unknown as ChatViewSlotProps['renderSlot']
  1176. const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
  1177. const first = view.getByTestId('images-first.png')
  1178. const file = view.getByTitle('notes.txt')
  1179. const last = view.getByTestId('images-last.png')
  1180. expect(first.getAttribute('data-compact')).toBe('true')
  1181. expect(last.getAttribute('data-compact')).toBe('true')
  1182. expect(first.parentElement).toBe(file.parentElement)
  1183. expect(file.parentElement).toBe(last.parentElement)
  1184. expect(first.compareDocumentPosition(file) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
  1185. expect(file.compareDocumentPosition(last) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
  1186. expect(view.getByText('TXT 23B')).toBeTruthy()
  1187. })
  1188. it('animates only the latest unresolved model retry', () => {
  1189. const retryNode = retry(2)
  1190. const nextRetry = { ...retry(3), turn: 2, retry: 2 }
  1191. const context = {
  1192. kind: 'context', seq: 4, time: 4_000, content: [], source: null,
  1193. producer: { role: 'inject', label: null },
  1194. form: null,
  1195. } as const satisfies ConversationNode
  1196. const h = makeHarness({ nodes: [user(1, 'try'), retryNode] }, { running: true })
  1197. const view = render(<h.ChatView {...h.props} />)
  1198. const disclosure = view.container.querySelector('details') as HTMLDetailsElement
  1199. expect(disclosure.dataset.active).toBe('true')
  1200. expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
  1201. act(() => {
  1202. h.setChat({ nodes: [user(1, 'try'), nextRetry] })
  1203. })
  1204. expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
  1205. expect(view.container.querySelector('details')).toBe(disclosure)
  1206. expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
  1207. act(() => {
  1208. h.setChat({
  1209. nodes: [
  1210. user(1, 'try'),
  1211. { ...nextRetry, retryState: 'started' },
  1212. context,
  1213. assistant(5, 'done'),
  1214. ],
  1215. })
  1216. h.setSession({ running: false })
  1217. })
  1218. expect(disclosure.dataset.active).toBeUndefined()
  1219. expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
  1220. act(() => {
  1221. h.setChat({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }] })
  1222. h.setSession({ running: true })
  1223. })
  1224. const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
  1225. expect(cancelledDisclosure.dataset.active).toBeUndefined()
  1226. expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
  1227. })
  1228. it('renders terminal turn failures inline with their durable message and optional code', () => {
  1229. const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] })
  1230. const view = render(<h.ChatView {...h.props} />)
  1231. const statuses = view.getAllByRole('status')
  1232. expect(statuses.map(status => status.textContent)).toEqual([
  1233. '本轮运行失败API 密钥无效AUTH',
  1234. '本轮运行失败plugin exploded',
  1235. ])
  1236. })
  1237. it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => {
  1238. const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] })
  1239. const view = render(<h.ChatView {...h.props} />)
  1240. const statuses = view.getAllByRole('status')
  1241. expect(statuses.map(status => status.textContent)).toEqual([
  1242. '已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
  1243. ])
  1244. expect(view.queryByText('本轮运行失败')).toBeNull()
  1245. })
  1246. it('hands the trajectory callback to the Tool seat', () => {
  1247. const h = makeHarness({
  1248. nodes: [toolResult(3, 'a')],
  1249. })
  1250. render(<h.ChatView {...h.props} />)
  1251. h.toolOwners[0]?.inspectCall('a')
  1252. expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
  1253. })
  1254. it('shows assistant IconActions only on the last content message of each turn', () => {
  1255. const h = makeHarness({
  1256. nodes: [
  1257. user(1, 'hi'),
  1258. assistant(2, 'mid-turn text', 1, 1),
  1259. toolResult(3, 'a'),
  1260. assistant(4, 'final answer', 1, 2),
  1261. user(5, 'next'),
  1262. assistant(6, 'second turn', 2),
  1263. ],
  1264. turnEnds: new Map([[1, 4], [2, 6]]),
  1265. })
  1266. const view = render(<h.ChatView {...h.props} />)
  1267. // Branch renders only under assistant answers; user bubbles keep copy alone.
  1268. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
  1269. const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
  1270. expect(branchButtons).toHaveLength(2)
  1271. expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
  1272. })
  1273. it('folds Think and Tool rows before the final answer without unmounting them', () => {
  1274. const first = {
  1275. ...assistant(2, 'earlier reply', 1, 1),
  1276. blocks: [
  1277. { kind: 'reasoning' as const, text: 'inspect the repository' },
  1278. { kind: 'text' as const, text: 'earlier reply' },
  1279. ],
  1280. }
  1281. const second = assistant(5, 'final answer', 1, 2)
  1282. const h = makeHarness({
  1283. nodes: [
  1284. user(1, 'question'),
  1285. first,
  1286. toolResult(3, 'a'),
  1287. toolResult(4, 'b', 'subagent'),
  1288. second,
  1289. ],
  1290. turnTimings: new Map([[1, { startTime: 1_000, endTime: 5_000 }]]),
  1291. turnEnds: new Map([[1, 6]]),
  1292. })
  1293. const view = render(<h.ChatView {...h.props} />)
  1294. const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
  1295. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1296. expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1')
  1297. expect(toggle.getAttribute('data-turn-process-messages')).toBe('1')
  1298. expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1')
  1299. const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
  1300. expect(members).toHaveLength(3)
  1301. expect(members.map(member => member.getAttribute('hidden')))
  1302. .toEqual(['until-found', 'until-found', 'until-found'])
  1303. expect(members[0]?.textContent).toContain('inspect the repository')
  1304. expect(members[1]?.textContent).toContain('bash:a')
  1305. expect(members[2]?.textContent).toContain('subagent:b')
  1306. expect(view.getByText('final answer')).toBeTruthy()
  1307. fireEvent.click(toggle)
  1308. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  1309. expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
  1310. fireEvent.click(toggle)
  1311. expect(members.map(member => member.getAttribute('hidden')))
  1312. .toEqual(['until-found', 'until-found', 'until-found'])
  1313. fireEvent(members[1]!, new Event('beforematch'))
  1314. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  1315. expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
  1316. act(() => { h.set({ nodes: [user(1, 'question'), first] }) })
  1317. expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false')
  1318. expect(members[0]?.getAttribute('hidden')).toBeNull()
  1319. act(() => { h.set({
  1320. nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second],
  1321. }) })
  1322. const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
  1323. expect(renewedToggle.getAttribute('aria-expanded')).toBe('true')
  1324. expect(members[0]?.getAttribute('hidden')).toBeNull()
  1325. })
  1326. it('folds injected Context in place with the rest of the Turn process', () => {
  1327. const h = makeHarness({
  1328. nodes: [
  1329. user(1, 'question'),
  1330. context(2, 'runtime policy changed', 1),
  1331. reasoningAssistant(3, 'inspect the repository', 1, 1),
  1332. toolResult(4, 'a'),
  1333. assistant(5, 'final answer', 1, 2),
  1334. ],
  1335. turnEnds: new Map([[1, 6]]),
  1336. })
  1337. const view = render(<h.ChatView {...h.props} />)
  1338. const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
  1339. const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
  1340. expect(members).toHaveLength(3)
  1341. expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call'])
  1342. expect(contextRow).not.toBeNull()
  1343. expect(contextRow?.getAttribute('hidden')).toBe('until-found')
  1344. fireEvent(contextRow!, new Event('beforematch'))
  1345. expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
  1346. })
  1347. it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
  1348. const builder = new ChatSnapshotBuilder()
  1349. const initial = withSystemPrompt(chatSnapshotFixture({
  1350. nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
  1351. }), builder)
  1352. const h = makeHarness({ chat: initial }, { running: true })
  1353. const view = render(<h.ChatView {...h.props} />)
  1354. const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
  1355. expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context'])
  1356. expect(promptRow.getAttribute('hidden')).toBeNull()
  1357. expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
  1358. act(() => {
  1359. h.set({
  1360. chat: withSystemPrompt(chatSnapshotFixture({
  1361. nodes: [
  1362. userInTurn(2, 'question', 1),
  1363. context(3, 'runtime policy', 1),
  1364. reasoningAssistant(4, 'inspect', 1, 1),
  1365. ],
  1366. }), builder),
  1367. running: true,
  1368. })
  1369. })
  1370. expect(renderedFlowKinds(view.container)).toEqual([
  1371. 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
  1372. ])
  1373. expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
  1374. expect(promptRow.getAttribute('hidden')).toBeNull()
  1375. act(() => {
  1376. h.set({
  1377. chat: withSystemPrompt(chatSnapshotFixture({
  1378. nodes: [
  1379. userInTurn(2, 'question', 1),
  1380. context(3, 'runtime policy', 1),
  1381. reasoningAssistant(4, 'inspect', 1, 1),
  1382. assistant(6, 'final answer', 1, 2),
  1383. ],
  1384. turnEnds: new Map([[1, 7]]),
  1385. }), builder),
  1386. running: false,
  1387. })
  1388. })
  1389. const toggle = turnProcessControl(view.container)!
  1390. const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
  1391. expect(renderedFlowKinds(view.container)).toEqual([
  1392. 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
  1393. ])
  1394. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1395. expect(promptRow.getAttribute('hidden')).toBeNull()
  1396. expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
  1397. expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
  1398. expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
  1399. fireEvent.click(toggle)
  1400. expect(renderedFlowKinds(view.container)).toEqual([
  1401. 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
  1402. ])
  1403. expect(promptRow.getAttribute('hidden')).toBeNull()
  1404. expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
  1405. })
  1406. it('folds Context under the fallback title when every summary count is zero', () => {
  1407. const h = makeHarness({
  1408. nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
  1409. turnEnds: new Map([[1, 4]]),
  1410. })
  1411. const view = render(<h.ChatView {...h.props} />)
  1412. const toggle = view.getByRole('button', { name: '已思考' })
  1413. const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
  1414. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1415. expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0')
  1416. expect(toggle.getAttribute('data-turn-process-messages')).toBe('0')
  1417. expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0')
  1418. expect(contextRow?.getAttribute('hidden')).toBe('until-found')
  1419. fireEvent.click(toggle)
  1420. expect(contextRow?.getAttribute('hidden')).toBeNull()
  1421. })
  1422. it('keeps ordinary spacing when steering separates the process control from its answer', () => {
  1423. const h = makeHarness({
  1424. nodes: [
  1425. user(1, 'question'),
  1426. reasoningAssistant(2, 'inspect', 1, 1),
  1427. steering(3, 'also mention safety', 1),
  1428. assistant(4, 'final answer', 1, 2),
  1429. ],
  1430. turnEnds: new Map([[1, 5]]),
  1431. })
  1432. const view = render(<h.ChatView {...h.props} />)
  1433. const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
  1434. expect(view.getByText('also mention safety')).toBeTruthy()
  1435. expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
  1436. })
  1437. it('keeps ordinary spacing when steering precedes the first process evidence', () => {
  1438. const h = makeHarness({
  1439. nodes: [
  1440. steering(1, 'question', 1),
  1441. steering(2, 'also mention safety', 1),
  1442. reasoningAssistant(3, 'inspect', 1, 1),
  1443. assistant(4, 'final answer', 1, 2),
  1444. ],
  1445. turnEnds: new Map([[1, 5]]),
  1446. })
  1447. const view = render(<h.ChatView {...h.props} />)
  1448. const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
  1449. expect(view.getByText('also mention safety')).toBeTruthy()
  1450. expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
  1451. })
  1452. it('keeps a live Turn expanded and folds it once at turn/end', () => {
  1453. const process = assistant(2, 'inspect', 1, 1)
  1454. const h = makeHarness({
  1455. nodes: [user(1, 'question'), process],
  1456. partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] },
  1457. running: true,
  1458. })
  1459. const view = render(<h.ChatView {...h.props} />)
  1460. expect(turnProcessControl(view.container)).toBeNull()
  1461. const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
  1462. act(() => {
  1463. h.set({
  1464. nodes: [user(1, 'question'), process, assistant(4, 'settled answer', 1, 2)],
  1465. partial: null,
  1466. running: false,
  1467. turnEnds: new Map([[1, 5]]),
  1468. })
  1469. })
  1470. const toggle = turnProcessControl(view.container)!
  1471. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1472. expect(processRow.getAttribute('hidden')).toBe('until-found')
  1473. })
  1474. it('switches completed Turns between the persisted Normal and Compact modes', () => {
  1475. const process = assistant(2, 'inspect', 1, 1)
  1476. const h = makeHarness({
  1477. nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)],
  1478. turnEnds: new Map([[1, 5]]),
  1479. })
  1480. const view = render(<h.ChatView {...h.props} />)
  1481. const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
  1482. expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
  1483. expect(processRow.getAttribute('hidden')).toBe('until-found')
  1484. act(() => { h.setTranscriptView('normal') })
  1485. expect(turnProcessControl(view.container)).toBeNull()
  1486. expect(processRow.getAttribute('hidden')).toBeNull()
  1487. act(() => { h.setTranscriptView('compact') })
  1488. expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
  1489. expect(processRow.getAttribute('hidden')).toBe('until-found')
  1490. })
  1491. it('folds final-step reasoning under the fallback title when every summary count is zero', () => {
  1492. const final = {
  1493. ...assistant(3, 'final answer', 1, 1),
  1494. blocks: [
  1495. { kind: 'reasoning' as const, text: 'private analysis' },
  1496. { kind: 'text' as const, text: 'final answer' },
  1497. ],
  1498. }
  1499. const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
  1500. const view = render(<h.ChatView {...h.props} />)
  1501. const toggle = view.getByRole('button', { name: '已思考' })
  1502. const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
  1503. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1504. expect(reasoning?.getAttribute('hidden')).toBe('until-found')
  1505. expect(view.getByText('final answer')).toBeTruthy()
  1506. fireEvent.click(toggle)
  1507. expect(view.getByText('private analysis')).toBeTruthy()
  1508. })
  1509. it('folds a completed Turn even while the reader is away from the tail', () => {
  1510. const first = assistant(2, 'first answer', 1, 1)
  1511. const h = makeHarness({ nodes: [user(1, 'question'), first], running: true })
  1512. const view = render(<h.ChatView {...h.props} />)
  1513. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  1514. Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
  1515. Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
  1516. const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
  1517. readerScroll(scroller, 100)
  1518. act(() => { h.set({
  1519. nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
  1520. turnEnds: new Map([[1, 5]]),
  1521. }) })
  1522. const toggle = turnProcessControl(view.container)!
  1523. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1524. expect(firstRow.getAttribute('hidden')).toBe('until-found')
  1525. expect(view.getByLabelText('回到底部')).toBeTruthy()
  1526. })
  1527. it('folds when the process controller first appears off-tail', () => {
  1528. const h = makeHarness({
  1529. nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
  1530. running: true,
  1531. })
  1532. const view = render(<h.ChatView {...h.props} />)
  1533. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  1534. Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
  1535. Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
  1536. const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
  1537. readerScroll(scroller, 100)
  1538. act(() => { h.set({
  1539. nodes: [
  1540. user(1, 'question'),
  1541. context(2, 'runtime policy', 1),
  1542. assistant(3, 'final answer', 1, 1),
  1543. ],
  1544. running: false,
  1545. turnEnds: new Map([[1, 4]]),
  1546. }) })
  1547. const toggle = turnProcessControl(view.container)!
  1548. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1549. expect(contextRow?.getAttribute('hidden')).toBe('until-found')
  1550. expect(view.getByLabelText('回到底部')).toBeTruthy()
  1551. })
  1552. it('keeps a focused process row visible when a live Turn completes', () => {
  1553. const h = makeHarness({
  1554. nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
  1555. running: true,
  1556. })
  1557. const view = render(<h.ChatView {...h.props} />)
  1558. const contextToggle = view.getByRole('button', { name: '上下文注入' })
  1559. const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
  1560. contextToggle.focus()
  1561. expect(document.activeElement).toBe(contextToggle)
  1562. act(() => { h.set({
  1563. nodes: [
  1564. user(1, 'question'),
  1565. context(2, 'runtime policy', 1),
  1566. assistant(3, 'final answer', 1, 1),
  1567. ],
  1568. running: false,
  1569. turnEnds: new Map([[1, 4]]),
  1570. }) })
  1571. const processToggle = turnProcessControl(view.container)!
  1572. expect(processToggle.getAttribute('aria-expanded')).toBe('true')
  1573. expect(contextRow?.getAttribute('hidden')).toBeNull()
  1574. expect(document.activeElement).toBe(contextToggle)
  1575. fireEvent.click(processToggle)
  1576. expect(document.activeElement).toBe(processToggle)
  1577. expect(processToggle.getAttribute('aria-expanded')).toBe('false')
  1578. expect(contextRow?.getAttribute('hidden')).toBe('until-found')
  1579. })
  1580. it('keeps a foldable closed Turn fully visible while history is partial', () => {
  1581. const h = makeHarness({
  1582. nodes: [
  1583. user(1, 'question'),
  1584. context(2, 'runtime policy', 1),
  1585. assistant(3, 'working', 1, 1),
  1586. assistant(4, 'final answer', 1, 2),
  1587. ],
  1588. turnEnds: new Map([[1, 5]]),
  1589. hasMore: true,
  1590. })
  1591. const view = render(<h.ChatView {...h.props} />)
  1592. const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
  1593. expect(turnProcessControl(view.container)).toBeNull()
  1594. expect(contextRow?.getAttribute('hidden')).toBeNull()
  1595. expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false)
  1596. act(() => { h.set({ hasMore: false }) })
  1597. const toggle = turnProcessControl(view.container)!
  1598. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1599. expect(contextRow?.getAttribute('hidden')).toBe('until-found')
  1600. })
  1601. it('withholds process controls for partial history and folds final-page groups', () => {
  1602. const h = makeHarness({
  1603. nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)],
  1604. hasMore: true,
  1605. })
  1606. const view = render(<h.ChatView {...h.props} />)
  1607. expect(turnProcessControl(view.container)).toBeNull()
  1608. act(() => {
  1609. h.set({
  1610. nodes: [
  1611. user(1, 'older question'),
  1612. assistant(2, 'older first answer', 1, 1),
  1613. assistant(4, 'older final answer', 1, 2),
  1614. user(9, 'visible question'),
  1615. assistant(10, 'visible answer', 2),
  1616. ],
  1617. turnEnds: new Map([[1, 5]]),
  1618. hasMore: false,
  1619. })
  1620. })
  1621. const toggle = turnProcessControl(view.container)!
  1622. const member = view.container.querySelector<HTMLElement>('[data-turn-process-member]')
  1623. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  1624. expect(member?.getAttribute('hidden')).toBe('until-found')
  1625. fireEvent.click(toggle)
  1626. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  1627. expect(member?.getAttribute('hidden')).toBeNull()
  1628. })
  1629. it('refreshes process layout without reordering when the final page only changes Turn data', () => {
  1630. const source = chatSnapshotFixture({
  1631. nodes: [
  1632. user(1, 'question'),
  1633. context(2, 'runtime policy', 1),
  1634. assistant(3, 'working', 1, 1),
  1635. assistant(4, 'final answer', 1, 2),
  1636. ],
  1637. turnEnds: new Map([[1, 5]]),
  1638. })
  1639. const process = source.nodes.values()
  1640. .find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process')
  1641. if (process === undefined
  1642. || (process.location.kind !== 'turn' && process.location.kind !== 'step')
  1643. || process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process')
  1644. const turnData = process.location.turn.data as typeof process.location.turn.data & {
  1645. set(key: 'turn-process', value: TurnProcessSpec): void
  1646. publish(): void
  1647. }
  1648. const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq }
  1649. turnData.set('turn-process', partialSpec)
  1650. const partialProcess = { ...process, data: partialSpec }
  1651. const builder = new ChatSnapshotBuilder()
  1652. const partial = builder.replace({
  1653. nodes: source.nodes.values().map(node => node.key === process.key ? partialProcess : node),
  1654. timeline: source.timeline,
  1655. })
  1656. const h = makeHarness({ chat: partial, hasMore: true })
  1657. const view = render(<h.ChatView {...h.props} />)
  1658. expect(turnProcessControl(view.container)).toBeNull()
  1659. const beforeKeys = partial.locations.getTurn(1)
  1660. const completeSpec = { ...partialSpec, processStartSeq: 2 }
  1661. turnData.set('turn-process', completeSpec)
  1662. const complete = builder.apply({
  1663. upserts: [{ ...partialProcess, data: completeSpec }],
  1664. timeline: source.timeline,
  1665. })
  1666. expect(complete.order).toBe(partial.order)
  1667. expect(complete.nodes).toBe(partial.nodes)
  1668. expect(complete.locations.getTurn(1)).not.toBe(beforeKeys)
  1669. expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([
  1670. 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
  1671. ])
  1672. act(() => {
  1673. turnData.publish()
  1674. h.set({ chat: complete, hasMore: false })
  1675. })
  1676. expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
  1677. })
  1678. it('keeps a manual expansion when the reader returns from another view', () => {
  1679. const host = document.createElement('div')
  1680. host.setAttribute('data-conversation-scroll', '')
  1681. Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
  1682. Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
  1683. Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
  1684. document.body.appendChild(host)
  1685. try {
  1686. const first = assistant(2, 'first answer', 1, 1)
  1687. const h = makeHarness({
  1688. nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
  1689. turnEnds: new Map([[1, 5]]),
  1690. })
  1691. const view = render(<h.ChatView {...h.props} />, { container: host })
  1692. fireEvent.click(turnProcessControl(view.container)!)
  1693. expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
  1694. view.rerender(<div />)
  1695. view.rerender(<h.ChatView {...h.props} />)
  1696. expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
  1697. } finally {
  1698. host.remove()
  1699. }
  1700. })
  1701. it('withholds assistant IconActions while the turn is still running', () => {
  1702. const h = makeHarness({
  1703. runningCalls: [runningCall('a')],
  1704. nodes: [
  1705. user(1, 'first'),
  1706. assistant(2, 'previous answer', 1),
  1707. user(4, 'second'),
  1708. assistant(5, 'mid-turn text', 2),
  1709. ],
  1710. // Boundary seqs follow the log: a turn/end is strictly after its own nodes.
  1711. turnEnds: new Map([[1, 3]]),
  1712. }, { running: true })
  1713. const view = render(<h.ChatView {...h.props} />)
  1714. // 2 user + the settled turn-1 tail, which keeps its seat while a later
  1715. // turn runs; turn 2's narration stays chrome-free while its tool runs, so
  1716. // the footer never appears and then moves.
  1717. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
  1718. expect(view.getByText('mid-turn text')).toBeTruthy()
  1719. // turn/end lands: the same node becomes the settled answer and takes the seat.
  1720. act(() => {
  1721. h.setSession({ running: false })
  1722. h.setChat({ runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) })
  1723. })
  1724. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
  1725. })
  1726. it('the actions-owning assistant footer shows the turn run time', () => {
  1727. const h = makeHarness({
  1728. nodes: [
  1729. user(1, 'hi'), // time 1_000
  1730. assistant(2, 'mid-turn text', 1, 1),
  1731. assistant(16, 'final answer', 1, 2),
  1732. toolResult(18, 'trailing'),
  1733. ],
  1734. turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
  1735. turnEnds: new Map([[1, 20]]),
  1736. })
  1737. const view = render(<h.ChatView {...h.props} />)
  1738. // The exact turn/end includes trailing tool activity after the final text.
  1739. expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
  1740. })
  1741. it('the actions-owning assistant footer shows an hour-scale run time', () => {
  1742. const h = makeHarness({
  1743. nodes: [
  1744. user(1, 'hi'),
  1745. assistant(2, 'mid-turn text', 1, 1),
  1746. assistant(16, 'final answer', 1, 2),
  1747. toolResult(18, 'trailing'),
  1748. ],
  1749. turnTimings: new Map([[1, { startTime: 1_000, endTime: 3_904_000 }]]),
  1750. turnEnds: new Map([[1, 20]]),
  1751. })
  1752. const view = render(<h.ChatView {...h.props} />)
  1753. expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent)
  1754. .toContain('用时 1小时05分03秒')
  1755. })
  1756. it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => {
  1757. const first: AssistantMessageNode = {
  1758. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
  1759. timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
  1760. usage: { outputTokens: 40 },
  1761. }
  1762. const second: AssistantMessageNode = {
  1763. kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
  1764. timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
  1765. usage: { outputTokens: 60 },
  1766. }
  1767. const h = makeHarness({
  1768. nodes: [user(1, 'hi'), first, second],
  1769. turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
  1770. turnEnds: new Map([[1, 20]]),
  1771. turnUsages: new Map([[1, {
  1772. uncachedInputTokens: 5_060,
  1773. cacheReadTokens: 4_940,
  1774. outputTokens: 100,
  1775. totalTokens: 10_100,
  1776. }]]),
  1777. })
  1778. const view = render(<h.ChatView {...h.props} />)
  1779. // The usage pill carries the compact total; cache hit stays dialog-only.
  1780. const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ })
  1781. expect(trigger.textContent).toBe('用量 10.1K tok')
  1782. expect(view.queryByRole('dialog')).toBeNull()
  1783. fireEvent.click(trigger)
  1784. const dialog = view.getByRole('dialog')
  1785. expect(dialog.getAttribute('aria-label')).toBe('本轮用量')
  1786. expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok')
  1787. expect(dialog.textContent).toContain('缓存命中49.4%')
  1788. expect(dialog.textContent).toContain('未缓存输入5,060 tok')
  1789. fireEvent.keyDown(document, { key: 'Escape' })
  1790. // The time pill carries the run time; first-step ttft (1.2s) and 100
  1791. // tokens over 5s of decode move into its dialog.
  1792. const timeTrigger = view.getByRole('button', { name: /用时 19秒/ })
  1793. expect(timeTrigger.textContent).toBe('用时 19秒')
  1794. expect(view.queryByText(/速度 20 tok\/s|首 token/)).toBeNull()
  1795. fireEvent.click(timeTrigger)
  1796. const timeDialog = view.getByRole('dialog')
  1797. expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度')
  1798. expect(timeDialog.textContent).toContain('本轮总用时19秒')
  1799. expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s')
  1800. expect(timeDialog.textContent).toContain('首 token 用时(TTFT)1.2秒')
  1801. })
  1802. it('withholds the usage-details trigger when turn usage is outside the window', () => {
  1803. const settled: AssistantMessageNode = {
  1804. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
  1805. timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
  1806. usage: { outputTokens: 40 },
  1807. }
  1808. const h = makeHarness({
  1809. nodes: [user(1, 'hi'), settled],
  1810. turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
  1811. turnEnds: new Map([[1, 20]]),
  1812. })
  1813. const view = render(<h.ChatView {...h.props} />)
  1814. // Timing facts keep their pill, but with no usage in the window there is
  1815. // no usage pill to click.
  1816. expect(view.getByRole('button', { name: /用时/ })).toBeTruthy()
  1817. expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
  1818. })
  1819. it('withholds ttft and throughput while the turn is still running', () => {
  1820. const settled: AssistantMessageNode = {
  1821. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
  1822. timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 },
  1823. usage: { outputTokens: 10 },
  1824. }
  1825. const h = makeHarness({
  1826. nodes: [user(1, 'hi'), settled],
  1827. turnTimings: new Map([[1, { startTime: 1_000 }]]),
  1828. turnEnds: new Map(),
  1829. }, { running: true })
  1830. const view = render(<h.ChatView {...h.props} />)
  1831. expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
  1832. })
  1833. it('keeps only the latest turn tail actions permanently visible', () => {
  1834. const h = makeHarness({
  1835. nodes: [
  1836. user(1, 'hi'),
  1837. assistant(2, 'answer'),
  1838. user(4, 'again'),
  1839. assistant(5, 'later answer', 2),
  1840. ],
  1841. turnTimings: new Map([
  1842. [1, { startTime: 1_000, endTime: 2_000 }],
  1843. [2, { startTime: 4_000, endTime: 5_000 }],
  1844. ]),
  1845. turnEnds: new Map([[1, 3], [2, 6]]),
  1846. })
  1847. const view = render(<h.ChatView {...h.props} />)
  1848. const tails = view.container.querySelectorAll('[data-turn-tail]')
  1849. expect(new Map([...tails].map(tail => [
  1850. tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'),
  1851. ]))).toEqual(new Map([['1', 'hover'], ['2', 'always']]))
  1852. expect(view.container.querySelectorAll('[data-chat-flow-kind="user"]')).toHaveLength(2)
  1853. })
  1854. it('the run-time label is withheld when the turn start is outside the window', () => {
  1855. const h = makeHarness({
  1856. nodes: [assistant(16, 'tail without trigger')],
  1857. turnEnds: new Map([[1, 16]]),
  1858. })
  1859. const view = render(<h.ChatView {...h.props} />)
  1860. expect(view.queryByText(/用时/)).toBeNull()
  1861. })
  1862. it('enables fork only on the finalized assistant at the completed transcript tail', () => {
  1863. const h = makeHarness({
  1864. nodes: [user(1, 'question'), assistant(2, 'answer')],
  1865. turnEnds: new Map([[1, 3]]),
  1866. })
  1867. const view = render(<h.ChatView {...h.props} />)
  1868. // The user bubble offers no branch; the settled answer's is live.
  1869. const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
  1870. expect(buttons).toHaveLength(1)
  1871. expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
  1872. fireEvent.click(buttons[0]!)
  1873. expect(h.forkAt.mock.calls).toEqual([[2]])
  1874. })
  1875. it('disables fork when the indexed Turn has a later steering Node', () => {
  1876. const base = chatSnapshotFixture({
  1877. nodes: [user(1, 'question'), assistant(2, 'answer')],
  1878. turnEnds: new Map([[1, 4]]),
  1879. })
  1880. const chat = {
  1881. ...base,
  1882. locations: {
  1883. getTurn: (turn: number) => turn === 1
  1884. ? [...base.locations.getTurn(turn), 'fixture:steering:later']
  1885. : base.locations.getTurn(turn),
  1886. getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
  1887. },
  1888. }
  1889. const h = makeHarness({}, {}, chat)
  1890. const view = render(<h.ChatView {...h.props} />)
  1891. const branch = view.getByRole('button', { name: '在新对话中分支' })
  1892. expect(branch.getAttribute('aria-disabled')).toBe('true')
  1893. fireEvent.click(branch)
  1894. expect(h.forkAt).not.toHaveBeenCalled()
  1895. })
  1896. it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
  1897. const interruptedThink: AssistantMessageNode = {
  1898. kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
  1899. blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
  1900. }
  1901. const h = makeHarness({
  1902. nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
  1903. turnEnds: new Map([[1, 5]]),
  1904. })
  1905. const view = render(<h.ChatView {...h.props} />)
  1906. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
  1907. const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
  1908. expect(buttons).toHaveLength(1)
  1909. expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
  1910. fireEvent.click(buttons[0]!)
  1911. expect(h.forkAt).not.toHaveBeenCalled()
  1912. })
  1913. it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
  1914. const markdown = '# Rendered\n\n- **one**\n- `two`'
  1915. const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
  1916. const view = render(<h.ChatView {...h.props} />)
  1917. expect(view.container.querySelectorAll('h1')).toHaveLength(1)
  1918. const literal = view.getByText((_content, element) => (
  1919. element?.tagName === 'SPAN' && element.childElementCount === 0 && element.textContent === markdown
  1920. ))
  1921. expect(literal.querySelector('h1')).toBeNull()
  1922. act(() => {
  1923. h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
  1924. })
  1925. expect(view.container.querySelectorAll('h1')).toHaveLength(2)
  1926. expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
  1927. act(() => {
  1928. h.setChat({
  1929. nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
  1930. partial: null,
  1931. })
  1932. })
  1933. expect(view.container.querySelectorAll('h1')).toHaveLength(2)
  1934. expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
  1935. act(() => {
  1936. h.setChat({
  1937. nodes: [
  1938. user(1, markdown),
  1939. assistant(2, markdown, 1, 1),
  1940. { ...assistant(3, markdown, 1, 2), interrupted: true },
  1941. ],
  1942. })
  1943. })
  1944. expect(view.getByText('已停止')).toBeTruthy()
  1945. expect(view.container.querySelectorAll('h1')).toHaveLength(2)
  1946. })
  1947. it('streaming partial frames update the tail without replacing a sibling Tool row', () => {
  1948. const h = makeHarness({
  1949. nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
  1950. })
  1951. const view = render(<h.ChatView {...h.props} />)
  1952. const tool = view.getByTestId('tool-seat-a')
  1953. const beforeHtml = tool.innerHTML
  1954. act(() => {
  1955. h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
  1956. })
  1957. act(() => {
  1958. h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
  1959. })
  1960. expect(view.getByText('streaming… more')).toBeTruthy()
  1961. expect(view.getByTestId('tool-seat-a')).toBe(tool)
  1962. expect(tool.innerHTML).toBe(beforeHtml)
  1963. })
  1964. it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
  1965. const h = makeHarness({
  1966. nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
  1967. })
  1968. // Count renderSlot invocations: the memo boundary holds when CallRow does
  1969. // not re-render, so the row's renderSlot call count freezes during chunks.
  1970. let rowRenders = 0
  1971. h.setNodeRenderer(((key: string, owner: object) => {
  1972. if (key !== 'conversation.chat.node'
  1973. || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null
  1974. rowRenders += 1
  1975. return <div data-testid="counting-row" />
  1976. }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
  1977. const view = render(<h.ChatView {...h.props} />)
  1978. expect(view.getByTestId('counting-row')).toBeTruthy()
  1979. const afterMount = rowRenders
  1980. act(() => {
  1981. h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
  1982. })
  1983. act(() => {
  1984. h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
  1985. })
  1986. expect(rowRenders).toBe(afterMount)
  1987. })
  1988. it('hands running calls to a live Tool group', () => {
  1989. const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true })
  1990. const view = render(<h.ChatView {...h.props} />)
  1991. expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
  1992. expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
  1993. expect(view.getByRole('status').textContent).toBe('深度求索中...')
  1994. })
  1995. it('keeps the Tool renderer mounted when a running call settles into log order', () => {
  1996. const mounted = vi.fn()
  1997. const unmounted = vi.fn()
  1998. function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) {
  1999. useEffect(() => {
  2000. mounted()
  2001. return () => { unmounted() }
  2002. }, [])
  2003. const root = node.data.root
  2004. return (
  2005. <div data-testid="stateful-tool" data-state={'kind' in root ? 'settled' : 'running'}>
  2006. {root.callId}
  2007. </div>
  2008. )
  2009. }
  2010. const h = makeHarness({
  2011. nodes: [user(1, 'q'), assistant(4, 'later')],
  2012. runningCalls: [runningCall('r1')],
  2013. }, { running: true })
  2014. h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
  2015. const routed = owner as RoutedChatNodeOwner
  2016. return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
  2017. ? <StatefulToolNode node={routed.node} />
  2018. : opts?.fallback ?? null
  2019. }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
  2020. const view = render(<h.ChatView {...h.props} />)
  2021. const tool = view.getByTestId('stateful-tool')
  2022. const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')
  2023. expect(tool.dataset.state).toBe('running')
  2024. expect(mounted).toHaveBeenCalledTimes(1)
  2025. act(() => {
  2026. h.setChat({
  2027. nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
  2028. runningCalls: [],
  2029. })
  2030. h.setSession({ running: false })
  2031. })
  2032. expect(view.getByTestId('stateful-tool')).toBe(tool)
  2033. expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row)
  2034. expect(tool.dataset.state).toBe('settled')
  2035. expect(mounted).toHaveBeenCalledTimes(1)
  2036. expect(unmounted).not.toHaveBeenCalled()
  2037. })
  2038. it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
  2039. const startTime = Date.now() - 125_000
  2040. const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
  2041. const h = makeHarness(
  2042. { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
  2043. { running: true },
  2044. )
  2045. const view = render(<h.ChatView {...h.props} />)
  2046. // Freshly mounted (as after a reload) yet already past the 15s gate.
  2047. const status = view.getByRole('status')
  2048. expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
  2049. expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
  2050. act(() => {
  2051. h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
  2052. id: 'steering-occurrence' as never,
  2053. content: [{ type: 'text', text: 'also' }],
  2054. role: 'user', source: { kind: 'user' },
  2055. }] } })
  2056. })
  2057. expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
  2058. })
  2059. it('the running clock reads hours once the turn passes an hour', () => {
  2060. const startTime = Date.now() - 3_903_000
  2061. const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
  2062. const h = makeHarness(
  2063. { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
  2064. { running: true },
  2065. )
  2066. const view = render(<h.ChatView {...h.props} />)
  2067. expect(view.getByRole('status').textContent).toMatch(/^深度求索中\.\.\.1小时05分0\d秒$/)
  2068. })
  2069. it('hands each ordered root call to the keyed business-node slot', () => {
  2070. const block = toolResult(3, 'a')
  2071. const h = makeHarness({ nodes: [block] })
  2072. const calls: { key: string; owner: object; entryKey?: string }[] = []
  2073. h.setNodeRenderer(((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
  2074. calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
  2075. return opts?.fallback ?? null
  2076. }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
  2077. render(<h.ChatView {...h.props} />)
  2078. expect(calls).toHaveLength(1)
  2079. expect(calls[0]).toMatchObject({
  2080. key: 'conversation.chat.node',
  2081. owner: { node: { kind: 'tool-call' } },
  2082. entryKey: 'tool-call',
  2083. })
  2084. const owner = calls[0]?.owner as RoutedChatNodeOwner
  2085. expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block)
  2086. expect(owner.openFile).not.toBe(h.openFile)
  2087. owner.openFile('src/a.ts')
  2088. expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
  2089. owner.inspectCall('a')
  2090. expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
  2091. })
  2092. it('shows a Host open refusal with the reason and retries the same path', async () => {
  2093. const openFile = vi.fn<(path: string) => Promise<void>>()
  2094. .mockRejectedValueOnce(new Error('xdg-open is not available'))
  2095. .mockResolvedValueOnce(undefined)
  2096. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  2097. h.props.openFile = openFile
  2098. render(<h.ChatView {...h.props} />)
  2099. await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
  2100. await waitFor(() => {
  2101. expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy()
  2102. })
  2103. expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('xdg-open is not available')
  2104. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
  2105. await waitFor(() => {
  2106. expect(screen.queryByRole('dialog')).toBeNull()
  2107. })
  2108. expect(openFile).toHaveBeenCalledTimes(2)
  2109. expect(openFile).toHaveBeenNthCalledWith(1, 'src/a.ts')
  2110. expect(openFile).toHaveBeenNthCalledWith(2, 'src/a.ts')
  2111. })
  2112. it('keeps a non-Error Host refusal visible and dismisses it on cancel', async () => {
  2113. const openFile = vi.fn<(path: string) => Promise<void>>()
  2114. .mockRejectedValueOnce('permission denied')
  2115. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  2116. h.props.openFile = openFile
  2117. render(<h.ChatView {...h.props} />)
  2118. await act(async () => { h.toolOwners[0]!.openFile('notes.md') })
  2119. await waitFor(() => {
  2120. expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('permission denied')
  2121. })
  2122. fireEvent.click(screen.getByRole('button', { name: '取消' }))
  2123. expect(screen.queryByRole('dialog')).toBeNull()
  2124. expect(openFile).toHaveBeenCalledTimes(1)
  2125. })
  2126. it('substitutes the unknown-open copy when the Host refusal has no text', async () => {
  2127. const openFile = vi.fn<(path: string) => Promise<void>>()
  2128. .mockRejectedValueOnce(new Error(''))
  2129. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  2130. h.props.openFile = openFile
  2131. render(<h.ChatView {...h.props} />)
  2132. await act(async () => { h.toolOwners[0]!.openFile('empty.ts') })
  2133. await waitFor(() => {
  2134. expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('无法打开此文件')
  2135. })
  2136. })
  2137. it('ignores a Host refusal that settles after the dialog is dismissed', async () => {
  2138. let rejectRetry!: (error: unknown) => void
  2139. const openFile = vi.fn<(path: string) => Promise<void>>()
  2140. .mockRejectedValueOnce(new Error('first refusal'))
  2141. .mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
  2142. rejectRetry = reject
  2143. }))
  2144. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  2145. h.props.openFile = openFile
  2146. render(<h.ChatView {...h.props} />)
  2147. await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
  2148. await waitFor(() => {
  2149. expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal')
  2150. })
  2151. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
  2152. fireEvent.click(screen.getByRole('button', { name: '取消' }))
  2153. expect(screen.queryByRole('dialog')).toBeNull()
  2154. await act(async () => { rejectRetry(new Error('late refusal')) })
  2155. expect(screen.queryByRole('dialog')).toBeNull()
  2156. })
  2157. it('ignores a Host open that succeeds after the dialog is dismissed', async () => {
  2158. let resolveRetry!: () => void
  2159. const openFile = vi.fn<(path: string) => Promise<void>>()
  2160. .mockRejectedValueOnce(new Error('first refusal'))
  2161. .mockImplementationOnce(() => new Promise<void>((resolve) => {
  2162. resolveRetry = () => { resolve() }
  2163. }))
  2164. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  2165. h.props.openFile = openFile
  2166. render(<h.ChatView {...h.props} />)
  2167. await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
  2168. await waitFor(() => {
  2169. expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal')
  2170. })
  2171. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
  2172. fireEvent.click(screen.getByRole('button', { name: '取消' }))
  2173. await act(async () => { resolveRetry() })
  2174. expect(screen.queryByRole('dialog')).toBeNull()
  2175. })
  2176. it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
  2177. const h = makeHarness(
  2178. { nodes: [user(5, 'later'), assistant(6, 'a')] },
  2179. { hasMore: true },
  2180. )
  2181. const view = render(<h.ChatView {...h.props} />)
  2182. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2183. // jsdom has no layout: fake the metrics the anchor math reads.
  2184. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
  2185. Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
  2186. const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement
  2187. let anchoredTop = 100
  2188. vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
  2189. () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
  2190. )
  2191. readerScroll(scroller, 80)
  2192. // Arm the paging anchor, then deliver an older page (head seq decreases).
  2193. fireEvent.click(view.getByText('加载更早'))
  2194. Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
  2195. anchoredTop = 700
  2196. act(() => {
  2197. h.setChat({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] })
  2198. })
  2199. expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
  2200. // A new trailing user bubble (own words) force-scrolls to the bottom.
  2201. act(() => {
  2202. h.setChat({
  2203. nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')],
  2204. })
  2205. })
  2206. expect(scroller.scrollTop).toBe(1600)
  2207. })
  2208. it('back-to-bottom cancels an in-flight paging anchor', () => {
  2209. const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true })
  2210. const view = render(<h.ChatView {...h.props} />)
  2211. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2212. Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
  2213. Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
  2214. readerScroll(scroller, 50)
  2215. fireEvent.click(view.getByText('加载更早'))
  2216. fireEvent.click(view.getByLabelText('回到底部'))
  2217. Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
  2218. act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
  2219. expect(scroller.scrollTop).toBe(1_300)
  2220. expect(h.chatScroll.read()).toBeNull()
  2221. })
  2222. it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
  2223. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2224. const view = render(<h.ChatView {...h.props} />)
  2225. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2226. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
  2227. Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
  2228. readerScroll(scroller, 100) // far from bottom
  2229. const backButton = view.getByLabelText('回到底部')
  2230. expect(backButton).toBeTruthy()
  2231. // Streaming growth must NOT drag a scrolled-away reader down.
  2232. act(() => {
  2233. h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } })
  2234. })
  2235. expect(scroller.scrollTop).toBe(100)
  2236. fireEvent.click(backButton)
  2237. expect(scroller.scrollTop).toBe(1000)
  2238. // At the bottom again: follow re-arms and the button unmounts.
  2239. expect(view.queryByLabelText('回到底部')).toBeNull()
  2240. })
  2241. it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
  2242. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2243. const view = render(<h.ChatView {...h.props} />)
  2244. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2245. const metrics = installScrollMetrics(scroller, 1_000, 300)
  2246. scroller.scrollTop = 700
  2247. fireEvent.scroll(scroller)
  2248. // Stream finalization shrinks the column: the browser clamps the pinned
  2249. // position onto the new floor and delivers a scroll event. The clamp
  2250. // lands exactly on the ledger's floor min, so it is not reader input.
  2251. metrics.setLayout(800, 700)
  2252. fireEvent.scroll(scroller)
  2253. fireEvent(scroller, new Event('scrollend'))
  2254. expect(scroller.scrollTop).toBe(500)
  2255. expect(view.queryByLabelText('回到底部')).toBeNull()
  2256. expect(h.chatScroll.read()).toBeNull()
  2257. metrics.setHeight(1_200)
  2258. act(() => { h.setSession({ running: true }) })
  2259. expect(scroller.scrollTop).toBe(900)
  2260. })
  2261. it('keeps following when a shrink clamp regrows before scrollend', () => {
  2262. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2263. const view = render(<h.ChatView {...h.props} />)
  2264. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2265. const metrics = installScrollMetrics(scroller, 1_000, 300)
  2266. scroller.scrollTop = 700
  2267. fireEvent.scroll(scroller)
  2268. fireEvent(scroller, new Event('scrollend'))
  2269. metrics.setLayout(800, 700)
  2270. fireEvent.scroll(scroller)
  2271. metrics.setHeight(962)
  2272. act(() => { h.setSession({ running: true }) })
  2273. fireEvent(scroller, new Event('scrollend'))
  2274. expect(scroller.scrollTop).toBe(662)
  2275. expect(view.queryByLabelText('回到底部')).toBeNull()
  2276. expect(h.chatScroll.read()).toBeNull()
  2277. })
  2278. it('settles pinned deliveries before observer growth without reading row geometry', () => {
  2279. let notify: (() => void) | undefined
  2280. class ResizeObserverStub {
  2281. constructor(callback: ResizeObserverCallback) {
  2282. notify = () => { callback([], this as unknown as ResizeObserver) }
  2283. }
  2284. observe = vi.fn()
  2285. disconnect = vi.fn()
  2286. }
  2287. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  2288. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2289. const view = render(<h.ChatView {...h.props} />)
  2290. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2291. const metrics = installScrollMetrics(scroller, 9_931, 300)
  2292. expect(notify).toBeDefined()
  2293. scroller.scrollTop = 9_631
  2294. fireEvent.scroll(scroller)
  2295. fireEvent(scroller, new Event('scrollend'))
  2296. const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
  2297. rect.mockClear()
  2298. try {
  2299. metrics.setLayout(9_918, 9_631)
  2300. fireEvent.scroll(scroller)
  2301. metrics.setHeight(10_013)
  2302. act(() => { notify?.() })
  2303. expect(scroller.scrollTop).toBe(9_713)
  2304. fireEvent.scroll(scroller)
  2305. metrics.setHeight(10_093)
  2306. act(() => { notify?.() })
  2307. expect(scroller.scrollTop).toBe(9_793)
  2308. expect(rect).not.toHaveBeenCalled()
  2309. expect(h.chatScroll.read()).toBeNull()
  2310. } finally {
  2311. rect.mockRestore()
  2312. }
  2313. })
  2314. it('lets small reader movements accumulate past the follow threshold during growth', () => {
  2315. let notify: (() => void) | undefined
  2316. class ResizeObserverStub {
  2317. constructor(callback: ResizeObserverCallback) {
  2318. notify = () => { callback([], this as unknown as ResizeObserver) }
  2319. }
  2320. observe = vi.fn()
  2321. disconnect = vi.fn()
  2322. }
  2323. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  2324. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2325. const view = render(<h.ChatView {...h.props} />)
  2326. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2327. const metrics = installScrollMetrics(scroller, 1_000, 300)
  2328. expect(notify).toBeDefined()
  2329. scroller.scrollTop = 700
  2330. fireEvent.scroll(scroller)
  2331. fireEvent(scroller, new Event('scrollend'))
  2332. scroller.scrollTop = 690
  2333. fireEvent.scroll(scroller)
  2334. metrics.setHeight(1_020)
  2335. act(() => { notify?.() })
  2336. expect(scroller.scrollTop).toBe(690)
  2337. scroller.scrollTop = 680
  2338. fireEvent.scroll(scroller)
  2339. fireEvent(scroller, new Event('scrollend'))
  2340. expect(view.getByLabelText('回到底部')).toBeTruthy()
  2341. metrics.setHeight(1_040)
  2342. act(() => { notify?.() })
  2343. expect(scroller.scrollTop).toBe(680)
  2344. })
  2345. it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
  2346. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2347. const view = render(<h.ChatView {...h.props} />)
  2348. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2349. const metrics = installScrollMetrics(scroller, 1_000, 300)
  2350. scroller.scrollTop = 700
  2351. fireEvent.scroll(scroller)
  2352. scroller.scrollTop = 500
  2353. fireEvent.scroll(scroller)
  2354. fireEvent(scroller, new Event('scrollend'))
  2355. scroller.scrollTop = 400
  2356. fireEvent.scroll(scroller)
  2357. fireEvent.click(view.getByLabelText('回到底部'))
  2358. fireEvent.scroll(scroller)
  2359. metrics.setHeight(1_200)
  2360. act(() => { h.setSession({ running: true }) })
  2361. expect(scroller.scrollTop).toBe(900)
  2362. expect(h.chatScroll.read()).toBeNull()
  2363. })
  2364. it('samples away-reader geometry on the interval or scrollend and cancels it on unmount', () => {
  2365. vi.useFakeTimers()
  2366. try {
  2367. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2368. const view = render(<h.ChatView {...h.props} />)
  2369. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2370. installScrollMetrics(scroller, 1_000, 300)
  2371. scroller.scrollTop = 700
  2372. fireEvent.scroll(scroller)
  2373. scroller.scrollTop = 500
  2374. fireEvent.scroll(scroller)
  2375. fireEvent(scroller, new Event('scrollend'))
  2376. expect(view.getByLabelText('回到底部')).toBeTruthy()
  2377. const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
  2378. try {
  2379. act(() => { vi.advanceTimersByTime(500) })
  2380. rect.mockClear()
  2381. scroller.scrollTop = 400
  2382. fireEvent.scroll(scroller)
  2383. scroller.scrollTop = 300
  2384. fireEvent.scroll(scroller)
  2385. act(() => { vi.advanceTimersByTime(499) })
  2386. expect(rect).not.toHaveBeenCalled()
  2387. act(() => { vi.advanceTimersByTime(1) })
  2388. expect(rect).toHaveBeenCalled()
  2389. rect.mockClear()
  2390. scroller.scrollTop = 200
  2391. fireEvent.scroll(scroller)
  2392. expect(rect).not.toHaveBeenCalled()
  2393. fireEvent(scroller, new Event('scrollend'))
  2394. expect(rect).toHaveBeenCalled()
  2395. scroller.scrollTop = 100
  2396. fireEvent.scroll(scroller)
  2397. view.unmount()
  2398. rect.mockClear()
  2399. act(() => { vi.advanceTimersByTime(500) })
  2400. expect(rect).not.toHaveBeenCalled()
  2401. } finally {
  2402. rect.mockRestore()
  2403. }
  2404. } finally {
  2405. vi.useRealTimers()
  2406. }
  2407. })
  2408. it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
  2409. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2410. const view = render(<h.ChatView {...h.props} />)
  2411. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2412. installScrollMetrics(scroller, 1_000, 300)
  2413. scroller.scrollTop = 700
  2414. fireEvent.scroll(scroller)
  2415. // Chromium advances compositor geometry before delivering the event:
  2416. // attribution must compare against the observed-top ledger, never a
  2417. // baseline sampled from already-moved raw geometry.
  2418. scroller.scrollTop = 500
  2419. fireEvent.scroll(scroller)
  2420. fireEvent(scroller, new Event('scrollend'))
  2421. expect(view.getByLabelText('回到底部')).toBeTruthy()
  2422. })
  2423. it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
  2424. let notify: (() => void) | undefined
  2425. const observe = vi.fn()
  2426. class ResizeObserverStub {
  2427. constructor(callback: ResizeObserverCallback) {
  2428. notify = () => { callback([], this as unknown as ResizeObserver) }
  2429. }
  2430. observe = observe
  2431. disconnect = vi.fn()
  2432. }
  2433. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  2434. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2435. const view = render(<h.ChatView {...h.props} />)
  2436. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2437. Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
  2438. Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
  2439. scroller.scrollTop = 700
  2440. fireEvent.scroll(scroller)
  2441. fireEvent(scroller, new Event('scrollend'))
  2442. Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
  2443. act(() => { notify?.() })
  2444. expect(scroller.scrollTop).toBe(1_200)
  2445. readerScroll(scroller, 200)
  2446. Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
  2447. act(() => { notify?.() })
  2448. expect(scroller.scrollTop).toBe(200)
  2449. expect(observe).toHaveBeenCalledTimes(1)
  2450. })
  2451. it('pinned dynamic-height updates select the latest Turn without reading row geometry', () => {
  2452. let notify: (() => void) | undefined
  2453. let nextFrame = 0
  2454. const frames = new Map<number, FrameRequestCallback>()
  2455. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
  2456. nextFrame += 1
  2457. frames.set(nextFrame, callback)
  2458. return nextFrame
  2459. })
  2460. vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) })
  2461. class ResizeObserverStub {
  2462. constructor(callback: ResizeObserverCallback) {
  2463. notify = () => { callback([], this as unknown as ResizeObserver) }
  2464. }
  2465. observe = vi.fn()
  2466. disconnect = vi.fn()
  2467. }
  2468. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  2469. const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
  2470. .mockReturnValue({ top: 0, bottom: 40 } as DOMRect)
  2471. const h = makeHarness({
  2472. nodes: [
  2473. userInTurn(1, 'first', 1), assistant(2, 'first answer', 1),
  2474. userInTurn(4, 'second', 2), assistant(5, 'second answer', 2),
  2475. ],
  2476. turnEnds: new Map([[1, 3], [2, 6]]),
  2477. })
  2478. const view = render(<h.ChatView {...h.props} />)
  2479. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2480. const metrics = installScrollMetrics(scroller, 1_000, 300)
  2481. scroller.scrollTop = 700
  2482. act(() => {
  2483. const pending = [...frames.values()]
  2484. frames.clear()
  2485. for (const callback of pending) callback(0)
  2486. })
  2487. rect.mockClear()
  2488. metrics.setHeight(1_200)
  2489. act(() => { notify?.() })
  2490. act(() => {
  2491. const pending = [...frames.values()]
  2492. frames.clear()
  2493. for (const callback of pending) callback(0)
  2494. })
  2495. expect(scroller.scrollTop).toBe(900)
  2496. expect(view.getByRole('button', { name: '跳转到第 2 轮' }).getAttribute('aria-current')).toBe('true')
  2497. expect(rect).not.toHaveBeenCalled()
  2498. })
  2499. it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
  2500. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2501. const view = render(<h.ChatView {...h.props} />)
  2502. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  2503. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
  2504. Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
  2505. // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
  2506. // re-render from setAtBottom must not force scrollTop to scrollHeight.
  2507. readerScroll(scroller, 690) // distance-to-bottom = 10
  2508. expect(view.queryByLabelText('回到底部')).toBeNull()
  2509. expect(scroller.scrollTop).toBe(690)
  2510. })
  2511. it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
  2512. const host = document.createElement('div')
  2513. host.setAttribute('data-conversation-scroll', '')
  2514. Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
  2515. Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
  2516. Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
  2517. document.body.appendChild(host)
  2518. try {
  2519. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2520. const view = render(<h.ChatView {...h.props} />, { container: host })
  2521. // Open jump uses the host, not the local .scroll node.
  2522. expect(host.scrollTop).toBe(2000)
  2523. readerScroll(host, 100)
  2524. expect(view.getByLabelText('回到底部')).toBeTruthy()
  2525. fireEvent.click(view.getByLabelText('回到底部'))
  2526. expect(host.scrollTop).toBe(2000)
  2527. } finally {
  2528. host.remove()
  2529. }
  2530. })
  2531. it('a remount restores the saved semantic row after width reflow', () => {
  2532. const host = document.createElement('div')
  2533. host.setAttribute('data-conversation-scroll', '')
  2534. Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
  2535. Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
  2536. Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
  2537. document.body.appendChild(host)
  2538. let anchorTop = 80
  2539. vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
  2540. () => ({ top: 0, bottom: 500 } as DOMRect),
  2541. )
  2542. const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  2543. if (this.dataset.chatAnchorKey === 'fixture:user:1') {
  2544. return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
  2545. }
  2546. return { top: 0, bottom: 40 } as DOMRect
  2547. })
  2548. try {
  2549. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2550. // Fresh open (nothing saved): the bottom jump stands.
  2551. const view = render(<h.ChatView {...h.props} />, { container: host })
  2552. expect(host.scrollTop).toBe(2000)
  2553. // Reader scrolls up; the position is recorded continuously.
  2554. readerScroll(host, 100)
  2555. // View-tab switch away and back: the view unmounts, then remounts.
  2556. view.rerender(<div />)
  2557. anchorTop = 560
  2558. host.scrollTop = 0
  2559. view.rerender(<h.ChatView {...h.props} />)
  2560. expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
  2561. // The restored position is above the floor: follow stays disarmed.
  2562. expect(view.getByLabelText('回到底部')).toBeTruthy()
  2563. } finally {
  2564. rect.mockRestore()
  2565. host.remove()
  2566. }
  2567. })
  2568. it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
  2569. const host = document.createElement('div')
  2570. host.setAttribute('data-conversation-scroll', '')
  2571. Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
  2572. Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
  2573. let scrollTop = 0
  2574. Object.defineProperty(host, 'scrollTop', {
  2575. configurable: true,
  2576. get: () => scrollTop,
  2577. set: (value: number) => { scrollTop = Math.min(value, 1_500) },
  2578. })
  2579. document.body.appendChild(host)
  2580. const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  2581. if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect
  2582. return { top: 0, bottom: 500 } as DOMRect
  2583. })
  2584. try {
  2585. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2586. h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 })
  2587. const view = render(<h.ChatView {...h.props} />, { container: host })
  2588. expect(host.scrollTop).toBe(1_500)
  2589. expect(h.chatScroll.read()).toBeNull()
  2590. view.rerender(<div />)
  2591. host.scrollTop = 0
  2592. view.rerender(<h.ChatView {...h.props} />)
  2593. expect(host.scrollTop).toBe(1_500)
  2594. } finally {
  2595. rect.mockRestore()
  2596. host.remove()
  2597. }
  2598. })
  2599. it('a remount while pinned to the bottom keeps the bottom jump', () => {
  2600. const host = document.createElement('div')
  2601. host.setAttribute('data-conversation-scroll', '')
  2602. Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
  2603. Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
  2604. Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
  2605. document.body.appendChild(host)
  2606. try {
  2607. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  2608. const view = render(<h.ChatView {...h.props} />, { container: host })
  2609. // At the bottom: the scroll event records the pinned state (null).
  2610. fireEvent.scroll(host)
  2611. expect(h.chatScroll.read()).toBeNull()
  2612. view.rerender(<div />)
  2613. host.scrollTop = 0
  2614. view.rerender(<h.ChatView {...h.props} />)
  2615. expect(host.scrollTop).toBe(2000)
  2616. } finally {
  2617. host.remove()
  2618. }
  2619. })
  2620. it('paging button loads older and shows its busy label', () => {
  2621. const h = makeHarness({ nodes: [user(5, 'later')] }, { hasMore: true })
  2622. const view = render(<h.ChatView {...h.props} />)
  2623. fireEvent.click(view.getByText('加载更早'))
  2624. expect(h.loadOlder).toHaveBeenCalledTimes(1)
  2625. act(() => { h.setSession({ loadingOlder: true }) })
  2626. expect(view.getByText('加载中…')).toBeTruthy()
  2627. })
  2628. it('shows open error and loading states', () => {
  2629. const h = makeHarness({}, {
  2630. openState: 'error',
  2631. openError: { code: 'gateway/internal', message: 'boom' } as never,
  2632. })
  2633. const view = render(<h.ChatView {...h.props} />)
  2634. expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
  2635. const loading = makeHarness({}, { openState: 'loading' })
  2636. const lv = render(<loading.ChatView {...loading.props} />)
  2637. expect(lv.getByText('载入历史…')).toBeTruthy()
  2638. })
  2639. it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
  2640. // Settled success: the bare command name is the title, the outcome text
  2641. // the summary — neither the dispatched `/` nor its arguments reach the row
  2642. // (the settlement text already says what the command did).
  2643. const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] })
  2644. const view = render(<settled.ChatView {...settled.props} />)
  2645. expect(view.getByText('plan')).toBeTruthy()
  2646. expect(view.queryByText('/plan')).toBeNull()
  2647. expect(view.queryByText('/plan now')).toBeNull()
  2648. expect(view.getByText('已进入 plan mode')).toBeTruthy()
  2649. // Error outcome flips the row state; a text-less error gets the default copy.
  2650. const failed = makeHarness({
  2651. nodes: [command({ seq: 6, commandId: 'cmd-2' as CommandNode['commandId'], outcome: { kind: 'error' } })],
  2652. })
  2653. const fv = render(<failed.ChatView {...failed.props} />)
  2654. expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
  2655. expect(fv.getByText('指令失败')).toBeTruthy()
  2656. expect(fv.getByText('失败')).toBeTruthy()
  2657. // Still executing: running state with the executing copy.
  2658. const executing = makeHarness({
  2659. nodes: [command({ seq: 7, commandId: 'cmd-3' as CommandNode['commandId'], outcome: null })],
  2660. })
  2661. const xv = render(<executing.ChatView {...executing.props} />)
  2662. expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
  2663. expect(xv.getByText('执行中…')).toBeTruthy()
  2664. expect(xv.getByText('运行中')).toBeTruthy()
  2665. // Cross-window soft-fall (run page truncated): generic title, outcome preserved.
  2666. const orphan = makeHarness({
  2667. nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })],
  2668. })
  2669. const ov = render(<orphan.ChatView {...orphan.props} />)
  2670. expect(ov.getByText('指令')).toBeTruthy()
  2671. expect(ov.getByText('已完成')).toBeTruthy()
  2672. })
  2673. it('renders /compact as one stateful disclosure from running through completion', () => {
  2674. const running = command({
  2675. commandId: 'cmd-compact' as CommandNode['commandId'],
  2676. name: 'compact',
  2677. outcome: null,
  2678. })
  2679. const h = makeHarness({ nodes: [running] })
  2680. const view = render(<h.ChatView {...h.props} />)
  2681. expect(view.getByText('正在压缩…')).toBeTruthy()
  2682. expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
  2683. act(() => {
  2684. h.setChat({
  2685. nodes: [{
  2686. ...running,
  2687. outcome: {
  2688. kind: 'success',
  2689. text: 'Compacted 16 history items (~11309 tokens).',
  2690. sourceEventSeq: 7,
  2691. },
  2692. }, compaction()],
  2693. })
  2694. })
  2695. expect(view.queryByText('正在压缩…')).toBeNull()
  2696. expect(view.queryByText('上下文已压缩')).toBeNull()
  2697. expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
  2698. const row = view.getByRole('button', { name: /compact/ })
  2699. expect(row.getAttribute('aria-expanded')).toBe('false')
  2700. expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull()
  2701. expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull()
  2702. expect(view.queryByText('保留的事实。')).toBeNull()
  2703. fireEvent.click(row)
  2704. expect(row.getAttribute('aria-expanded')).toBe('true')
  2705. expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull()
  2706. expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy()
  2707. })
  2708. it('keeps /compact no-history and error settlements on the generic command row', () => {
  2709. const noHistory = makeHarness({
  2710. nodes: [command({
  2711. name: 'compact',
  2712. outcome: { kind: 'success', text: 'No compactable history yet.' },
  2713. })],
  2714. })
  2715. const noHistoryView = render(<noHistory.ChatView {...noHistory.props} />)
  2716. expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy()
  2717. expect(noHistoryView.queryByRole('button')).toBeNull()
  2718. const failed = makeHarness({
  2719. nodes: [command({
  2720. commandId: 'cmd-compact-failed' as CommandNode['commandId'],
  2721. name: 'compact',
  2722. outcome: { kind: 'error', text: 'Compaction cancelled.' },
  2723. })],
  2724. })
  2725. const failedView = render(<failed.ChatView {...failed.props} />)
  2726. expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
  2727. expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
  2728. })
  2729. })