| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920 |
- // @vitest-environment jsdom
- import type { InboxState } from '@deepseek-ai/dsh-agent/types'
- import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
- import { useEffect } from 'react'
- import type {
- AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
- ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
- LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode,
- ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData,
- TranscriptViewMode, UserMessageNode,
- } from '@deepseek-ai/dsh-client-ui-chat/client'
- import type {
- SessionListState, SessionSnapshot,
- } from '@deepseek-ai/dsh-api-session-controller/client'
- import type {
- ConversationLocationDataStore, ConversationTurnDataMap,
- } from '@deepseek-ai/dsh-client-ui-conversation/client'
- import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
- import type { SessionId } from '@deepseek-ai/dsh-session/types'
- import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
- import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
- import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
- import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
- import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
- import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
- import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
- import { createChatStore } from '../src/client/stores.ts'
- import { ChatView } from '../src/client/chat/ChatView.tsx'
- import { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx'
- import { useTurnDataValue } from '../src/client/chat/use-turn-data.ts'
- import { en, zh } from '../src/client/locale.ts'
- import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
- import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
- import {
- CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
- TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
- } from '../src/client/chat/MessageItem.tsx'
- import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
- import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
- import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
- import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
- import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
- import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
- import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
- // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
- const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
- afterEach(() => {
- cleanup()
- vi.unstubAllGlobals()
- })
- // Keyless create() persists under the bare declared key; clear between cases
- // so one harness's selection cannot rehydrate into the next.
- beforeEach(() => {
- localStorage.clear()
- })
- const SID = 's1' as SessionId
- type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
- interface TestSessionSnapshot extends SessionSnapshot {
- readonly testInbox?: InboxState
- }
- function sessionSnapshot(overrides: Partial<TestSessionSnapshot> = {}): TestSessionSnapshot {
- return {
- sessionId: SID,
- pendingSubmissions: [],
- running: false,
- removed: false,
- openState: 'open',
- openError: null,
- hasMore: false,
- loadingOlder: false,
- promptError: null,
- blank: false,
- subagent: null,
- lastAgentError: null,
- promptAttempted: true,
- awaitingFirstTurn: false,
- ...overrides,
- }
- }
- /** Scripted Session source: set() swaps the top-level object like the real Controller binding. */
- function makeSessionSource(init: Partial<TestSessionSnapshot> = {}) {
- let snap = sessionSnapshot(init)
- const subs = new Set<() => void>()
- return {
- set: (next: Partial<TestSessionSnapshot>) => {
- snap = { ...snap, ...next }
- for (const fn of [...subs]) fn()
- },
- source: {
- getSnapshot: () => snap,
- subscribe: (fn: () => void) => {
- subs.add(fn)
- return () => subs.delete(fn)
- },
- },
- }
- }
- type ChatSlice = Partial<LegacyConversationSlice> & {
- readonly turnUsages?: NonNullable<Parameters<typeof chatSnapshotFixture>[0]>['turnUsages']
- }
- type HarnessUpdate = ChatSlice & Partial<TestSessionSnapshot> & { readonly chat?: ChatSnapshot }
- /** Scripted Chat target source, independent from Session lifecycle state. */
- function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
- let snap = snapshot ?? chatSnapshotFixture(init)
- const subs = new Set<() => void>()
- return {
- set: (next: ChatSlice) => {
- snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
- for (const fn of [...subs]) fn()
- },
- replace: (next: ChatSnapshot) => {
- snap = next
- for (const fn of [...subs]) fn()
- },
- source: {
- getSnapshot: () => snap,
- subscribe: (fn: () => void) => {
- subs.add(fn)
- return () => subs.delete(fn)
- },
- },
- }
- }
- const user = (seq: number, text: string): UserMessageNode => ({
- kind: 'user',
- seq,
- time: seq * 1000,
- content: [{ type: 'text', text }] as never,
- source: null,
- })
- const userInTurn = (seq: number, text: string, turn: number): ConversationNode => ({
- ...user(seq, text),
- // The production Location index owns this association. The legacy fixture
- // accepts the extra coordinate so component tests can build the same view.
- turn,
- } as unknown as ConversationNode)
- const assistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
- kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text }],
- })
- const reasoningAssistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
- kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'reasoning', text }],
- })
- const context = (seq: number, text: string, turn?: number): ContextMessageNode & { turn?: number } => ({
- kind: 'context', seq, time: seq * 1_000, content: [{ type: 'text', text }], source: null,
- producer: { role: 'inject', label: null }, form: null,
- ...(turn === undefined ? {} : { turn }),
- })
- const steering = (seq: number, text: string, turn: number): SteeringMessageNode & { turn: number } => ({
- kind: 'steering', messageId: `steering-${String(seq)}` as SteeringMessageNode['messageId'],
- seq, time: seq * 1_000, turn, content: [{ type: 'text', text }], source: null,
- })
- const retry = (seq: number): ModelRetryNode => ({
- kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
- seq, time: seq * 1_000, turn: 1, step: 0,
- retryState: 'scheduled',
- provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
- retry: 1, maxRetries: 2, delayMs: 450,
- failure: { code: 'TRANSPORT', message: '连接被重置' },
- })
- const turnError = (seq: number, code?: string): TurnErrorNode => ({
- kind: 'turn-error', seq, time: seq * 1_000, turn: 1, step: 0,
- message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
- ...(code === undefined ? {} : { code }),
- })
- const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({
- kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0,
- })
- const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
- kind: 'tool-result', seq, time: seq * 1_000, callId,
- call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
- callTime: seq * 1_000 - 500,
- content: [], isError: false, subCalls: [],
- })
- const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
- callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
- })
- const command = (over: Partial<CommandNode> = {}): CommandNode => ({
- kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
- name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
- ...over,
- })
- const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummaryNode => ({
- kind: 'compaction', seq: 8, time: 8_000,
- summary: '## 压缩摘要\n\n保留的事实。',
- summaryEventSeq: 7,
- shadowedItemCount: 16,
- shadowedTokenCount: 11_309,
- ...over,
- })
- /** Empty sessions-list hook for the global standard-kit seat. */
- function emptySessions() {
- const store = createSnapshotStore<SessionListState>(
- { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
- return bindSnapshotSelector(store)
- }
- function emptyWorkspaces() {
- const store = createSnapshotStore<WorkspaceSnapshot>({
- items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
- })
- return bindSnapshotSelector(store)
- }
- function bindKeyedSnapshotSelector<Value>(
- resolve: (key: string) => ObservableSnapshot<Value>,
- ): KeyedSnapshotSelectorHook<Value> {
- const hooks = new WeakMap<object, SnapshotSelectorHook<Value>>()
- return ((key: string, selector?: (value: Value) => unknown, equal?: (left: unknown, right: unknown) => boolean) => {
- const source = resolve(key)
- let useValue = hooks.get(source)
- if (useValue === undefined) {
- useValue = bindSnapshotSelector(source)
- hooks.set(source, useValue)
- }
- return useValue(selector ?? ((value: Value) => value), equal)
- }) as KeyedSnapshotSelectorHook<Value>
- }
- function makeHarness(
- init: HarnessUpdate = {},
- sessionOverrides: Partial<TestSessionSnapshot> = {},
- chatSnapshot?: ChatSnapshot,
- ) {
- const {
- chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, turnUsages,
- ...sessionInit
- } = init
- const chatSlice: ChatSlice = {
- ...(nodes === undefined ? {} : { nodes }),
- ...(partial === undefined ? {} : { partial }),
- ...(runningCalls === undefined ? {} : { runningCalls }),
- ...(turnTimings === undefined ? {} : { turnTimings }),
- ...(turnEnds === undefined ? {} : { turnEnds }),
- ...(turnUsages === undefined ? {} : { turnUsages }),
- }
- const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
- const useTestSession = bindSnapshotSelector(session.source)
- const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
- const useChatNode = bindKeyedSnapshotSelector(
- key => chatSource.source.getSnapshot().nodes.source(key),
- )
- const useChatNodeProcess = bindKeyedSnapshotSelector(
- key => chatSource.source.getSnapshot().nodes.processSource(key),
- )
- const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
- const openSkill = vi.fn<(name: string) => void>()
- const loadOlder = vi.fn()
- const loadThrough = vi.fn<(seq: number) => Promise<void>>().mockResolvedValue(undefined)
- // Mutable outline holder: tests swap the value and drive a re-render via set().
- let outlineValue: unknown
- const openView = vi.fn<(view: string, focus: string) => void>()
- // In-memory scroll memory matching the apply.ts per-session map contract.
- let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
- const chatScroll: ChatViewSlotProps['chatScroll'] = {
- save: (position) => { savedScroll = position },
- read: () => savedScroll,
- }
- const forkAt = vi.fn()
- // Rows and the harness must observe the same chat-store instance.
- const chat = createChatStore().create()
- const transcriptView = createSnapshotStore<TranscriptViewMode>('compact')
- const t = makeTranslate(zh, commonZh)
- const toolOwners: Array<{
- callId: string
- toolName: string
- block: ToolCallBlock
- openFile: ChatNodeOwnerProps['openFile']
- inspectCall: ChatNodeOwnerProps['inspectCall']
- }> = []
- const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
- opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
- const renderTurnTailSlot = (() => null) as unknown as
- React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
- let nodeSlotOverride: React.ComponentProps<typeof ChatNodeSeat>['renderSlot'] | undefined
- const renderNodeSlot = ((key: string, owner: object, opts?: {
- fallback?: React.ReactNode
- hookContext?: unknown
- }) => {
- if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never)
- if (key !== 'conversation.chat.node') return opts?.fallback ?? null
- const nodeOwner = owner as RoutedChatNodeOwner
- const turnData = opts?.hookContext as
- ConversationLocationDataStore<ConversationTurnDataMap> | undefined
- const useTurnData: UseChatNodeTurnData = dataKey => useTurnDataValue(turnData, dataKey)
- const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
- { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
- )
- switch (nodeOwner.node.kind) {
- case 'user':
- case 'steering':
- return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
- case 'context':
- return <ContextMessageNodeView {...nodeProps<'context'>()} />
- case 'assistant-step':
- return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
- case 'command':
- return (
- <CommandNodeView
- {...nodeProps<'command'>()}
- renderSlot={renderCommandSlot}
- SessionProvider={props.SessionProvider}
- />
- )
- case 'manual-compaction':
- return <ManualCompactionNodeView {...nodeProps<'manual-compaction'>()} />
- case 'compaction':
- return <CompactionNodeView {...nodeProps<'compaction'>()} />
- case 'model-retry':
- return <RetryNodeView {...nodeProps<'model-retry'>()} />
- case 'turn-error':
- return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
- case 'turn-max-tokens':
- return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
- case 'turn-process':
- return <TurnProcessNodeView {...nodeProps<'turn-process'>()} />
- case 'system-prompt':
- return <SystemPromptNodeView {...nodeProps<'system-prompt'>()} />
- case 'turn-tail':
- return (
- <TurnTailNodeView
- {...nodeProps<'turn-tail'>()}
- renderSlot={renderTurnTailSlot}
- SessionProvider={props.SessionProvider}
- />
- )
- case 'unknown':
- return <UnknownNodeView {...nodeProps<'unknown'>()} />
- case 'tool-call': {
- const block = nodeOwner.node.data.root
- const toolName = 'kind' in block ? block.call?.name ?? '' : block.name
- const tool = {
- callId: block.callId,
- toolName,
- block,
- openFile: nodeOwner.openFile,
- inspectCall: nodeOwner.inspectCall,
- }
- toolOwners.push(tool)
- return (
- <div
- data-testid={`tool-seat-${tool.callId}`}
- data-chat-anchor-key={`call:${tool.callId}`}
- data-chat-call-id={tool.callId}
- >
- {tool.toolName || '(unnamed)'}:{tool.callId}
- </div>
- )
- }
- default:
- return opts?.fallback ?? null
- }
- }) as unknown as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
- const renderSlot = renderNodeSlot
- // SessionProvider seat arrives with the session-scope child declaration;
- // ChatView never invokes it (pass-through stub).
- const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
- const props: ChatViewSlotProps = {
- usePanelInfo: selector => selector({ activePanelId: null }),
- sessionId: SID,
- useSession: bindSnapshotSelector(session.source),
- useChat: bindSnapshotSelector(chatSource.source),
- useChatNode,
- useChatNodeProcess,
- useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
- useTrajectory: (() => { throw new Error('unused') }),
- useSessions: emptySessions(),
- useSessionRetainInfo: () => undefined,
- useResource,
- useSessionStatus: bindSnapshotSelector(
- createSnapshotStore<SessionStatusSnapshot>(new Map()),
- ),
- useWorkspaces: emptyWorkspaces(),
- useProjection: (key: string) => {
- const inbox = useTestSession(snapshot => snapshot.testInbox)
- return key === 'inbox' ? inbox : outlineValue
- },
- useInput: (() => { throw new Error('unused') }),
- inputActions: {
- setDraft: () => {},
- addAttachments: () => true,
- removeAttachment: () => {},
- pruneAttachments: () => {},
- submit: () => {},
- },
- useStore: bindSnapshotSelector(chat),
- actions: chat.actions,
- useTranscriptView: bindSnapshotSelector(transcriptView),
- renderSlot,
- SessionProvider: SessionProviderStub,
- viewRequest: null,
- openView,
- completeViewRequest: () => {},
- openFile,
- openSkill,
- openExternalLink: vi.fn(),
- loadOlder,
- loadThrough,
- loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
- chatScroll,
- forkAt,
- // Absent-service default; mention tests override with a real resolver.
- fileMentions: () => undefined,
- t,
- }
- const set = (next: HarnessUpdate): void => {
- const {
- chat: explicitChat, nodes, partial, runningCalls, turnTimings, turnEnds,
- ...sessionUpdate
- } = next
- if (explicitChat !== undefined) chatSource.replace(explicitChat)
- else if (nodes !== undefined || partial !== undefined || runningCalls !== undefined
- || turnTimings !== undefined || turnEnds !== undefined) {
- chatSource.set({
- ...(nodes === undefined ? {} : { nodes }),
- ...(partial === undefined ? {} : { partial }),
- ...(runningCalls === undefined ? {} : { runningCalls }),
- ...(turnTimings === undefined ? {} : { turnTimings }),
- ...(turnEnds === undefined ? {} : { turnEnds }),
- })
- }
- session.set(sessionUpdate)
- }
- return {
- set, setSession: session.set, setChat: chatSource.set, ChatView, props,
- openFile, openSkill, loadOlder, loadThrough, openView,
- setOutline: (value: unknown) => { outlineValue = value },
- chatScroll, forkAt, toolOwners,
- setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
- setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
- nodeSlotOverride = renderer
- },
- }
- }
- /** Simulate reader input (any device): a delivered position that deviates
- * from the observed-top ledger of programmatic writes. */
- function readerScroll(element: HTMLElement, top: number): void {
- element.scrollTop = top
- fireEvent.scroll(element)
- fireEvent(element, new Event('scrollend'))
- }
- function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
- return container.querySelector<HTMLButtonElement>('[data-turn-process]')
- }
- function withSystemPrompt(
- snapshot: ChatSnapshot,
- builder = new ChatSnapshotBuilder(),
- text = '# System',
- ): ChatSnapshot {
- const turn = snapshot.timeline.turns.get(1)
- if (turn === undefined) throw new Error('fixture lacks Turn 1')
- const prompt: ChatNode<'system-prompt'> = {
- key: 'fixture:system-prompt:1',
- id: '1',
- target: 'chat',
- kind: 'system-prompt',
- anchorSeq: 1,
- location: { kind: 'turn', turn },
- visibility: 'visible',
- data: { text },
- }
- return builder.replace({
- nodes: [prompt, ...snapshot.nodes.values()],
- timeline: snapshot.timeline,
- })
- }
- function renderedFlowKinds(container: HTMLElement): Array<string | undefined> {
- return [...container.querySelectorAll<HTMLElement>('[data-chat-flow-kind]')]
- .map(row => row.dataset.chatFlowKind)
- }
- function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
- let scrollHeight = initialHeight
- let scrollTop = 0
- Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
- Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
- Object.defineProperty(element, 'scrollTop', {
- configurable: true,
- get: () => scrollTop,
- set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
- })
- return {
- setHeight: (value: number) => { scrollHeight = value },
- setLayout: (height: number, top: number) => {
- scrollHeight = height
- scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
- },
- }
- }
- describe('Chat node rendering', () => {
- it('opens Markdown references to unmodified files with line navigation', () => {
- const h = makeHarness({
- nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)],
- turnEnds: new Map([[1, 2]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- fireEvent.click(view.getByRole('button', { name: 'source' }))
- expect(h.openFile).toHaveBeenCalledWith('src/index.ts', { line: 24 })
- })
- it('threads the injected file-mention vocabulary into the closing prose only', () => {
- const wrote = (seq: number, callId: string): ToolResultNode => ({
- ...toolResult(seq, callId, 'write'),
- })
- const h = makeHarness({
- nodes: [
- user(1, 'build it'),
- assistant(2, 'writing `report.html` now', 1),
- wrote(3, 'w'),
- assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
- ],
- turnEnds: new Map([[1, 4]]),
- })
- h.props.fileMentions = owner => ({
- resolve: (value) => {
- if (value !== 'report.html') return undefined
- return {
- open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
- label: '打开 site/report.html',
- title: 'site/report.html',
- }
- },
- })
- const view = render(<h.ChatView {...h.props} />)
- // Exactly one live mention: the closing message links, the mid-turn
- // narration stays inert code, and the unknown file resolves to nothing.
- const mentions = view.container.querySelectorAll('code button')
- expect(mentions).toHaveLength(1)
- const mention = view.getByRole('button', { name: '打开 site/report.html' })
- expect(mention.getAttribute('title')).toBe('site/report.html')
- fireEvent.click(mention)
- // The vocabulary was built from the closing message's own owner currency.
- expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
- })
- it('formatRunDuration localizes units and floors partial seconds', () => {
- const t = makeTranslate(zh, commonZh)
- expect(formatRunDuration(0, t)).toBe('0秒')
- expect(formatRunDuration(-500, t)).toBe('0秒')
- expect(formatRunDuration(15_999, t)).toBe('15秒')
- expect(formatRunDuration(125_000, t)).toBe('2分05秒')
- // The hour rolls at exactly 3600s, never at 60 displayed minutes.
- expect(formatRunDuration(3_599_999, t)).toBe('59分59秒')
- expect(formatRunDuration(3_600_000, t)).toBe('1小时00分00秒')
- expect(formatRunDuration(3_903_000, t)).toBe('1小时05分03秒')
- expect(formatRunDuration(7_261_000, t)).toBe('2小时01分01秒')
- })
- it('formatRunDuration uses the English hour template', () => {
- const t = makeTranslate(en, commonEn)
- expect(formatRunDuration(3_903_000, t)).toBe('1h 05m 03s')
- })
- })
- describe('ChatView', () => {
- it('leaves the turn rail unrendered when an unrelated Chat update commits', () => {
- const snapshot = chatSnapshotFixture({
- nodes: [
- userInTurn(1, 'first prompt', 1),
- assistant(2, 'first response', 1),
- userInTurn(4, 'second prompt', 2),
- assistant(5, 'second response', 2),
- ],
- turnEnds: new Map([[1, 3], [2, 6]]),
- })
- const h = makeHarness({}, {}, snapshot)
- // The rail asks for its own accessible name once per render, so counting
- // that key counts renders without reaching into the component.
- let railRenders = 0
- const translate = h.props.t
- const counting = ((key: string, vars?: Record<string, unknown>) => {
- if (key === 'chat.turnNavigation.label') railRenders += 1
- return (translate as (k: string, v?: Record<string, unknown>) => string)(key, vars)
- }) as ChatViewSlotProps['t']
- render(<h.ChatView {...h.props} t={counting} />)
- const afterMount = railRenders
- expect(afterMount).toBeGreaterThan(0)
- act(() => { h.setTranscriptView('compact') })
- expect(railRenders).toBe(afterMount)
- })
- it('projects loaded turns into prompt and response navigation previews', () => {
- const snapshot = chatSnapshotFixture({
- nodes: [
- userInTurn(1, 'first prompt', 1),
- assistant(2, 'first response', 1),
- userInTurn(4, 'second prompt', 2),
- assistant(5, 'second response', 2),
- ],
- turnEnds: new Map([[1, 3], [2, 6]]),
- })
- expect(snapshot.navigation.items()).toEqual([
- { turn: 1, anchorKey: 'fixture:user:1', prompt: 'first prompt', response: 'first response' },
- { turn: 2, anchorKey: 'fixture:user:4', prompt: 'second prompt', response: 'second response' },
- ])
- const h = makeHarness({}, {}, snapshot)
- const view = render(<h.ChatView {...h.props} />)
- const navigation = view.getByRole('navigation', { name: '轮次导航' })
- expect(navigation.style.getPropertyValue('--turn-natural-height')).toBe('22px')
- const first = view.getByRole('button', { name: '跳转到第 1 轮' })
- const second = view.getByRole('button', { name: '跳转到第 2 轮' })
- expect(first.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('0px')
- expect(second.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('10px')
- expect(second.getAttribute('aria-current')).toBe('true')
- fireEvent.focus(first)
- const preview = view.getByRole('tooltip')
- expect(preview.textContent).toContain('first prompt')
- expect(preview.textContent).toContain('first response')
- })
- it('jumps to a turn anchor and reflows stable marks after an older page arrives', () => {
- const later = [
- userInTurn(4, 'second prompt', 2), assistant(5, 'second response', 2),
- userInTurn(7, 'third prompt', 3), assistant(8, 'third response', 3),
- ]
- const h = makeHarness({ nodes: later }, { hasMore: true })
- const view = render(<h.ChatView {...h.props} />)
- const second = view.getByRole('button', { name: '跳转到第 2 轮' })
- const secondPosition = second.parentElement as HTMLElement
- expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('0px')
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 1_000, 300)
- metrics.setLayout(1_000, 700)
- vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect)
- const secondRow = view.container.querySelector('[data-chat-flow-key="fixture:user:4"]') as HTMLElement
- vi.spyOn(secondRow, 'getBoundingClientRect').mockReturnValue({ top: -500, bottom: -440 } as DOMRect)
- fireEvent.click(second)
- expect(scroller.scrollTop).toBe(176)
- expect(second.getAttribute('aria-current')).toBe('true')
- act(() => {
- h.setChat({
- nodes: [userInTurn(1, 'first prompt', 1), assistant(2, 'first response', 1), ...later],
- turnTimings: new Map([[1, { startTime: 1_000 }], [2, { startTime: 4_000 }], [3, { startTime: 7_000 }]]),
- })
- })
- const movedSecond = view.getByRole('button', { name: '跳转到第 2 轮' })
- expect(movedSecond.parentElement).toBe(secondPosition)
- // Fixed pitch: the mark moves one slot down and never compresses.
- expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('10px')
- })
- it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
- const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
- const h = makeHarness({ nodes: later }, { hasMore: true })
- h.setOutline([
- { turn: 1, seq: 0, prompt: 'first prompt from outline', response: 'first answer from outline' },
- { turn: 2, seq: 4, prompt: 'second prompt from outline', response: '' },
- { turn: 3, seq: 8, prompt: 'third prompt', response: 'third response' },
- ])
- const view = render(<h.ChatView {...h.props} />)
- const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
- view.getByRole('button', { name: '加载并跳转到第 2 轮' })
- const third = view.getByRole('button', { name: '跳转到第 3 轮' })
- expect(third.getAttribute('aria-current')).toBe('true')
- fireEvent.focus(first)
- // An unloaded turn previews both sides from the outline.
- expect(view.getByRole('tooltip').textContent).toContain('first prompt from outline')
- expect(view.getByRole('tooltip').textContent).toContain('first answer from outline')
- fireEvent.click(first)
- expect(h.loadThrough).toHaveBeenCalledWith(0)
- expect(first.getAttribute('aria-busy')).toBe('true')
- // The fake loader never delivers rows: settlement repages once for the
- // unmoved head, then lands on the nearest rendered turn and un-busies.
- await act(async () => {})
- expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
- expect(first.getAttribute('aria-busy')).toBeNull()
- expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
- })
- it('a jump from the pinned tail releases bottom ownership so the follow snap cannot cancel it', async () => {
- const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
- const h = makeHarness({ nodes: later }, { hasMore: true })
- h.setOutline([
- { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
- { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
- ])
- let releaseJump: (() => void) | undefined
- h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
- const view = render(<h.ChatView {...h.props} />)
- // Pinned to the tail on open: the back-to-bottom control is absent.
- expect(view.queryByRole('button', { name: '回到底部' })).toBeNull()
- const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
- fireEvent.click(first)
- // The click itself leaves the tail...
- expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
- // ...so a non-reader scroll delivery at the floor (the first prepend's
- // compensation fires one) no longer snaps to the tail and cancel the jump.
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement
- fireEvent.scroll(scroller)
- expect(first.getAttribute('aria-busy')).toBe('true')
- await act(async () => { releaseJump?.() })
- })
- it('holds a jump issued while a plain pull owns the pager and resumes it when the pull settles', async () => {
- const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
- const h = makeHarness({ nodes: later }, { hasMore: true, loadingOlder: true })
- h.setOutline([
- { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
- { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
- ])
- const view = render(<h.ChatView {...h.props} />)
- const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
- fireEvent.click(first)
- // The session-side guard refuses the busy-pager jump instantly, yet the
- // mark stays busy instead of degrading to the nearest loaded turn.
- await act(async () => {})
- expect(h.loadThrough.mock.calls).toEqual([[0]])
- expect(first.getAttribute('aria-busy')).toBe('true')
- // The plain pull settles: the flip re-settles the jump, which repages.
- act(() => { h.setSession({ loadingOlder: false }) })
- await act(async () => {})
- expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
- expect(first.getAttribute('aria-busy')).toBeNull()
- })
- it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
- const h = makeHarness(
- { nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
- { hasMore: true },
- )
- h.setOutline(Array.from({ length: 60 }, (_, index) => ({
- turn: index + 1,
- seq: index * 4,
- prompt: `p${String(index + 1)}`,
- response: '',
- })))
- const view = render(<h.ChatView {...h.props} />)
- const nav = view.getByRole('navigation', { name: '轮次导航' })
- // 60 marks at the fixed 10px pitch: the ladder keeps its natural height.
- expect(nav.style.getPropertyValue('--turn-natural-height')).toBe('602px')
- const scroller = nav.querySelector('[class*="scroller"]') as HTMLElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 602, configurable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 300, configurable: true })
- scroller.scrollTop = 0
- fireEvent.scroll(scroller)
- expect(scroller.className).toContain('fadeBottom')
- expect(scroller.className).not.toContain('fadeTop')
- scroller.scrollTop = 150
- fireEvent.scroll(scroller)
- expect(scroller.className).toContain('fadeTop')
- expect(scroller.className).toContain('fadeBottom')
- expect(nav.style.getPropertyValue('--turn-scroll-top')).toBe('150px')
- // Pointer mapping subtracts the rail scroll: y=94 with scrollTop 150 is
- // natural offset 238px → the 25th mark.
- vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue({ top: 0 } as DOMRect)
- fireEvent.pointerMove(nav, { clientY: 94 })
- expect(view.getByRole('tooltip').textContent).toContain('p25')
- })
- it('lands a jump on its turn once the paged rows commit', async () => {
- const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
- const h = makeHarness({ nodes: later }, { hasMore: true })
- h.setOutline([
- { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
- { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
- ])
- let releaseJump: (() => void) | undefined
- h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
- const view = render(<h.ChatView {...h.props} />)
- fireEvent.click(view.getByRole('button', { name: '加载并跳转到第 1 轮' }))
- expect(h.loadThrough).toHaveBeenCalledWith(0)
- // The paged window commits: turn 1's rows and rail item enter the snapshot.
- act(() => {
- h.setChat({
- nodes: [userInTurn(0, 'first prompt', 1), assistant(1, 'first response', 1), ...later],
- turnTimings: new Map([[1, { startTime: 1_000 }], [3, { startTime: 8_000 }]]),
- })
- })
- const first = view.getByRole('button', { name: '跳转到第 1 轮' })
- expect(first.getAttribute('aria-current')).toBe('true')
- // The mark stays busy until the jump settles: the loader's completion
- // runs the final landing correction after the load-earlier button leaves.
- expect(first.getAttribute('aria-busy')).toBe('true')
- await act(async () => { releaseJump?.() })
- // Only the busy lifecycle is asserted after settlement: jsdom's zero
- // geometry makes the rAF active-turn resync read "at bottom" and hand the
- // mark to the last turn, so aria-current here is timing-dependent under
- // instrumentation; the landing position contract lives in the browser e2e.
- expect(first.getAttribute('aria-busy')).toBeNull()
- })
- it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
- const h = makeHarness({
- nodes: [{ ...toolResult(3, 'w1'), call: null }],
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByTestId('tool-seat-w1')).toBeTruthy()
- expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
- })
- it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
- const h = makeHarness(
- { nodes: [user(9, 'first visible'), user(10, 'next visible')] },
- { hasMore: true },
- )
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
- const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
- let firstTop = 100
- let nextTop = 300
- vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
- () => ({ top: 0, bottom: 200 } as DOMRect),
- )
- vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
- () => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
- )
- vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
- () => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
- )
- Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
- readerScroll(scroller, 50)
- fireEvent.click(view.getByText('加载更早'))
- // The reader moves after the request starts; this, not the click-time
- // row, is the intent the arriving page must preserve.
- firstTop = -200
- nextTop = 60
- readerScroll(scroller, 90)
- Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
- nextTop = 560
- act(() => {
- h.setChat({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] })
- })
- expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
- })
- it('bounds no-anchor hit testing before using the mounted-row fallback', () => {
- const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
- const hitTest = vi.fn((): Element[] => [])
- Object.defineProperty(document, 'elementsFromPoint', {
- configurable: true,
- value: hitTest,
- })
- try {
- const h = makeHarness({ nodes: [user(1, 'visible row')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
- installScrollMetrics(scroller, 4_000, 2_000)
- vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({
- top: 0, bottom: 2_000, left: 0, right: 1_000,
- } as DOMRect)
- vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({
- top: 100, bottom: 140, left: 0, right: 1_000,
- } as DOMRect)
- readerScroll(scroller, 100)
- expect(hitTest).toHaveBeenCalledTimes(1)
- expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:1')
- } finally {
- if (originalHitTest !== undefined) {
- Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
- } else {
- Reflect.deleteProperty(document, 'elementsFromPoint')
- }
- }
- })
- it('falls back to the first visible row when the viewport top hit-test misses', () => {
- const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
- const nodes = Array.from({ length: 16 }, (_, index) => user(20 + index, `row ${String(index)}`))
- const h = makeHarness(
- { nodes },
- { hasMore: true },
- )
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const rows = [...view.container.querySelectorAll<HTMLElement>('[data-chat-flow-key]')]
- let prepended = false
- let rowRectCalls = 0
- vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
- () => ({ top: 0, bottom: 200 } as DOMRect),
- )
- rows.forEach((row, index) => {
- vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => {
- rowRectCalls += 1
- const shift = prepended ? (index === 8 ? 400 : 500) : 0
- const top = 20 + (index - 8) * 60 + shift
- return { top, bottom: top + 40 } as DOMRect
- })
- })
- Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
- readerScroll(scroller, 50)
- const hitTest = vi.fn((_x: number, _y: number): Element[] => [])
- Object.defineProperty(document, 'elementsFromPoint', {
- configurable: true,
- value: hitTest,
- })
- try {
- rowRectCalls = 0
- fireEvent.click(view.getByText('加载更早'))
- expect(hitTest).toHaveBeenCalledTimes(1)
- expect(hitTest.mock.calls[0]?.[1]).toBe(1)
- expect(rowRectCalls).toBeLessThanOrEqual(6)
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
- prepended = true
- act(() => {
- h.setChat({ nodes: [assistant(2, 'older'), ...nodes] })
- })
- expect(scroller.scrollTop).toBe(450) // reader offset 50 + first visible row's 400px shift
- } finally {
- if (originalHitTest !== undefined) {
- Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
- } else {
- Reflect.deleteProperty(document, 'elementsFromPoint')
- }
- }
- })
- it('renders the fixture main line as independently keyed business nodes', () => {
- const h = makeHarness({
- nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByText('do the thing')).toBeTruthy()
- expect(view.getByText('running tools')).toBeTruthy()
- expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a')
- expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b')
- expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
- key: row.getAttribute('data-chat-flow-key'),
- kind: row.getAttribute('data-chat-flow-kind'),
- }))).toEqual([
- { key: 'fixture:user:1', kind: 'user' },
- { key: 'fixture:turn-process:1', kind: 'turn-process' },
- { key: 'fixture:assistant:2', kind: 'assistant-step' },
- { key: 'fixture:tool:a', kind: 'tool-call' },
- { key: 'fixture:tool:b', kind: 'tool-call' },
- ])
- expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
- .toEqual(['a', 'b'])
- expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
- .toEqual([
- 'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2',
- 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
- ])
- })
- it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
- const writeText = vi.fn().mockResolvedValue(undefined)
- Object.defineProperty(navigator, 'clipboard', {
- configurable: true,
- value: { writeText },
- })
- const pending = {
- id: 'steer-occurrence' as never,
- role: 'user' as const,
- source: { kind: 'user' as const },
- content: [{ type: 'text' as const, text: 'interrupt now' }],
- preview: 'interrupt now',
- text: 'interrupt now',
- }
- const queued = {
- id: 'queued-occurrence' as never,
- role: 'user' as const,
- source: { kind: 'user' as const },
- content: [{ type: 'text' as const, text: 'later' }],
- preview: 'later',
- text: 'later',
- }
- const h = makeHarness(
- { nodes: [assistant(1, 'working')] },
- { testInbox: { 'next-turn': [queued], 'next-step': [pending] }, running: true },
- )
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull()
- expect(view.queryByText('later')).toBeNull()
- const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
- expect(pendingBubble).not.toBeNull()
- fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
- expect(writeText).toHaveBeenCalledWith('interrupt now')
- expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
- expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
- & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
- act(() => {
- h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
- h.setChat({
- nodes: [
- assistant(1, 'working'),
- {
- kind: 'steering', messageId: pending.id,
- seq: 2, time: 2_000,
- content: [{ type: 'text', text: 'interrupt now' }], source: null,
- },
- ],
- })
- })
- expect(view.getAllByText('interrupt now')).toHaveLength(1)
- expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
- // Only the durable steering bubble: the turn is still running, so its
- // assistant narration owns no footer yet, and a steering bubble never
- // carries a branch action.
- expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
- const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
- expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
- act(() => {
- h.setSession({ running: false })
- h.setChat({ turnEnds: new Map([[1, 3]]) })
- })
- // The Turn Tail belongs to the closed Turn, independently of a later
- // steering bubble's placement in the Chat list.
- const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
- expect(branchButtons).toHaveLength(1)
- expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
- fireEvent.click(branchButtons[0]!)
- expect(h.forkAt).toHaveBeenCalledWith(1)
- })
- it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
- const pending = {
- id: 'steer-occurrence-later' as never,
- role: 'user' as const,
- source: { kind: 'user' as const },
- content: [{ type: 'text' as const, text: 'same steering' }],
- preview: 'same steering',
- text: 'same steering',
- }
- const h = makeHarness({
- nodes: [{
- kind: 'user', seq: 2, time: 2_000,
- content: pending.content, source: null,
- }],
- }, { testInbox: { 'next-turn': [], 'next-step': [pending] }, running: true })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getAllByText('same steering')).toHaveLength(2)
- expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
- })
- it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => {
- const h = makeHarness(
- { nodes: [assistant(1, 'working')] },
- {
- pendingSubmissions: [
- {
- requestId: 'req-1' as never, placement: 'transcript',
- time: 5_000, text: '即发即显', attachments: [],
- },
- ],
- },
- )
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull()
- // The durable node arrives while the echo is STILL in the session
- // snapshot: the render-time rpcId dedupe keeps exactly one bubble.
- act(() => {
- h.setChat({
- nodes: [
- assistant(1, 'working'),
- {
- kind: 'user', seq: 2, time: 2_000,
- content: [{ type: 'text', text: '即发即显' }] as never,
- source: { kind: 'user', rpcId: 'req-1' },
- },
- ],
- })
- })
- expect(view.getAllByText('即发即显')).toHaveLength(1)
- expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
- // The delayed snapshot retirement changes nothing visible.
- act(() => { h.setSession({ pendingSubmissions: [] }) })
- expect(view.getAllByText('即发即显')).toHaveLength(1)
- })
- it('renders a local steer echo as pending steering before Host image admission completes', () => {
- const h = makeHarness(
- { nodes: [assistant(1, 'working')] },
- {
- running: true,
- pendingSubmissions: [{
- requestId: 'req-steer' as never,
- placement: 'steering',
- time: 5_500,
- text: '带图纠偏',
- attachments: [{
- type: 'image', value: { previewUrl: 'blob:steer-preview', name: 'steer.png' },
- }],
- }],
- },
- )
- const view = render(<h.ChatView {...h.props} />)
- const local = view.getByText('带图纠偏').closest('[data-submission-echo]')
- expect(local?.hasAttribute('data-pending-steering')).toBe(true)
- act(() => {
- h.setSession({
- testInbox: { 'next-turn': [], 'next-step': [{
- id: 'steer-occurrence' as never,
- content: [{ type: 'text', text: '带图纠偏' }],
- role: 'user', source: { kind: 'user', rpcId: 'req-steer' as never },
- }] },
- })
- })
- expect(view.getAllByText('带图纠偏')).toHaveLength(1)
- expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
- expect(view.container.querySelector('[data-pending-steering]')).not.toBeNull()
- })
- it('keeps a queued echo out of the Chat flow before and after Host admission', () => {
- const h = makeHarness(
- { nodes: [assistant(1, 'working')] },
- {
- running: true,
- pendingSubmissions: [
- {
- requestId: 'req-q' as never, placement: 'queued',
- time: 6_000, text: '排队中', attachments: [],
- },
- ],
- },
- )
- const view = render(<h.ChatView {...h.props} />)
- expect(view.queryByText('排队中')).toBeNull()
- act(() => {
- h.setSession({
- testInbox: { 'next-step': [], 'next-turn': [{
- id: 'q-occurrence' as never,
- content: [{ type: 'text' as const, text: '排队中' }],
- role: 'user', source: { kind: 'user', rpcId: 'req-q' as never },
- }] },
- })
- })
- // The queued occurrence and its local predecessor both belong to the
- // queue dock, never the Chat flow.
- expect(view.queryByText('排队中')).toBeNull()
- })
- it('an image echo renders its previews through the message-image slot', () => {
- const h = makeHarness(
- { nodes: [] },
- {
- pendingSubmissions: [{
- requestId: 'req-img' as never,
- placement: 'transcript',
- time: 7_000,
- text: '',
- attachments: [
- {
- type: 'image', value: { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
- },
- { type: 'image', value: { previewUrl: 'blob:echo-b' } },
- ],
- }],
- },
- )
- const baseRenderSlot = h.props.renderSlot
- const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
- if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
- const { images, compact } = owner as { images: readonly unknown[]; compact?: boolean }
- return (
- <div
- data-testid="echo-image"
- data-count={images.length}
- data-compact={String(compact)}
- data-first={JSON.stringify(images[0])}
- />
- )
- }) as unknown as ChatViewSlotProps['renderSlot']
- const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
- const images = view.getAllByTestId('echo-image')
- expect(images).toHaveLength(2)
- expect(images.every(image => image.getAttribute('data-count') === '1')).toBe(true)
- expect(images.every(image => image.getAttribute('data-compact') === 'true')).toBe(true)
- expect(images[0]?.parentElement).toBe(images[1]?.parentElement)
- expect(JSON.parse(images[0]?.getAttribute('data-first') ?? '{}')).toEqual({
- preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
- })
- })
- it('a mixed echo renders the Web file card between its selected images', () => {
- const h = makeHarness(
- { nodes: [] },
- {
- pendingSubmissions: [{
- requestId: 'req-mixed' as never,
- placement: 'transcript',
- time: 7_500,
- text: '',
- attachments: [
- { type: 'image', value: { previewUrl: 'blob:first', name: 'first.png' } },
- {
- type: 'file',
- value: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 },
- },
- { type: 'image', value: { previewUrl: 'blob:last', name: 'last.png' } },
- ],
- }],
- },
- )
- const baseRenderSlot = h.props.renderSlot
- const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
- if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
- const { images, compact } = owner as {
- images: ReadonlyArray<{ preview?: { name?: string } }>
- compact?: boolean
- }
- return (
- <div
- data-testid={`images-${images[0]?.preview?.name ?? 'unknown'}`}
- data-compact={String(compact)}
- />
- )
- }) as unknown as ChatViewSlotProps['renderSlot']
- const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
- const first = view.getByTestId('images-first.png')
- const file = view.getByTitle('notes.txt')
- const last = view.getByTestId('images-last.png')
- expect(first.getAttribute('data-compact')).toBe('true')
- expect(last.getAttribute('data-compact')).toBe('true')
- expect(first.parentElement).toBe(file.parentElement)
- expect(file.parentElement).toBe(last.parentElement)
- expect(first.compareDocumentPosition(file) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
- expect(file.compareDocumentPosition(last) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
- expect(view.getByText('TXT 23B')).toBeTruthy()
- })
- it('animates only the latest unresolved model retry', () => {
- const retryNode = retry(2)
- const nextRetry = { ...retry(3), turn: 2, retry: 2 }
- const context = {
- kind: 'context', seq: 4, time: 4_000, content: [], source: null,
- producer: { role: 'inject', label: null },
- form: null,
- } as const satisfies ConversationNode
- const h = makeHarness({ nodes: [user(1, 'try'), retryNode] }, { running: true })
- const view = render(<h.ChatView {...h.props} />)
- const disclosure = view.container.querySelector('details') as HTMLDetailsElement
- expect(disclosure.dataset.active).toBe('true')
- expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
- act(() => {
- h.setChat({ nodes: [user(1, 'try'), nextRetry] })
- })
- expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
- expect(view.container.querySelector('details')).toBe(disclosure)
- expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
- act(() => {
- h.setChat({
- nodes: [
- user(1, 'try'),
- { ...nextRetry, retryState: 'started' },
- context,
- assistant(5, 'done'),
- ],
- })
- h.setSession({ running: false })
- })
- expect(disclosure.dataset.active).toBeUndefined()
- expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
- act(() => {
- h.setChat({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }] })
- h.setSession({ running: true })
- })
- const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
- expect(cancelledDisclosure.dataset.active).toBeUndefined()
- expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
- })
- it('renders terminal turn failures inline with their durable message and optional code', () => {
- const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] })
- const view = render(<h.ChatView {...h.props} />)
- const statuses = view.getAllByRole('status')
- expect(statuses.map(status => status.textContent)).toEqual([
- '本轮运行失败API 密钥无效AUTH',
- '本轮运行失败plugin exploded',
- ])
- })
- it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => {
- const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] })
- const view = render(<h.ChatView {...h.props} />)
- const statuses = view.getAllByRole('status')
- expect(statuses.map(status => status.textContent)).toEqual([
- '已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
- ])
- expect(view.queryByText('本轮运行失败')).toBeNull()
- })
- it('hands the trajectory callback to the Tool seat', () => {
- const h = makeHarness({
- nodes: [toolResult(3, 'a')],
- })
- render(<h.ChatView {...h.props} />)
- h.toolOwners[0]?.inspectCall('a')
- expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
- })
- it('shows assistant IconActions only on the last content message of each turn', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'hi'),
- assistant(2, 'mid-turn text', 1, 1),
- toolResult(3, 'a'),
- assistant(4, 'final answer', 1, 2),
- user(5, 'next'),
- assistant(6, 'second turn', 2),
- ],
- turnEnds: new Map([[1, 4], [2, 6]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- // Branch renders only under assistant answers; user bubbles keep copy alone.
- expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
- const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
- expect(branchButtons).toHaveLength(2)
- expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
- })
- it('folds Think and Tool rows before the final answer without unmounting them', () => {
- const first = {
- ...assistant(2, 'earlier reply', 1, 1),
- blocks: [
- { kind: 'reasoning' as const, text: 'inspect the repository' },
- { kind: 'text' as const, text: 'earlier reply' },
- ],
- }
- const second = assistant(5, 'final answer', 1, 2)
- const h = makeHarness({
- nodes: [
- user(1, 'question'),
- first,
- toolResult(3, 'a'),
- toolResult(4, 'b', 'subagent'),
- second,
- ],
- turnTimings: new Map([[1, { startTime: 1_000, endTime: 5_000 }]]),
- turnEnds: new Map([[1, 6]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1')
- expect(toggle.getAttribute('data-turn-process-messages')).toBe('1')
- expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1')
- const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
- expect(members).toHaveLength(3)
- expect(members.map(member => member.getAttribute('hidden')))
- .toEqual(['until-found', 'until-found', 'until-found'])
- expect(members[0]?.textContent).toContain('inspect the repository')
- expect(members[1]?.textContent).toContain('bash:a')
- expect(members[2]?.textContent).toContain('subagent:b')
- expect(view.getByText('final answer')).toBeTruthy()
- fireEvent.click(toggle)
- expect(toggle.getAttribute('aria-expanded')).toBe('true')
- expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
- fireEvent.click(toggle)
- expect(members.map(member => member.getAttribute('hidden')))
- .toEqual(['until-found', 'until-found', 'until-found'])
- fireEvent(members[1]!, new Event('beforematch'))
- expect(toggle.getAttribute('aria-expanded')).toBe('true')
- expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
- act(() => { h.set({ nodes: [user(1, 'question'), first] }) })
- expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false')
- expect(members[0]?.getAttribute('hidden')).toBeNull()
- act(() => { h.set({
- nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second],
- }) })
- const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
- expect(renewedToggle.getAttribute('aria-expanded')).toBe('true')
- expect(members[0]?.getAttribute('hidden')).toBeNull()
- })
- it('folds injected Context in place with the rest of the Turn process', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'question'),
- context(2, 'runtime policy changed', 1),
- reasoningAssistant(3, 'inspect the repository', 1, 1),
- toolResult(4, 'a'),
- assistant(5, 'final answer', 1, 2),
- ],
- turnEnds: new Map([[1, 6]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
- const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
- expect(members).toHaveLength(3)
- expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call'])
- expect(contextRow).not.toBeNull()
- expect(contextRow?.getAttribute('hidden')).toBe('until-found')
- fireEvent(contextRow!, new Event('beforematch'))
- expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
- })
- it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
- const builder = new ChatSnapshotBuilder()
- const initial = withSystemPrompt(chatSnapshotFixture({
- nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
- }), builder)
- const h = makeHarness({ chat: initial }, { running: true })
- const view = render(<h.ChatView {...h.props} />)
- const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
- expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context'])
- expect(promptRow.getAttribute('hidden')).toBeNull()
- expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
- act(() => {
- h.set({
- chat: withSystemPrompt(chatSnapshotFixture({
- nodes: [
- userInTurn(2, 'question', 1),
- context(3, 'runtime policy', 1),
- reasoningAssistant(4, 'inspect', 1, 1),
- ],
- }), builder),
- running: true,
- })
- })
- expect(renderedFlowKinds(view.container)).toEqual([
- 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
- ])
- expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
- expect(promptRow.getAttribute('hidden')).toBeNull()
- act(() => {
- h.set({
- chat: withSystemPrompt(chatSnapshotFixture({
- nodes: [
- userInTurn(2, 'question', 1),
- context(3, 'runtime policy', 1),
- reasoningAssistant(4, 'inspect', 1, 1),
- assistant(6, 'final answer', 1, 2),
- ],
- turnEnds: new Map([[1, 7]]),
- }), builder),
- running: false,
- })
- })
- const toggle = turnProcessControl(view.container)!
- const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
- expect(renderedFlowKinds(view.container)).toEqual([
- 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
- ])
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(promptRow.getAttribute('hidden')).toBeNull()
- expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
- expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
- expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
- fireEvent.click(toggle)
- expect(renderedFlowKinds(view.container)).toEqual([
- 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
- ])
- expect(promptRow.getAttribute('hidden')).toBeNull()
- expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
- })
- it('folds Context under the fallback title when every summary count is zero', () => {
- const h = makeHarness({
- nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
- turnEnds: new Map([[1, 4]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const toggle = view.getByRole('button', { name: '已思考' })
- const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0')
- expect(toggle.getAttribute('data-turn-process-messages')).toBe('0')
- expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0')
- expect(contextRow?.getAttribute('hidden')).toBe('until-found')
- fireEvent.click(toggle)
- expect(contextRow?.getAttribute('hidden')).toBeNull()
- })
- it('keeps ordinary spacing when steering separates the process control from its answer', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'question'),
- reasoningAssistant(2, 'inspect', 1, 1),
- steering(3, 'also mention safety', 1),
- assistant(4, 'final answer', 1, 2),
- ],
- turnEnds: new Map([[1, 5]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
- expect(view.getByText('also mention safety')).toBeTruthy()
- expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
- })
- it('keeps ordinary spacing when steering precedes the first process evidence', () => {
- const h = makeHarness({
- nodes: [
- steering(1, 'question', 1),
- steering(2, 'also mention safety', 1),
- reasoningAssistant(3, 'inspect', 1, 1),
- assistant(4, 'final answer', 1, 2),
- ],
- turnEnds: new Map([[1, 5]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
- expect(view.getByText('also mention safety')).toBeTruthy()
- expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
- })
- it('keeps a live Turn expanded and folds it once at turn/end', () => {
- const process = assistant(2, 'inspect', 1, 1)
- const h = makeHarness({
- nodes: [user(1, 'question'), process],
- partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] },
- running: true,
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(turnProcessControl(view.container)).toBeNull()
- const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
- act(() => {
- h.set({
- nodes: [user(1, 'question'), process, assistant(4, 'settled answer', 1, 2)],
- partial: null,
- running: false,
- turnEnds: new Map([[1, 5]]),
- })
- })
- const toggle = turnProcessControl(view.container)!
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(processRow.getAttribute('hidden')).toBe('until-found')
- })
- it('switches completed Turns between the persisted Normal and Compact modes', () => {
- const process = assistant(2, 'inspect', 1, 1)
- const h = makeHarness({
- nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)],
- turnEnds: new Map([[1, 5]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
- expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
- expect(processRow.getAttribute('hidden')).toBe('until-found')
- act(() => { h.setTranscriptView('normal') })
- expect(turnProcessControl(view.container)).toBeNull()
- expect(processRow.getAttribute('hidden')).toBeNull()
- act(() => { h.setTranscriptView('compact') })
- expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
- expect(processRow.getAttribute('hidden')).toBe('until-found')
- })
- it('folds final-step reasoning under the fallback title when every summary count is zero', () => {
- const final = {
- ...assistant(3, 'final answer', 1, 1),
- blocks: [
- { kind: 'reasoning' as const, text: 'private analysis' },
- { kind: 'text' as const, text: 'final answer' },
- ],
- }
- const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
- const view = render(<h.ChatView {...h.props} />)
- const toggle = view.getByRole('button', { name: '已思考' })
- const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(reasoning?.getAttribute('hidden')).toBe('until-found')
- expect(view.getByText('final answer')).toBeTruthy()
- fireEvent.click(toggle)
- expect(view.getByText('private analysis')).toBeTruthy()
- })
- it('folds a completed Turn even while the reader is away from the tail', () => {
- const first = assistant(2, 'first answer', 1, 1)
- const h = makeHarness({ nodes: [user(1, 'question'), first], running: true })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
- const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
- readerScroll(scroller, 100)
- act(() => { h.set({
- nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
- turnEnds: new Map([[1, 5]]),
- }) })
- const toggle = turnProcessControl(view.container)!
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(firstRow.getAttribute('hidden')).toBe('until-found')
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- })
- it('folds when the process controller first appears off-tail', () => {
- const h = makeHarness({
- nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
- running: true,
- })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
- const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
- readerScroll(scroller, 100)
- act(() => { h.set({
- nodes: [
- user(1, 'question'),
- context(2, 'runtime policy', 1),
- assistant(3, 'final answer', 1, 1),
- ],
- running: false,
- turnEnds: new Map([[1, 4]]),
- }) })
- const toggle = turnProcessControl(view.container)!
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(contextRow?.getAttribute('hidden')).toBe('until-found')
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- })
- it('keeps a focused process row visible when a live Turn completes', () => {
- const h = makeHarness({
- nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
- running: true,
- })
- const view = render(<h.ChatView {...h.props} />)
- const contextToggle = view.getByRole('button', { name: '上下文注入' })
- const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
- contextToggle.focus()
- expect(document.activeElement).toBe(contextToggle)
- act(() => { h.set({
- nodes: [
- user(1, 'question'),
- context(2, 'runtime policy', 1),
- assistant(3, 'final answer', 1, 1),
- ],
- running: false,
- turnEnds: new Map([[1, 4]]),
- }) })
- const processToggle = turnProcessControl(view.container)!
- expect(processToggle.getAttribute('aria-expanded')).toBe('true')
- expect(contextRow?.getAttribute('hidden')).toBeNull()
- expect(document.activeElement).toBe(contextToggle)
- fireEvent.click(processToggle)
- expect(document.activeElement).toBe(processToggle)
- expect(processToggle.getAttribute('aria-expanded')).toBe('false')
- expect(contextRow?.getAttribute('hidden')).toBe('until-found')
- })
- it('keeps a foldable closed Turn fully visible while history is partial', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'question'),
- context(2, 'runtime policy', 1),
- assistant(3, 'working', 1, 1),
- assistant(4, 'final answer', 1, 2),
- ],
- turnEnds: new Map([[1, 5]]),
- hasMore: true,
- })
- const view = render(<h.ChatView {...h.props} />)
- const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
- expect(turnProcessControl(view.container)).toBeNull()
- expect(contextRow?.getAttribute('hidden')).toBeNull()
- expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false)
- act(() => { h.set({ hasMore: false }) })
- const toggle = turnProcessControl(view.container)!
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(contextRow?.getAttribute('hidden')).toBe('until-found')
- })
- it('withholds process controls for partial history and folds final-page groups', () => {
- const h = makeHarness({
- nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)],
- hasMore: true,
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(turnProcessControl(view.container)).toBeNull()
- act(() => {
- h.set({
- nodes: [
- user(1, 'older question'),
- assistant(2, 'older first answer', 1, 1),
- assistant(4, 'older final answer', 1, 2),
- user(9, 'visible question'),
- assistant(10, 'visible answer', 2),
- ],
- turnEnds: new Map([[1, 5]]),
- hasMore: false,
- })
- })
- const toggle = turnProcessControl(view.container)!
- const member = view.container.querySelector<HTMLElement>('[data-turn-process-member]')
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(member?.getAttribute('hidden')).toBe('until-found')
- fireEvent.click(toggle)
- expect(toggle.getAttribute('aria-expanded')).toBe('true')
- expect(member?.getAttribute('hidden')).toBeNull()
- })
- it('refreshes process layout without reordering when the final page only changes Turn data', () => {
- const source = chatSnapshotFixture({
- nodes: [
- user(1, 'question'),
- context(2, 'runtime policy', 1),
- assistant(3, 'working', 1, 1),
- assistant(4, 'final answer', 1, 2),
- ],
- turnEnds: new Map([[1, 5]]),
- })
- const process = source.nodes.values()
- .find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process')
- if (process === undefined
- || (process.location.kind !== 'turn' && process.location.kind !== 'step')
- || process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process')
- const turnData = process.location.turn.data as typeof process.location.turn.data & {
- set(key: 'turn-process', value: TurnProcessSpec): void
- publish(): void
- }
- const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq }
- turnData.set('turn-process', partialSpec)
- const partialProcess = { ...process, data: partialSpec }
- const builder = new ChatSnapshotBuilder()
- const partial = builder.replace({
- nodes: source.nodes.values().map(node => node.key === process.key ? partialProcess : node),
- timeline: source.timeline,
- })
- const h = makeHarness({ chat: partial, hasMore: true })
- const view = render(<h.ChatView {...h.props} />)
- expect(turnProcessControl(view.container)).toBeNull()
- const beforeKeys = partial.locations.getTurn(1)
- const completeSpec = { ...partialSpec, processStartSeq: 2 }
- turnData.set('turn-process', completeSpec)
- const complete = builder.apply({
- upserts: [{ ...partialProcess, data: completeSpec }],
- timeline: source.timeline,
- })
- expect(complete.order).toBe(partial.order)
- expect(complete.nodes).toBe(partial.nodes)
- expect(complete.locations.getTurn(1)).not.toBe(beforeKeys)
- expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([
- 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
- ])
- act(() => {
- turnData.publish()
- h.set({ chat: complete, hasMore: false })
- })
- expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
- })
- it('keeps a manual expansion when the reader returns from another view', () => {
- const host = document.createElement('div')
- host.setAttribute('data-conversation-scroll', '')
- Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
- Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
- Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
- document.body.appendChild(host)
- try {
- const first = assistant(2, 'first answer', 1, 1)
- const h = makeHarness({
- nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
- turnEnds: new Map([[1, 5]]),
- })
- const view = render(<h.ChatView {...h.props} />, { container: host })
- fireEvent.click(turnProcessControl(view.container)!)
- expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
- view.rerender(<div />)
- view.rerender(<h.ChatView {...h.props} />)
- expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
- } finally {
- host.remove()
- }
- })
- it('withholds assistant IconActions while the turn is still running', () => {
- const h = makeHarness({
- runningCalls: [runningCall('a')],
- nodes: [
- user(1, 'first'),
- assistant(2, 'previous answer', 1),
- user(4, 'second'),
- assistant(5, 'mid-turn text', 2),
- ],
- // Boundary seqs follow the log: a turn/end is strictly after its own nodes.
- turnEnds: new Map([[1, 3]]),
- }, { running: true })
- const view = render(<h.ChatView {...h.props} />)
- // 2 user + the settled turn-1 tail, which keeps its seat while a later
- // turn runs; turn 2's narration stays chrome-free while its tool runs, so
- // the footer never appears and then moves.
- expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
- expect(view.getByText('mid-turn text')).toBeTruthy()
- // turn/end lands: the same node becomes the settled answer and takes the seat.
- act(() => {
- h.setSession({ running: false })
- h.setChat({ runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) })
- })
- expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
- })
- it('the actions-owning assistant footer shows the turn run time', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'hi'), // time 1_000
- assistant(2, 'mid-turn text', 1, 1),
- assistant(16, 'final answer', 1, 2),
- toolResult(18, 'trailing'),
- ],
- turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
- turnEnds: new Map([[1, 20]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- // The exact turn/end includes trailing tool activity after the final text.
- expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
- })
- it('the actions-owning assistant footer shows an hour-scale run time', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'hi'),
- assistant(2, 'mid-turn text', 1, 1),
- assistant(16, 'final answer', 1, 2),
- toolResult(18, 'trailing'),
- ],
- turnTimings: new Map([[1, { startTime: 1_000, endTime: 3_904_000 }]]),
- turnEnds: new Map([[1, 20]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent)
- .toContain('用时 1小时05分03秒')
- })
- it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => {
- const first: AssistantMessageNode = {
- kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
- timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
- usage: { outputTokens: 40 },
- }
- const second: AssistantMessageNode = {
- kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
- timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
- usage: { outputTokens: 60 },
- }
- const h = makeHarness({
- nodes: [user(1, 'hi'), first, second],
- turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
- turnEnds: new Map([[1, 20]]),
- turnUsages: new Map([[1, {
- uncachedInputTokens: 5_060,
- cacheReadTokens: 4_940,
- outputTokens: 100,
- totalTokens: 10_100,
- }]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- // The usage pill carries the compact total; cache hit stays dialog-only.
- const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ })
- expect(trigger.textContent).toBe('用量 10.1K tok')
- expect(view.queryByRole('dialog')).toBeNull()
- fireEvent.click(trigger)
- const dialog = view.getByRole('dialog')
- expect(dialog.getAttribute('aria-label')).toBe('本轮用量')
- expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok')
- expect(dialog.textContent).toContain('缓存命中49.4%')
- expect(dialog.textContent).toContain('未缓存输入5,060 tok')
- fireEvent.keyDown(document, { key: 'Escape' })
- // The time pill carries the run time; first-step ttft (1.2s) and 100
- // tokens over 5s of decode move into its dialog.
- const timeTrigger = view.getByRole('button', { name: /用时 19秒/ })
- expect(timeTrigger.textContent).toBe('用时 19秒')
- expect(view.queryByText(/速度 20 tok\/s|首 token/)).toBeNull()
- fireEvent.click(timeTrigger)
- const timeDialog = view.getByRole('dialog')
- expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度')
- expect(timeDialog.textContent).toContain('本轮总用时19秒')
- expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s')
- expect(timeDialog.textContent).toContain('首 token 用时(TTFT)1.2秒')
- })
- it('withholds the usage-details trigger when turn usage is outside the window', () => {
- const settled: AssistantMessageNode = {
- kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
- timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
- usage: { outputTokens: 40 },
- }
- const h = makeHarness({
- nodes: [user(1, 'hi'), settled],
- turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
- turnEnds: new Map([[1, 20]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- // Timing facts keep their pill, but with no usage in the window there is
- // no usage pill to click.
- expect(view.getByRole('button', { name: /用时/ })).toBeTruthy()
- expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
- })
- it('withholds ttft and throughput while the turn is still running', () => {
- const settled: AssistantMessageNode = {
- kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
- timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 },
- usage: { outputTokens: 10 },
- }
- const h = makeHarness({
- nodes: [user(1, 'hi'), settled],
- turnTimings: new Map([[1, { startTime: 1_000 }]]),
- turnEnds: new Map(),
- }, { running: true })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
- })
- it('keeps only the latest turn tail actions permanently visible', () => {
- const h = makeHarness({
- nodes: [
- user(1, 'hi'),
- assistant(2, 'answer'),
- user(4, 'again'),
- assistant(5, 'later answer', 2),
- ],
- turnTimings: new Map([
- [1, { startTime: 1_000, endTime: 2_000 }],
- [2, { startTime: 4_000, endTime: 5_000 }],
- ]),
- turnEnds: new Map([[1, 3], [2, 6]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const tails = view.container.querySelectorAll('[data-turn-tail]')
- expect(new Map([...tails].map(tail => [
- tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'),
- ]))).toEqual(new Map([['1', 'hover'], ['2', 'always']]))
- expect(view.container.querySelectorAll('[data-chat-flow-kind="user"]')).toHaveLength(2)
- })
- it('the run-time label is withheld when the turn start is outside the window', () => {
- const h = makeHarness({
- nodes: [assistant(16, 'tail without trigger')],
- turnEnds: new Map([[1, 16]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.queryByText(/用时/)).toBeNull()
- })
- it('enables fork only on the finalized assistant at the completed transcript tail', () => {
- const h = makeHarness({
- nodes: [user(1, 'question'), assistant(2, 'answer')],
- turnEnds: new Map([[1, 3]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- // The user bubble offers no branch; the settled answer's is live.
- const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
- expect(buttons).toHaveLength(1)
- expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
- fireEvent.click(buttons[0]!)
- expect(h.forkAt.mock.calls).toEqual([[2]])
- })
- it('disables fork when the indexed Turn has a later steering Node', () => {
- const base = chatSnapshotFixture({
- nodes: [user(1, 'question'), assistant(2, 'answer')],
- turnEnds: new Map([[1, 4]]),
- })
- const chat = {
- ...base,
- locations: {
- getTurn: (turn: number) => turn === 1
- ? [...base.locations.getTurn(turn), 'fixture:steering:later']
- : base.locations.getTurn(turn),
- getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
- },
- }
- const h = makeHarness({}, {}, chat)
- const view = render(<h.ChatView {...h.props} />)
- const branch = view.getByRole('button', { name: '在新对话中分支' })
- expect(branch.getAttribute('aria-disabled')).toBe('true')
- fireEvent.click(branch)
- expect(h.forkAt).not.toHaveBeenCalled()
- })
- it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
- const interruptedThink: AssistantMessageNode = {
- kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
- blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
- }
- const h = makeHarness({
- nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
- turnEnds: new Map([[1, 5]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
- const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
- expect(buttons).toHaveLength(1)
- expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
- fireEvent.click(buttons[0]!)
- expect(h.forkAt).not.toHaveBeenCalled()
- })
- it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
- const markdown = '# Rendered\n\n- **one**\n- `two`'
- const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.container.querySelectorAll('h1')).toHaveLength(1)
- const literal = view.getByText((_content, element) => (
- element?.tagName === 'SPAN' && element.childElementCount === 0 && element.textContent === markdown
- ))
- expect(literal.querySelector('h1')).toBeNull()
- act(() => {
- h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
- })
- expect(view.container.querySelectorAll('h1')).toHaveLength(2)
- expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
- act(() => {
- h.setChat({
- nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
- partial: null,
- })
- })
- expect(view.container.querySelectorAll('h1')).toHaveLength(2)
- expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
- act(() => {
- h.setChat({
- nodes: [
- user(1, markdown),
- assistant(2, markdown, 1, 1),
- { ...assistant(3, markdown, 1, 2), interrupted: true },
- ],
- })
- })
- expect(view.getByText('已停止')).toBeTruthy()
- expect(view.container.querySelectorAll('h1')).toHaveLength(2)
- })
- it('streaming partial frames update the tail without replacing a sibling Tool row', () => {
- const h = makeHarness({
- nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
- })
- const view = render(<h.ChatView {...h.props} />)
- const tool = view.getByTestId('tool-seat-a')
- const beforeHtml = tool.innerHTML
- act(() => {
- h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
- })
- act(() => {
- h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
- })
- expect(view.getByText('streaming… more')).toBeTruthy()
- expect(view.getByTestId('tool-seat-a')).toBe(tool)
- expect(tool.innerHTML).toBe(beforeHtml)
- })
- it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
- const h = makeHarness({
- nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
- })
- // Count renderSlot invocations: the memo boundary holds when CallRow does
- // not re-render, so the row's renderSlot call count freezes during chunks.
- let rowRenders = 0
- h.setNodeRenderer(((key: string, owner: object) => {
- if (key !== 'conversation.chat.node'
- || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null
- rowRenders += 1
- return <div data-testid="counting-row" />
- }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByTestId('counting-row')).toBeTruthy()
- const afterMount = rowRenders
- act(() => {
- h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
- })
- act(() => {
- h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
- })
- expect(rowRenders).toBe(afterMount)
- })
- it('hands running calls to a live Tool group', () => {
- const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
- expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
- expect(view.getByRole('status').textContent).toBe('深度求索中...')
- })
- it('keeps the Tool renderer mounted when a running call settles into log order', () => {
- const mounted = vi.fn()
- const unmounted = vi.fn()
- function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) {
- useEffect(() => {
- mounted()
- return () => { unmounted() }
- }, [])
- const root = node.data.root
- return (
- <div data-testid="stateful-tool" data-state={'kind' in root ? 'settled' : 'running'}>
- {root.callId}
- </div>
- )
- }
- const h = makeHarness({
- nodes: [user(1, 'q'), assistant(4, 'later')],
- runningCalls: [runningCall('r1')],
- }, { running: true })
- h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
- const routed = owner as RoutedChatNodeOwner
- return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
- ? <StatefulToolNode node={routed.node} />
- : opts?.fallback ?? null
- }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
- const view = render(<h.ChatView {...h.props} />)
- const tool = view.getByTestId('stateful-tool')
- const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')
- expect(tool.dataset.state).toBe('running')
- expect(mounted).toHaveBeenCalledTimes(1)
- act(() => {
- h.setChat({
- nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
- runningCalls: [],
- })
- h.setSession({ running: false })
- })
- expect(view.getByTestId('stateful-tool')).toBe(tool)
- expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row)
- expect(tool.dataset.state).toBe('settled')
- expect(mounted).toHaveBeenCalledTimes(1)
- expect(unmounted).not.toHaveBeenCalled()
- })
- it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
- const startTime = Date.now() - 125_000
- const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
- const h = makeHarness(
- { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
- { running: true },
- )
- const view = render(<h.ChatView {...h.props} />)
- // Freshly mounted (as after a reload) yet already past the 15s gate.
- const status = view.getByRole('status')
- expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
- expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
- act(() => {
- h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
- id: 'steering-occurrence' as never,
- content: [{ type: 'text', text: 'also' }],
- role: 'user', source: { kind: 'user' },
- }] } })
- })
- expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
- })
- it('the running clock reads hours once the turn passes an hour', () => {
- const startTime = Date.now() - 3_903_000
- const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
- const h = makeHarness(
- { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
- { running: true },
- )
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByRole('status').textContent).toMatch(/^深度求索中\.\.\.1小时05分0\d秒$/)
- })
- it('hands each ordered root call to the keyed business-node slot', () => {
- const block = toolResult(3, 'a')
- const h = makeHarness({ nodes: [block] })
- const calls: { key: string; owner: object; entryKey?: string }[] = []
- h.setNodeRenderer(((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
- calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
- return opts?.fallback ?? null
- }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
- render(<h.ChatView {...h.props} />)
- expect(calls).toHaveLength(1)
- expect(calls[0]).toMatchObject({
- key: 'conversation.chat.node',
- owner: { node: { kind: 'tool-call' } },
- entryKey: 'tool-call',
- })
- const owner = calls[0]?.owner as RoutedChatNodeOwner
- expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block)
- expect(owner.openFile).not.toBe(h.openFile)
- owner.openFile('src/a.ts')
- expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
- owner.inspectCall('a')
- expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
- })
- it('shows a Host open refusal with the reason and retries the same path', async () => {
- const openFile = vi.fn<(path: string) => Promise<void>>()
- .mockRejectedValueOnce(new Error('xdg-open is not available'))
- .mockResolvedValueOnce(undefined)
- const h = makeHarness({ nodes: [toolResult(3, 'a')] })
- h.props.openFile = openFile
- render(<h.ChatView {...h.props} />)
- await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
- await waitFor(() => {
- expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy()
- })
- expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('xdg-open is not available')
- await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
- await waitFor(() => {
- expect(screen.queryByRole('dialog')).toBeNull()
- })
- expect(openFile).toHaveBeenCalledTimes(2)
- expect(openFile).toHaveBeenNthCalledWith(1, 'src/a.ts')
- expect(openFile).toHaveBeenNthCalledWith(2, 'src/a.ts')
- })
- it('keeps a non-Error Host refusal visible and dismisses it on cancel', async () => {
- const openFile = vi.fn<(path: string) => Promise<void>>()
- .mockRejectedValueOnce('permission denied')
- const h = makeHarness({ nodes: [toolResult(3, 'a')] })
- h.props.openFile = openFile
- render(<h.ChatView {...h.props} />)
- await act(async () => { h.toolOwners[0]!.openFile('notes.md') })
- await waitFor(() => {
- expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('permission denied')
- })
- fireEvent.click(screen.getByRole('button', { name: '取消' }))
- expect(screen.queryByRole('dialog')).toBeNull()
- expect(openFile).toHaveBeenCalledTimes(1)
- })
- it('substitutes the unknown-open copy when the Host refusal has no text', async () => {
- const openFile = vi.fn<(path: string) => Promise<void>>()
- .mockRejectedValueOnce(new Error(''))
- const h = makeHarness({ nodes: [toolResult(3, 'a')] })
- h.props.openFile = openFile
- render(<h.ChatView {...h.props} />)
- await act(async () => { h.toolOwners[0]!.openFile('empty.ts') })
- await waitFor(() => {
- expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('无法打开此文件')
- })
- })
- it('ignores a Host refusal that settles after the dialog is dismissed', async () => {
- let rejectRetry!: (error: unknown) => void
- const openFile = vi.fn<(path: string) => Promise<void>>()
- .mockRejectedValueOnce(new Error('first refusal'))
- .mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
- rejectRetry = reject
- }))
- const h = makeHarness({ nodes: [toolResult(3, 'a')] })
- h.props.openFile = openFile
- render(<h.ChatView {...h.props} />)
- await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
- await waitFor(() => {
- expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal')
- })
- await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
- fireEvent.click(screen.getByRole('button', { name: '取消' }))
- expect(screen.queryByRole('dialog')).toBeNull()
- await act(async () => { rejectRetry(new Error('late refusal')) })
- expect(screen.queryByRole('dialog')).toBeNull()
- })
- it('ignores a Host open that succeeds after the dialog is dismissed', async () => {
- let resolveRetry!: () => void
- const openFile = vi.fn<(path: string) => Promise<void>>()
- .mockRejectedValueOnce(new Error('first refusal'))
- .mockImplementationOnce(() => new Promise<void>((resolve) => {
- resolveRetry = () => { resolve() }
- }))
- const h = makeHarness({ nodes: [toolResult(3, 'a')] })
- h.props.openFile = openFile
- render(<h.ChatView {...h.props} />)
- await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
- await waitFor(() => {
- expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal')
- })
- await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
- fireEvent.click(screen.getByRole('button', { name: '取消' }))
- await act(async () => { resolveRetry() })
- expect(screen.queryByRole('dialog')).toBeNull()
- })
- it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
- const h = makeHarness(
- { nodes: [user(5, 'later'), assistant(6, 'a')] },
- { hasMore: true },
- )
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- // jsdom has no layout: fake the metrics the anchor math reads.
- Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
- const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement
- let anchoredTop = 100
- vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
- () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
- )
- readerScroll(scroller, 80)
- // Arm the paging anchor, then deliver an older page (head seq decreases).
- fireEvent.click(view.getByText('加载更早'))
- Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
- anchoredTop = 700
- act(() => {
- h.setChat({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] })
- })
- expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
- // A new trailing user bubble (own words) force-scrolls to the bottom.
- act(() => {
- h.setChat({
- nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')],
- })
- })
- expect(scroller.scrollTop).toBe(1600)
- })
- it('back-to-bottom cancels an in-flight paging anchor', () => {
- const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
- readerScroll(scroller, 50)
- fireEvent.click(view.getByText('加载更早'))
- fireEvent.click(view.getByLabelText('回到底部'))
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
- act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
- expect(scroller.scrollTop).toBe(1_300)
- expect(h.chatScroll.read()).toBeNull()
- })
- it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
- readerScroll(scroller, 100) // far from bottom
- const backButton = view.getByLabelText('回到底部')
- expect(backButton).toBeTruthy()
- // Streaming growth must NOT drag a scrolled-away reader down.
- act(() => {
- h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } })
- })
- expect(scroller.scrollTop).toBe(100)
- fireEvent.click(backButton)
- expect(scroller.scrollTop).toBe(1000)
- // At the bottom again: follow re-arms and the button unmounts.
- expect(view.queryByLabelText('回到底部')).toBeNull()
- })
- it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 1_000, 300)
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- // Stream finalization shrinks the column: the browser clamps the pinned
- // position onto the new floor and delivers a scroll event. The clamp
- // lands exactly on the ledger's floor min, so it is not reader input.
- metrics.setLayout(800, 700)
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- expect(scroller.scrollTop).toBe(500)
- expect(view.queryByLabelText('回到底部')).toBeNull()
- expect(h.chatScroll.read()).toBeNull()
- metrics.setHeight(1_200)
- act(() => { h.setSession({ running: true }) })
- expect(scroller.scrollTop).toBe(900)
- })
- it('keeps following when a shrink clamp regrows before scrollend', () => {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 1_000, 300)
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- metrics.setLayout(800, 700)
- fireEvent.scroll(scroller)
- metrics.setHeight(962)
- act(() => { h.setSession({ running: true }) })
- fireEvent(scroller, new Event('scrollend'))
- expect(scroller.scrollTop).toBe(662)
- expect(view.queryByLabelText('回到底部')).toBeNull()
- expect(h.chatScroll.read()).toBeNull()
- })
- it('settles pinned deliveries before observer growth without reading row geometry', () => {
- let notify: (() => void) | undefined
- class ResizeObserverStub {
- constructor(callback: ResizeObserverCallback) {
- notify = () => { callback([], this as unknown as ResizeObserver) }
- }
- observe = vi.fn()
- disconnect = vi.fn()
- }
- vi.stubGlobal('ResizeObserver', ResizeObserverStub)
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 9_931, 300)
- expect(notify).toBeDefined()
- scroller.scrollTop = 9_631
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
- rect.mockClear()
- try {
- metrics.setLayout(9_918, 9_631)
- fireEvent.scroll(scroller)
- metrics.setHeight(10_013)
- act(() => { notify?.() })
- expect(scroller.scrollTop).toBe(9_713)
- fireEvent.scroll(scroller)
- metrics.setHeight(10_093)
- act(() => { notify?.() })
- expect(scroller.scrollTop).toBe(9_793)
- expect(rect).not.toHaveBeenCalled()
- expect(h.chatScroll.read()).toBeNull()
- } finally {
- rect.mockRestore()
- }
- })
- it('lets small reader movements accumulate past the follow threshold during growth', () => {
- let notify: (() => void) | undefined
- class ResizeObserverStub {
- constructor(callback: ResizeObserverCallback) {
- notify = () => { callback([], this as unknown as ResizeObserver) }
- }
- observe = vi.fn()
- disconnect = vi.fn()
- }
- vi.stubGlobal('ResizeObserver', ResizeObserverStub)
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 1_000, 300)
- expect(notify).toBeDefined()
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- scroller.scrollTop = 690
- fireEvent.scroll(scroller)
- metrics.setHeight(1_020)
- act(() => { notify?.() })
- expect(scroller.scrollTop).toBe(690)
- scroller.scrollTop = 680
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- metrics.setHeight(1_040)
- act(() => { notify?.() })
- expect(scroller.scrollTop).toBe(680)
- })
- it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 1_000, 300)
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- scroller.scrollTop = 500
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- scroller.scrollTop = 400
- fireEvent.scroll(scroller)
- fireEvent.click(view.getByLabelText('回到底部'))
- fireEvent.scroll(scroller)
- metrics.setHeight(1_200)
- act(() => { h.setSession({ running: true }) })
- expect(scroller.scrollTop).toBe(900)
- expect(h.chatScroll.read()).toBeNull()
- })
- it('samples away-reader geometry on the interval or scrollend and cancels it on unmount', () => {
- vi.useFakeTimers()
- try {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- installScrollMetrics(scroller, 1_000, 300)
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- scroller.scrollTop = 500
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
- try {
- act(() => { vi.advanceTimersByTime(500) })
- rect.mockClear()
- scroller.scrollTop = 400
- fireEvent.scroll(scroller)
- scroller.scrollTop = 300
- fireEvent.scroll(scroller)
- act(() => { vi.advanceTimersByTime(499) })
- expect(rect).not.toHaveBeenCalled()
- act(() => { vi.advanceTimersByTime(1) })
- expect(rect).toHaveBeenCalled()
- rect.mockClear()
- scroller.scrollTop = 200
- fireEvent.scroll(scroller)
- expect(rect).not.toHaveBeenCalled()
- fireEvent(scroller, new Event('scrollend'))
- expect(rect).toHaveBeenCalled()
- scroller.scrollTop = 100
- fireEvent.scroll(scroller)
- view.unmount()
- rect.mockClear()
- act(() => { vi.advanceTimersByTime(500) })
- expect(rect).not.toHaveBeenCalled()
- } finally {
- rect.mockRestore()
- }
- } finally {
- vi.useRealTimers()
- }
- })
- it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- installScrollMetrics(scroller, 1_000, 300)
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- // Chromium advances compositor geometry before delivering the event:
- // attribution must compare against the observed-top ledger, never a
- // baseline sampled from already-moved raw geometry.
- scroller.scrollTop = 500
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- })
- it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
- let notify: (() => void) | undefined
- const observe = vi.fn()
- class ResizeObserverStub {
- constructor(callback: ResizeObserverCallback) {
- notify = () => { callback([], this as unknown as ResizeObserver) }
- }
- observe = observe
- disconnect = vi.fn()
- }
- vi.stubGlobal('ResizeObserver', ResizeObserverStub)
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
- scroller.scrollTop = 700
- fireEvent.scroll(scroller)
- fireEvent(scroller, new Event('scrollend'))
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
- act(() => { notify?.() })
- expect(scroller.scrollTop).toBe(1_200)
- readerScroll(scroller, 200)
- Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
- act(() => { notify?.() })
- expect(scroller.scrollTop).toBe(200)
- expect(observe).toHaveBeenCalledTimes(1)
- })
- it('pinned dynamic-height updates select the latest Turn without reading row geometry', () => {
- let notify: (() => void) | undefined
- let nextFrame = 0
- const frames = new Map<number, FrameRequestCallback>()
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
- nextFrame += 1
- frames.set(nextFrame, callback)
- return nextFrame
- })
- vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) })
- class ResizeObserverStub {
- constructor(callback: ResizeObserverCallback) {
- notify = () => { callback([], this as unknown as ResizeObserver) }
- }
- observe = vi.fn()
- disconnect = vi.fn()
- }
- vi.stubGlobal('ResizeObserver', ResizeObserverStub)
- const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
- .mockReturnValue({ top: 0, bottom: 40 } as DOMRect)
- const h = makeHarness({
- nodes: [
- userInTurn(1, 'first', 1), assistant(2, 'first answer', 1),
- userInTurn(4, 'second', 2), assistant(5, 'second answer', 2),
- ],
- turnEnds: new Map([[1, 3], [2, 6]]),
- })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- const metrics = installScrollMetrics(scroller, 1_000, 300)
- scroller.scrollTop = 700
- act(() => {
- const pending = [...frames.values()]
- frames.clear()
- for (const callback of pending) callback(0)
- })
- rect.mockClear()
- metrics.setHeight(1_200)
- act(() => { notify?.() })
- act(() => {
- const pending = [...frames.values()]
- frames.clear()
- for (const callback of pending) callback(0)
- })
- expect(scroller.scrollTop).toBe(900)
- expect(view.getByRole('button', { name: '跳转到第 2 轮' }).getAttribute('aria-current')).toBe('true')
- expect(rect).not.toHaveBeenCalled()
- })
- it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />)
- const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
- Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
- Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
- // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
- // re-render from setAtBottom must not force scrollTop to scrollHeight.
- readerScroll(scroller, 690) // distance-to-bottom = 10
- expect(view.queryByLabelText('回到底部')).toBeNull()
- expect(scroller.scrollTop).toBe(690)
- })
- it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
- const host = document.createElement('div')
- host.setAttribute('data-conversation-scroll', '')
- Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
- Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
- Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
- document.body.appendChild(host)
- try {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />, { container: host })
- // Open jump uses the host, not the local .scroll node.
- expect(host.scrollTop).toBe(2000)
- readerScroll(host, 100)
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- fireEvent.click(view.getByLabelText('回到底部'))
- expect(host.scrollTop).toBe(2000)
- } finally {
- host.remove()
- }
- })
- it('a remount restores the saved semantic row after width reflow', () => {
- const host = document.createElement('div')
- host.setAttribute('data-conversation-scroll', '')
- Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
- Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
- Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
- document.body.appendChild(host)
- let anchorTop = 80
- vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
- () => ({ top: 0, bottom: 500 } as DOMRect),
- )
- const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
- if (this.dataset.chatAnchorKey === 'fixture:user:1') {
- return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
- }
- return { top: 0, bottom: 40 } as DOMRect
- })
- try {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- // Fresh open (nothing saved): the bottom jump stands.
- const view = render(<h.ChatView {...h.props} />, { container: host })
- expect(host.scrollTop).toBe(2000)
- // Reader scrolls up; the position is recorded continuously.
- readerScroll(host, 100)
- // View-tab switch away and back: the view unmounts, then remounts.
- view.rerender(<div />)
- anchorTop = 560
- host.scrollTop = 0
- view.rerender(<h.ChatView {...h.props} />)
- expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
- // The restored position is above the floor: follow stays disarmed.
- expect(view.getByLabelText('回到底部')).toBeTruthy()
- } finally {
- rect.mockRestore()
- host.remove()
- }
- })
- it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
- const host = document.createElement('div')
- host.setAttribute('data-conversation-scroll', '')
- Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
- Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
- let scrollTop = 0
- Object.defineProperty(host, 'scrollTop', {
- configurable: true,
- get: () => scrollTop,
- set: (value: number) => { scrollTop = Math.min(value, 1_500) },
- })
- document.body.appendChild(host)
- const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
- if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect
- return { top: 0, bottom: 500 } as DOMRect
- })
- try {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 })
- const view = render(<h.ChatView {...h.props} />, { container: host })
- expect(host.scrollTop).toBe(1_500)
- expect(h.chatScroll.read()).toBeNull()
- view.rerender(<div />)
- host.scrollTop = 0
- view.rerender(<h.ChatView {...h.props} />)
- expect(host.scrollTop).toBe(1_500)
- } finally {
- rect.mockRestore()
- host.remove()
- }
- })
- it('a remount while pinned to the bottom keeps the bottom jump', () => {
- const host = document.createElement('div')
- host.setAttribute('data-conversation-scroll', '')
- Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
- Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
- Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
- document.body.appendChild(host)
- try {
- const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
- const view = render(<h.ChatView {...h.props} />, { container: host })
- // At the bottom: the scroll event records the pinned state (null).
- fireEvent.scroll(host)
- expect(h.chatScroll.read()).toBeNull()
- view.rerender(<div />)
- host.scrollTop = 0
- view.rerender(<h.ChatView {...h.props} />)
- expect(host.scrollTop).toBe(2000)
- } finally {
- host.remove()
- }
- })
- it('paging button loads older and shows its busy label', () => {
- const h = makeHarness({ nodes: [user(5, 'later')] }, { hasMore: true })
- const view = render(<h.ChatView {...h.props} />)
- fireEvent.click(view.getByText('加载更早'))
- expect(h.loadOlder).toHaveBeenCalledTimes(1)
- act(() => { h.setSession({ loadingOlder: true }) })
- expect(view.getByText('加载中…')).toBeTruthy()
- })
- it('shows open error and loading states', () => {
- const h = makeHarness({}, {
- openState: 'error',
- openError: { code: 'gateway/internal', message: 'boom' } as never,
- })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
- const loading = makeHarness({}, { openState: 'loading' })
- const lv = render(<loading.ChatView {...loading.props} />)
- expect(lv.getByText('载入历史…')).toBeTruthy()
- })
- it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
- // Settled success: the bare command name is the title, the outcome text
- // the summary — neither the dispatched `/` nor its arguments reach the row
- // (the settlement text already says what the command did).
- const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] })
- const view = render(<settled.ChatView {...settled.props} />)
- expect(view.getByText('plan')).toBeTruthy()
- expect(view.queryByText('/plan')).toBeNull()
- expect(view.queryByText('/plan now')).toBeNull()
- expect(view.getByText('已进入 plan mode')).toBeTruthy()
- // Error outcome flips the row state; a text-less error gets the default copy.
- const failed = makeHarness({
- nodes: [command({ seq: 6, commandId: 'cmd-2' as CommandNode['commandId'], outcome: { kind: 'error' } })],
- })
- const fv = render(<failed.ChatView {...failed.props} />)
- expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
- expect(fv.getByText('指令失败')).toBeTruthy()
- expect(fv.getByText('失败')).toBeTruthy()
- // Still executing: running state with the executing copy.
- const executing = makeHarness({
- nodes: [command({ seq: 7, commandId: 'cmd-3' as CommandNode['commandId'], outcome: null })],
- })
- const xv = render(<executing.ChatView {...executing.props} />)
- expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
- expect(xv.getByText('执行中…')).toBeTruthy()
- expect(xv.getByText('运行中')).toBeTruthy()
- // Cross-window soft-fall (run page truncated): generic title, outcome preserved.
- const orphan = makeHarness({
- nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })],
- })
- const ov = render(<orphan.ChatView {...orphan.props} />)
- expect(ov.getByText('指令')).toBeTruthy()
- expect(ov.getByText('已完成')).toBeTruthy()
- })
- it('renders /compact as one stateful disclosure from running through completion', () => {
- const running = command({
- commandId: 'cmd-compact' as CommandNode['commandId'],
- name: 'compact',
- outcome: null,
- })
- const h = makeHarness({ nodes: [running] })
- const view = render(<h.ChatView {...h.props} />)
- expect(view.getByText('正在压缩…')).toBeTruthy()
- expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
- act(() => {
- h.setChat({
- nodes: [{
- ...running,
- outcome: {
- kind: 'success',
- text: 'Compacted 16 history items (~11309 tokens).',
- sourceEventSeq: 7,
- },
- }, compaction()],
- })
- })
- expect(view.queryByText('正在压缩…')).toBeNull()
- expect(view.queryByText('上下文已压缩')).toBeNull()
- expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
- const row = view.getByRole('button', { name: /compact/ })
- expect(row.getAttribute('aria-expanded')).toBe('false')
- expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull()
- expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull()
- expect(view.queryByText('保留的事实。')).toBeNull()
- fireEvent.click(row)
- expect(row.getAttribute('aria-expanded')).toBe('true')
- expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull()
- expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy()
- })
- it('keeps /compact no-history and error settlements on the generic command row', () => {
- const noHistory = makeHarness({
- nodes: [command({
- name: 'compact',
- outcome: { kind: 'success', text: 'No compactable history yet.' },
- })],
- })
- const noHistoryView = render(<noHistory.ChatView {...noHistory.props} />)
- expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy()
- expect(noHistoryView.queryByRole('button')).toBeNull()
- const failed = makeHarness({
- nodes: [command({
- commandId: 'cmd-compact-failed' as CommandNode['commandId'],
- name: 'compact',
- outcome: { kind: 'error', text: 'Compaction cancelled.' },
- })],
- })
- const failedView = render(<failed.ChatView {...failed.props} />)
- expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
- expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
- })
- })
|