Clicking an unloaded rail mark from the pinned tail raced the pinned scroll snap: the first prepend's compensation fires a non-reader scroll delivery, the snap called toBottom, and toBottom cancels a pending jump — so the jump silently stayed at the tail while history loaded. The click now drops atBottom itself (jumping into history is leaving the live tail), pinned by a jsdom regression and re-verified live: a 118-turn session lands on turn 1 in ~250ms from click.
2611 lines
113 KiB
TypeScript
2611 lines
113 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
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, SelectionTarget, 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 { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
|
||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||
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 { 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 { encodeTurnProcess } from '../src/client/contract/turn-process.ts'
|
||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||
|
||
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 }
|
||
|
||
function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||
return {
|
||
sessionId: SID,
|
||
queue: [],
|
||
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<SessionSnapshot> = {}) {
|
||
let snap = sessionSnapshot(init)
|
||
const subs = new Set<() => void>()
|
||
return {
|
||
set: (next: Partial<SessionSnapshot>) => {
|
||
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<SessionSnapshot> & { 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,
|
||
provenance: { 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: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
|
||
return bindSnapshotSelector(store)
|
||
}
|
||
|
||
function emptyWorkspaces() {
|
||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||
})
|
||
return bindSnapshotSelector(store)
|
||
}
|
||
|
||
function makeHarness(
|
||
init: HarnessUpdate = {},
|
||
sessionOverrides: Partial<SessionSnapshot> = {},
|
||
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 chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
|
||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
|
||
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
|
||
selectedCallId: string | undefined
|
||
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 renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
|
||
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
|
||
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 nodeKey = opts?.hookContext as string | undefined
|
||
const useTurnData: UseChatNodeTurnData = dataKey => props.useChat((snapshot) => {
|
||
const location = nodeKey === undefined ? undefined : snapshot.nodes.get(nodeKey)?.location
|
||
return location?.kind === 'turn' || location?.kind === 'step'
|
||
? location.turn.data.get(dataKey)
|
||
: undefined
|
||
})
|
||
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}
|
||
renderSlotChain={renderTurnTail}
|
||
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,
|
||
selectedCallId: nodeOwner.selectedCallId,
|
||
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 = {
|
||
sessionId: SID,
|
||
useSession: bindSnapshotSelector(session.source),
|
||
useChat: bindSnapshotSelector(chatSource.source),
|
||
useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
|
||
useTrajectory: (() => { throw new Error('unused') }),
|
||
useSessions: emptySessions(),
|
||
useSessionPendingInteraction: bindSnapshotSelector(
|
||
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
|
||
),
|
||
useWorkspaces: emptyWorkspaces(),
|
||
useProjection: () => outlineValue,
|
||
useInput: (() => { throw new Error('unused') }),
|
||
inputActions: {
|
||
setDraft: () => {},
|
||
addImages: () => true,
|
||
removeImage: () => {},
|
||
pruneImages: () => {},
|
||
submit: () => {},
|
||
},
|
||
useStore: bindSnapshotSelector(chat),
|
||
actions: chat.actions,
|
||
useTranscriptView: bindSnapshotSelector(transcriptView),
|
||
renderSlot,
|
||
SessionProvider: SessionProviderStub,
|
||
viewRequest: null,
|
||
openView,
|
||
completeViewRequest: () => {},
|
||
openDetails,
|
||
openFile,
|
||
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)
|
||
}
|
||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||
return {
|
||
set, setSession: session.set, setChat: chatSource.set, ChatView, props,
|
||
openDetails, openFile, loadOlder, loadThrough, openView,
|
||
setOutline: (value: unknown) => { outlineValue = value },
|
||
chatScroll, forkAt, setSelection, 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)
|
||
}
|
||
|
||
function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
|
||
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
|
||
}
|
||
|
||
function withSystemPrompt(snapshot: ChatSnapshot, 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 new ChatSnapshotBuilder().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('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秒')
|
||
})
|
||
|
||
})
|
||
|
||
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.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||
|
||
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({
|
||
turns: [
|
||
{ turn: 1, seq: 0, prompt: 'first prompt from outline' },
|
||
{ turn: 2, seq: 4, prompt: 'second prompt from outline' },
|
||
{ turn: 3, seq: 8, prompt: 'third prompt' },
|
||
],
|
||
})
|
||
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)
|
||
expect(view.getByRole('tooltip').textContent).toContain('first prompt 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({
|
||
turns: [
|
||
{ turn: 1, seq: 0, prompt: 'first prompt' },
|
||
{ turn: 3, seq: 8, prompt: 'third prompt' },
|
||
],
|
||
})
|
||
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('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({
|
||
turns: Array.from({ length: 60 }, (_, index) => ({
|
||
turn: index + 1,
|
||
seq: index * 4,
|
||
prompt: `p${String(index + 1)}`,
|
||
})),
|
||
})
|
||
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({
|
||
turns: [
|
||
{ turn: 1, seq: 0, prompt: 'first prompt' },
|
||
{ turn: 3, seq: 8, prompt: 'third prompt' },
|
||
],
|
||
})
|
||
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?.() })
|
||
expect(first.getAttribute('aria-busy')).toBeNull()
|
||
expect(first.getAttribute('aria-current')).toBe('true')
|
||
})
|
||
|
||
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,
|
||
messageId: 'steer-message' as never,
|
||
placement: 'steering' as const,
|
||
content: [{ type: 'text' as const, text: 'interrupt now' }],
|
||
preview: 'interrupt now',
|
||
text: 'interrupt now',
|
||
}
|
||
const queued = {
|
||
id: 'queued-occurrence' as never,
|
||
messageId: 'queued-message' as never,
|
||
placement: 'queued' as const,
|
||
content: [{ type: 'text' as const, text: 'later' }],
|
||
preview: 'later',
|
||
text: 'later',
|
||
}
|
||
const h = makeHarness(
|
||
{ nodes: [assistant(1, 'working')] },
|
||
{ queue: [queued, 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({ queue: [queued] })
|
||
h.setChat({
|
||
nodes: [
|
||
assistant(1, 'working'),
|
||
{
|
||
kind: 'steering', messageId: pending.messageId,
|
||
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,
|
||
messageId: 'shared-steer-message' as never,
|
||
placement: 'steering' 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,
|
||
}],
|
||
}, { queue: [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: '即发即显', images: [],
|
||
},
|
||
],
|
||
},
|
||
)
|
||
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: '带图纠偏',
|
||
images: [{ 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({
|
||
queue: [{
|
||
id: 'steer-occurrence' as never,
|
||
messageId: 'steer-message' as never,
|
||
placement: 'steering',
|
||
rpcId: 'req-steer' as never,
|
||
content: [{ type: 'text', text: '带图纠偏' }],
|
||
preview: '带图纠偏',
|
||
text: '带图纠偏',
|
||
}],
|
||
})
|
||
})
|
||
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: '排队中', images: [],
|
||
},
|
||
],
|
||
},
|
||
)
|
||
const view = render(<h.ChatView {...h.props} />)
|
||
expect(view.queryByText('排队中')).toBeNull()
|
||
act(() => {
|
||
h.setSession({
|
||
queue: [{
|
||
id: 'q-occurrence' as never,
|
||
messageId: 'q-message' as never,
|
||
placement: 'queued' as const,
|
||
rpcId: 'req-q' as never,
|
||
content: [{ type: 'text' as const, text: '排队中' }],
|
||
preview: '排队中',
|
||
text: '排队中',
|
||
}],
|
||
})
|
||
})
|
||
// 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: '',
|
||
images: [
|
||
{ previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
|
||
{ 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 = (owner as { images: readonly unknown[] }).images
|
||
return <div data-testid="echo-images" data-count={images.length} data-first={JSON.stringify(images[0])} />
|
||
}) as unknown as ChatViewSlotProps['renderSlot']
|
||
const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
|
||
const gallery = view.getByTestId('echo-images')
|
||
expect(gallery.getAttribute('data-count')).toBe('2')
|
||
expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({
|
||
preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
|
||
})
|
||
})
|
||
|
||
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,
|
||
provenance: { 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 initial = withSystemPrompt(chatSnapshotFixture({
|
||
nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
|
||
}))
|
||
const running = withSystemPrompt(chatSnapshotFixture({
|
||
nodes: [
|
||
userInTurn(2, 'question', 1),
|
||
context(3, 'runtime policy', 1),
|
||
reasoningAssistant(4, 'inspect', 1, 1),
|
||
],
|
||
}))
|
||
const completed = 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]]),
|
||
}))
|
||
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: running, 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: completed, 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: ReturnType<typeof encodeTurnProcess>): void
|
||
}
|
||
const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq }
|
||
turnData.set('turn-process', encodeTurnProcess(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', encodeTurnProcess(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(() => { 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 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('user rows and turn tails both gate the whole actions row by recency', () => {
|
||
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} />)
|
||
// The last user-authored row and the latest turn's tail stay shown;
|
||
// every earlier row of either kind reveals on hover.
|
||
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']]))
|
||
const userRows = [...view.container.querySelectorAll('[data-actions-reveal]')]
|
||
.filter(row => row.getAttribute('data-turn-tail') === null)
|
||
expect(userRows.map(row => row.getAttribute('data-actions-reveal'))).toEqual(['hover', 'always'])
|
||
})
|
||
|
||
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('updates the selected call id handed to the Tool seat', () => {
|
||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||
render(<h.ChatView {...h.props} />)
|
||
expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined()
|
||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||
expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a')
|
||
})
|
||
|
||
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({ queue: [{
|
||
id: 'steering-occurrence' as never,
|
||
messageId: 'steering-message' as never,
|
||
placement: 'steering',
|
||
content: [{ type: 'text', text: 'also' }],
|
||
preview: 'also',
|
||
text: 'also',
|
||
}] })
|
||
})
|
||
expect(status.textContent).toMatch(/^深度求索中\.\.\.2分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' }, selectedCallId: undefined },
|
||
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('names a workspace-folder Host refusal as a folder', 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('.') })
|
||
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)
|
||
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('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)
|
||
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)
|
||
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()
|
||
})
|
||
})
|