|
|
@@ -4,22 +4,26 @@ 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, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
|
|
- ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
|
|
|
- TurnMaxTokensNode, UserMessageNode, WorkspaceListState,
|
|
|
-} from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
-import {
|
|
|
- createSnapshotStore, EMPTY_CONVERSATION_VIEWS,
|
|
|
-} from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+ AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
|
|
|
+ ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode,
|
|
|
+ LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget,
|
|
|
+ ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
|
|
|
+ UseChatNodeTurnData, UserMessageNode,
|
|
|
+} from '@deepseek-ai/dsh-client-ui-chat/client'
|
|
|
import type {
|
|
|
- ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
|
|
|
-} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
+ 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 } 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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
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 { zh } from '../src/client/locales.ts'
|
|
|
+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 {
|
|
|
@@ -43,32 +47,54 @@ beforeEach(() => {
|
|
|
const SID = 's1' as SessionId
|
|
|
type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
|
|
|
|
|
|
-function snapshotBase(): ConversationSnapshot {
|
|
|
+function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
|
|
return {
|
|
|
- sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [],
|
|
|
- turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
|
|
- queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
|
|
- hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
|
|
+ sessionId: SID,
|
|
|
+ queue: [],
|
|
|
+ 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 snapshot source: set() swaps the top-level object like the real Session. */
|
|
|
-function makeSource(init?: Partial<ConversationSnapshot>) {
|
|
|
- const initial = { ...snapshotBase(), ...init }
|
|
|
- let snap: ConversationSnapshot = {
|
|
|
- ...initial,
|
|
|
- chat: init?.chat ?? chatSnapshotFixture(initial),
|
|
|
+/** 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>
|
|
|
+
|
|
|
+/** 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: Partial<ConversationSnapshot>) => {
|
|
|
- const merged = { ...snap, ...next }
|
|
|
- snap = {
|
|
|
- ...merged,
|
|
|
- chat: Object.hasOwn(next, 'chat') && next.chat !== undefined
|
|
|
- ? next.chat
|
|
|
- : chatSnapshotFixture(merged, snap.chat),
|
|
|
- }
|
|
|
+ set: (next: ChatSlice) => {
|
|
|
+ snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
|
|
|
for (const fn of [...subs]) fn()
|
|
|
},
|
|
|
source: {
|
|
|
@@ -138,19 +164,23 @@ function emptySessions() {
|
|
|
}
|
|
|
|
|
|
function emptyWorkspaces() {
|
|
|
- const store = createSnapshotStore<WorkspaceListState>({
|
|
|
+ const store = createSnapshotStore<WorkspaceSnapshot>({
|
|
|
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
|
|
- baselinesReady: true, recentWorkspaceId: undefined,
|
|
|
})
|
|
|
return bindSnapshotSelector(store)
|
|
|
}
|
|
|
|
|
|
-function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|
|
- const { set, source } = makeSource(init)
|
|
|
+function makeHarness(
|
|
|
+ chatSlice: ChatSlice = {},
|
|
|
+ sessionInit: Partial<SessionSnapshot> = {},
|
|
|
+ chatSnapshot?: ChatSnapshot,
|
|
|
+) {
|
|
|
+ const session = makeSessionSource(sessionInit)
|
|
|
+ const chatSource = makeChatSource(chatSlice, chatSnapshot)
|
|
|
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
|
|
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
|
|
|
const loadOlder = vi.fn()
|
|
|
- const inspectCall = vi.fn<(callId: string) => void>()
|
|
|
+ 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'] = {
|
|
|
@@ -182,8 +212,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|
|
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.useSession((snapshot) => {
|
|
|
- const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
|
|
|
+ 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
|
|
|
@@ -255,12 +285,18 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|
|
}
|
|
|
}) as unknown as ChatViewSlotProps['renderSlot']
|
|
|
// SessionProvider seat arrives with the session-scope child declaration;
|
|
|
- // ChatView never invokes it (render-prop pass-through stub).
|
|
|
- const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
|
|
+ // ChatView never invokes it (pass-through stub).
|
|
|
+ const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
|
|
|
const props: ChatViewSlotProps = {
|
|
|
sessionId: SID,
|
|
|
- useSession: bindSnapshotSelector(source),
|
|
|
+ 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: (() => undefined),
|
|
|
useInput: (() => { throw new Error('unused') }),
|
|
|
@@ -275,11 +311,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|
|
actions: chat.actions,
|
|
|
renderSlot,
|
|
|
SessionProvider: SessionProviderStub,
|
|
|
+ viewRequest: null,
|
|
|
+ openView,
|
|
|
+ completeViewRequest: () => {},
|
|
|
openDetails,
|
|
|
openFile,
|
|
|
loadOlder,
|
|
|
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
|
- inspectCall,
|
|
|
chatScroll,
|
|
|
forkAt,
|
|
|
// Absent-service default; mention tests override with a real resolver.
|
|
|
@@ -288,7 +326,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|
|
}
|
|
|
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
|
|
return {
|
|
|
- set, ChatView, props, openDetails, openFile, loadOlder, inspectCall,
|
|
|
+ setSession: session.set, setChat: chatSource.set, ChatView, props,
|
|
|
+ openDetails, openFile, loadOlder, openView,
|
|
|
chatScroll, forkAt, setSelection, toolOwners,
|
|
|
}
|
|
|
}
|
|
|
@@ -380,7 +419,10 @@ describe('ChatView', () => {
|
|
|
})
|
|
|
|
|
|
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 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
|
|
|
@@ -407,7 +449,9 @@ describe('ChatView', () => {
|
|
|
readerScroll(scroller, 90)
|
|
|
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
|
|
nextTop = 560
|
|
|
- act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
|
|
|
+ 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
|
|
|
})
|
|
|
|
|
|
@@ -460,7 +504,10 @@ describe('ChatView', () => {
|
|
|
preview: 'later',
|
|
|
text: 'later',
|
|
|
}
|
|
|
- const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true })
|
|
|
+ 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()
|
|
|
@@ -474,8 +521,8 @@ describe('ChatView', () => {
|
|
|
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({
|
|
|
- queue: [queued],
|
|
|
+ h.setSession({ queue: [queued] })
|
|
|
+ h.setChat({
|
|
|
nodes: [
|
|
|
assistant(1, 'working'),
|
|
|
{
|
|
|
@@ -496,7 +543,8 @@ describe('ChatView', () => {
|
|
|
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
|
|
+ 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.
|
|
|
@@ -517,13 +565,11 @@ describe('ChatView', () => {
|
|
|
text: 'same steering',
|
|
|
}
|
|
|
const h = makeHarness({
|
|
|
- queue: [pending],
|
|
|
nodes: [{
|
|
|
kind: 'user', seq: 2, time: 2_000,
|
|
|
content: pending.content, source: null,
|
|
|
}],
|
|
|
- running: true,
|
|
|
- })
|
|
|
+ }, { queue: [pending], running: true })
|
|
|
const view = render(<h.ChatView {...h.props} />)
|
|
|
|
|
|
expect(view.getAllByText('same steering')).toHaveLength(2)
|
|
|
@@ -538,35 +584,36 @@ describe('ChatView', () => {
|
|
|
provenance: { role: 'inject', label: null },
|
|
|
form: null,
|
|
|
} as const satisfies ConversationNode
|
|
|
- const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
|
|
+ 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.set({ nodes: [user(1, 'try'), nextRetry] })
|
|
|
+ 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.set({
|
|
|
+ h.setChat({
|
|
|
nodes: [
|
|
|
user(1, 'try'),
|
|
|
{ ...nextRetry, retryState: 'started' },
|
|
|
context,
|
|
|
assistant(5, 'done'),
|
|
|
],
|
|
|
- running: false,
|
|
|
})
|
|
|
+ h.setSession({ running: false })
|
|
|
})
|
|
|
expect(disclosure.dataset.active).toBeUndefined()
|
|
|
expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
|
|
|
+ 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()
|
|
|
@@ -598,7 +645,8 @@ describe('ChatView', () => {
|
|
|
nodes: [toolResult(3, 'a')],
|
|
|
})
|
|
|
render(<h.ChatView {...h.props} />)
|
|
|
- expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall)
|
|
|
+ h.toolOwners[0]?.inspectCall('a')
|
|
|
+ expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
|
|
|
})
|
|
|
|
|
|
it('shows assistant IconActions only on the last content message of each turn', () => {
|
|
|
@@ -623,7 +671,6 @@ describe('ChatView', () => {
|
|
|
|
|
|
it('withholds assistant IconActions while the turn is still running', () => {
|
|
|
const h = makeHarness({
|
|
|
- running: true,
|
|
|
runningCalls: [runningCall('a')],
|
|
|
nodes: [
|
|
|
user(1, 'first'),
|
|
|
@@ -633,7 +680,7 @@ describe('ChatView', () => {
|
|
|
],
|
|
|
// 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
|
|
|
@@ -641,7 +688,10 @@ describe('ChatView', () => {
|
|
|
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.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) })
|
|
|
+ act(() => {
|
|
|
+ h.setSession({ running: false })
|
|
|
+ h.setChat({ runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) })
|
|
|
+ })
|
|
|
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
|
|
})
|
|
|
|
|
|
@@ -694,8 +744,7 @@ describe('ChatView', () => {
|
|
|
nodes: [user(1, 'hi'), settled],
|
|
|
turnTimings: new Map([[1, { startTime: 1_000 }]]),
|
|
|
turnEnds: new Map(),
|
|
|
- running: true,
|
|
|
- })
|
|
|
+ }, { running: true })
|
|
|
const view = render(<h.ChatView {...h.props} />)
|
|
|
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
|
|
|
})
|
|
|
@@ -748,7 +797,7 @@ describe('ChatView', () => {
|
|
|
getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
|
|
|
},
|
|
|
}
|
|
|
- const h = makeHarness({ chat })
|
|
|
+ const h = makeHarness({}, {}, chat)
|
|
|
const view = render(<h.ChatView {...h.props} />)
|
|
|
const branch = view.getByRole('button', { name: '在新对话中分支' })
|
|
|
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
|
|
@@ -785,13 +834,13 @@ describe('ChatView', () => {
|
|
|
expect(literal.querySelector('h1')).toBeNull()
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
|
|
|
+ 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.set({
|
|
|
+ h.setChat({
|
|
|
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
|
|
|
partial: null,
|
|
|
})
|
|
|
@@ -800,7 +849,7 @@ describe('ChatView', () => {
|
|
|
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({
|
|
|
+ h.setChat({
|
|
|
nodes: [
|
|
|
user(1, markdown),
|
|
|
assistant(2, markdown),
|
|
|
@@ -820,10 +869,10 @@ describe('ChatView', () => {
|
|
|
const tool = view.getByTestId('tool-seat-a')
|
|
|
const beforeHtml = tool.innerHTML
|
|
|
act(() => {
|
|
|
- h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
|
|
+ h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
|
|
})
|
|
|
act(() => {
|
|
|
- h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
|
|
|
+ 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)
|
|
|
@@ -847,10 +896,10 @@ describe('ChatView', () => {
|
|
|
expect(view.getByTestId('counting-row')).toBeTruthy()
|
|
|
const afterMount = rowRenders
|
|
|
act(() => {
|
|
|
- h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
|
|
|
+ h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
|
|
|
})
|
|
|
act(() => {
|
|
|
- h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
|
|
|
+ h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
|
|
|
})
|
|
|
expect(rowRenders).toBe(afterMount)
|
|
|
})
|
|
|
@@ -864,7 +913,7 @@ describe('ChatView', () => {
|
|
|
})
|
|
|
|
|
|
it('hands running calls to a live Tool group', () => {
|
|
|
- const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
|
|
+ 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"}' })
|
|
|
@@ -890,8 +939,7 @@ describe('ChatView', () => {
|
|
|
const h = makeHarness({
|
|
|
nodes: [user(1, 'q'), assistant(4, 'later')],
|
|
|
runningCalls: [runningCall('r1')],
|
|
|
- running: true,
|
|
|
- })
|
|
|
+ }, { 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'
|
|
|
@@ -905,11 +953,11 @@ describe('ChatView', () => {
|
|
|
expect(mounted).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({
|
|
|
+ h.setChat({
|
|
|
nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
|
|
|
runningCalls: [],
|
|
|
- running: false,
|
|
|
})
|
|
|
+ h.setSession({ running: false })
|
|
|
})
|
|
|
|
|
|
expect(view.getByTestId('stateful-tool')).toBe(tool)
|
|
|
@@ -922,16 +970,17 @@ describe('ChatView', () => {
|
|
|
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 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(/^Deep diving\.\.\.2分0\d秒$/)
|
|
|
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
|
|
act(() => {
|
|
|
- h.set({ queue: [{
|
|
|
+ h.setSession({ queue: [{
|
|
|
id: 'steering-occurrence' as never,
|
|
|
messageId: 'steering-message' as never,
|
|
|
placement: 'steering',
|
|
|
@@ -963,7 +1012,8 @@ describe('ChatView', () => {
|
|
|
expect(owner.openFile).not.toBe(h.openFile)
|
|
|
owner.openFile('src/a.ts')
|
|
|
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
|
|
- expect(owner.inspectCall).toBe(h.inspectCall)
|
|
|
+ owner.inspectCall('a')
|
|
|
+ expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
|
|
|
})
|
|
|
|
|
|
it('shows a Host open refusal with the reason and retries the same path', async () => {
|
|
|
@@ -1068,7 +1118,10 @@ describe('ChatView', () => {
|
|
|
})
|
|
|
|
|
|
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 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.
|
|
|
@@ -1084,15 +1137,21 @@ describe('ChatView', () => {
|
|
|
fireEvent.click(view.getByText('加载更早'))
|
|
|
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
|
|
anchoredTop = 700
|
|
|
- act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
|
|
+ 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.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
|
|
+ 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 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 })
|
|
|
@@ -1101,7 +1160,7 @@ describe('ChatView', () => {
|
|
|
fireEvent.click(view.getByText('加载更早'))
|
|
|
fireEvent.click(view.getByLabelText('回到底部'))
|
|
|
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
|
|
- act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
|
|
+ act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
|
|
expect(scroller.scrollTop).toBe(1_300)
|
|
|
expect(h.chatScroll.read()).toBeNull()
|
|
|
})
|
|
|
@@ -1116,7 +1175,9 @@ describe('ChatView', () => {
|
|
|
const backButton = view.getByLabelText('回到底部')
|
|
|
expect(backButton).toBeTruthy()
|
|
|
// Streaming growth must NOT drag a scrolled-away reader down.
|
|
|
- act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
|
|
|
+ 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)
|
|
|
@@ -1142,7 +1203,7 @@ describe('ChatView', () => {
|
|
|
expect(h.chatScroll.read()).toBeNull()
|
|
|
|
|
|
metrics.setHeight(1_200)
|
|
|
- act(() => { h.set({ running: true }) })
|
|
|
+ act(() => { h.setSession({ running: true }) })
|
|
|
expect(scroller.scrollTop).toBe(900)
|
|
|
})
|
|
|
|
|
|
@@ -1318,22 +1379,22 @@ describe('ChatView', () => {
|
|
|
})
|
|
|
|
|
|
it('paging button loads older and shows its busy label', () => {
|
|
|
- const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
|
|
+ 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.set({ loadingOlder: true }) })
|
|
|
+ act(() => { h.setSession({ loadingOlder: true }) })
|
|
|
expect(view.getByText('加载中…')).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
it('shows open error and loading states', () => {
|
|
|
- const h = makeHarness({
|
|
|
+ const h = makeHarness({}, {
|
|
|
openState: 'error',
|
|
|
openError: { code: 'internal', message: 'boom' } as never,
|
|
|
})
|
|
|
const view = render(<h.ChatView {...h.props} />)
|
|
|
expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
|
|
|
- const loading = makeHarness({ openState: 'loading' })
|
|
|
+ const loading = makeHarness({}, { openState: 'loading' })
|
|
|
const lv = render(<loading.ChatView {...loading.props} />)
|
|
|
expect(lv.getByText('载入历史…')).toBeTruthy()
|
|
|
})
|
|
|
@@ -1388,7 +1449,7 @@ describe('ChatView', () => {
|
|
|
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
|
|
|
|
|
act(() => {
|
|
|
- h.set({
|
|
|
+ h.setChat({
|
|
|
nodes: [{
|
|
|
...running,
|
|
|
outcome: {
|