fix(web): restrict message forks to completed turn tails

This commit is contained in:
kingwl
2026-08-02 16:25:02 +08:00
parent ab320ba991
commit 76547dfe0c
39 changed files with 283 additions and 73 deletions
@@ -36,12 +36,14 @@ describe('MessageItem arms', () => {
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
}}
onFork={onFork}
/>,
)
expect(screen.getByText('14:24')).toBeTruthy()
@@ -50,6 +52,8 @@ describe('MessageItem arms', () => {
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(1)
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
@@ -410,12 +414,15 @@ describe('small branch tails', () => {
})
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
seq={3}
onFork={onFork}
/>,
)
expect(settled.getByText('14:24')).toBeTruthy()
@@ -423,6 +430,8 @@ describe('small branch tails', () => {
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
fireEvent.click(settled.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('answer body')
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(3)
settled.unmount()
const thinkOnly = render(
@@ -67,7 +67,7 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -20,7 +20,7 @@ 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 { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -33,7 +33,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -211,6 +211,24 @@ describe('chat-flow derivation', () => {
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const nodes = [
user(1, 'first'),
assistant(2, 'answer before tools'),
toolResult(3, 'a'),
interruptedThink,
user(6, 'second'),
assistant(7, 'clean tail', 2),
user(10, 'user-only tail'),
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11]]))
expect([...seqs]).toEqual([7, 10])
})
})
describe('ChatView', () => {
@@ -323,21 +341,38 @@ describe('ChatView', () => {
user(5, 'next'),
assistant(6, 'second turn', 2),
],
turnEnds: new Map([[1, 4], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
// User rows keep copy/clock, while only the two completed assistant tails may branch.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2)
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
it('forks only from a 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} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons).toHaveLength(1)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
expect(h.forkAt.mock.calls).toEqual([[2]])
})
it('keeps copy chrome but hides branch when tool and interrupted Think follow the response', () => {
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)
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
@@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -23,7 +23,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -28,7 +28,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
@@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -455,7 +455,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
@@ -232,7 +232,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,