From 5dbc298ed1bd728bf2f7ec93f9dfe7e096f85eae Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:46:07 +0800 Subject: [PATCH] refactor(ui-chat): place Chat rows by group-tree position Chat grouping, whole-Turn folding, and Turn-input detection read each Node's declared `groupTree` position instead of the `INDEPENDENT` and `TURN_PROCESS_INDEPENDENT_KINDS` kind lists and the user/steering/ turn-trigger checks. `ChatGroupTree` gains an `input` position between `exchange` and `turn`; the built-in Definitions declare the positions their kinds had, so built-in rows keep their placement. The late question reply claims its message on the Chat target, so the generic message Definition no longer projects a duplicate trigger and grouping drops the message-id aggregation. Its placement is unchanged. --- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 5 +-- .../ui-chat/src/client/chat/ChatView.tsx | 4 +-- .../ui-chat/src/client/contract/chat-nodes.ts | 8 ++--- .../ui-chat/src/client/contract/group-tree.ts | 21 ++++++++----- .../src/client/contract/turn-process.ts | 16 ---------- .../src/client/conversation-nodes/README.md | 9 +++--- .../chat-snapshot-builder.ts | 9 +++--- .../src/client/conversation-nodes/message.ts | 6 ++-- .../conversation-nodes/process-groups.ts | 31 ++++--------------- .../src/client/conversation-nodes/retry.ts | 2 +- .../client/conversation-nodes/turn-error.ts | 2 +- .../conversation-nodes/turn-max-tokens.ts | 2 +- .../turn-process-presentation.ts | 8 ++--- .../client/conversation-nodes/turn-process.ts | 1 + .../client/conversation-nodes/turn-tail.ts | 2 +- .../tests/chat-snapshot-fixture.client.ts | 24 +++++++++++--- .../src/client/question-reply.ts | 7 +++-- 17 files changed, 74 insertions(+), 83 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 9b076678a3..9721b4a0a8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -5,7 +5,8 @@ import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@de import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatFlowSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatNodeStore } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' +import { turnProcessAlwaysOpen } from '../contract/turn-process.ts' import { turnProcessOpen } from '../stores.ts' import { hiddenOrCollapsing } from './flow-motion.ts' import css from './ChatView.module.css' @@ -76,7 +77,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ && (processPresentation.turnStarted || processPresentation.turnClosed) const processMember = routedNode !== undefined && processWindowReady - && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) + && !isAtOrOutside(routedNode, 'turn') && routedNode.anchorSeq >= processSpec.processStartSeq && (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq || (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep)) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3b528dc47a..e346368a9e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -10,6 +10,7 @@ import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/ import { Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' +import { isAtOrOutside } from '../contract/group-tree.ts' import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { TurnNavigator } from './TurnNavigator.tsx' @@ -37,9 +38,8 @@ function observedInputs( let lastInputTurn: number | undefined for (const key of order) { const node = nodes.get(key) - if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) continue + if (node === undefined || !isAtOrOutside(node, 'input')) continue if (node.location.kind === 'turn' || node.location.kind === 'step') lastInputTurn = node.location.turn.turn - if (node.kind === 'turn-trigger') continue const source = (node.data as { readonly source?: unknown }).source as | { readonly kind?: unknown; readonly rpcId?: unknown } | undefined diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index c1cb6fad37..ea65a14834 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -12,10 +12,10 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' /** - * Group-tree position, visible or hidden. An `exchange` Node's Turn and every - * later Turn before the next opening Turn form one exchange; the latest - * loaded exchange starts at the last loaded opening Turn, or at the first - * loaded Turn when none is loaded. + * Group-tree position, visible or hidden; absence joins the surrounding step + * run. An `exchange` Node's Turn and every later Turn before the next opening + * Turn form one exchange; the latest loaded exchange starts at the last loaded + * opening Turn, or at the first loaded Turn when none is loaded. */ readonly groupTree?: ChatGroupTree | undefined } diff --git a/packages/client/ui-chat/src/client/contract/group-tree.ts b/packages/client/ui-chat/src/client/contract/group-tree.ts index efe1dabe3a..d9ceed6073 100644 --- a/packages/client/ui-chat/src/client/contract/group-tree.ts +++ b/packages/client/ui-chat/src/client/contract/group-tree.ts @@ -1,20 +1,27 @@ import type { ChatConversationViewNode } from './chat-nodes.ts' /** - * Chat group-tree positions from the outermost grouping inward; a Node at a - * position also stands outside every inner grouping. + * Chat group-tree positions from the outermost inward; a Node at a position + * also has the effect of every inner position. * - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts * until the next opening Turn. + * - `input`: the Node is an input of its Turn. An input before the Turn's + * process opens the Turn; a later one is interleaved and keeps the whole + * Turn open. * - `turn`: the Node stays in its Turn outside the Turn's process disclosure. * - `process`: the Node stays in the process disclosure outside step runs. + * - absent: the Node joins the step run around it. * - * Only `exchange` is read today; process disclosure and step runs still select - * independent Nodes by kind (`TURN_PROCESS_INDEPENDENT_KINDS`, the process-group - * `INDEPENDENT` set), so no Definition publishes `turn` or `process` yet. + * The Definition that builds a Node chooses its position, and step runs, the + * process disclosure, and Turn inputs are selected by position. Two rows are + * placed without one: an `assistant-step` Node splits into a reasoning row in + * the step run and a reply row outside it, and the Turn's process + * specification keeps the finalized answer's reply outside the disclosure, + * because which step answers changes as later steps arrive. */ -export type ChatGroupTree = 'exchange' | 'turn' | 'process' +export type ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process' -const DEPTH: Readonly> = { exchange: 0, turn: 1, process: 2 } +const DEPTH: Readonly> = { exchange: 0, input: 1, turn: 2, process: 3 } /** * Test whether a Node stands at a group-tree position or outside it. diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index 8b7d89f64e..63c0e56af6 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -17,22 +17,6 @@ export interface TurnProcessSpec { readonly subagentCount: number } -const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ - 'system-prompt', - 'user', - 'steering', - 'turn-trigger', - 'turn-process', - 'turn-error', - 'turn-max-tokens', - 'turn-tail', -] as const satisfies readonly ChatNode['kind'][] - -/** Chat Node kinds that remain independent of a Turn's process disclosure. */ -export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet = new Set( - TURN_PROCESS_INDEPENDENT_KIND_LIST, -) - /** * Compare immutable Turn-process specifications by their published fields. * @param left - previous specification. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index a15f07a6a7..bda19aba52 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -94,7 +94,7 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs` | Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. | | A Turn control exists, but there is no process content | Retain its title, without a collapse action. | -The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. User and steering inputs, trigger notices, terminal errors, max-token notices, and the completed-turn footer remain independent. Secondary grouping treats model retries as separators, but whole-Turn folding still includes retry rows. +The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it. Loading an older page preserves the reader's group-opening choices. Newly loaded process content follows the same Turn state while the final answer is unchanged and whole-Turn folding remains eligible. If the page reveals an intervening input, individual group disclosures replace whole-Turn hiding. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group. @@ -198,15 +198,14 @@ Switching modes retains manually opened groups and inner disclosures. It changes A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed. -When `question-reply` and `turn-trigger` project the same message id, grouping references only the question reply. Both Nodes stay in the Node Store; the duplicate trigger neither renders nor splits a group. The Turn control precedes the group containing an opening reply. This aggregation also applies before the owning Turn is loaded. +Grouping reads each Node's [group-tree position](../contract/group-tree.ts), which its Definition sets, rather than its kind. | Input | Membership and segmentation | |---|---| | Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. | | Assistant reply | End the preceding group and emit an independent `response` reference. If the same Node has reasoning, append that reasoning before ending the group. | -| `user`, `steering`, `turn-trigger`, `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail` | End the preceding group and retain the Node as an independent root. | -| `turn-process` | Retain the control as an independent root without ending the current group. | -| Other visible Nodes owned by a Turn, including tools | Append the whole Node to the current group, creating a group if necessary. | +| Node at `process` or an outer position: `model-retry`, Turn inputs, `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control | End the preceding group and retain the Node as an independent root. | +| Other visible Nodes owned by a Turn without a position, including tools | Append the whole Node to the current group, creating a group if necessary. | | Node from another Turn or without a Turn | Break the same-Turn sequence. A Node without a Turn remains independent, including an unsplit Assistant. | | Hidden Nodes, system prompts, ordinary Context injection, and `permission` commands | Excluded from the grouping input; they neither join nor split a group. | diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index dad0b6e51e..933ae8f760 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -11,7 +11,7 @@ import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' import { locationCoordinates } from './common.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' import { GroupTreeSegments } from './group-tree-segments.ts' @@ -457,7 +457,7 @@ function turnProcessPresentations( if (location.kind !== 'turn' && location.kind !== 'step') continue const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} const controlAnchor = current.control?.data.controlAnchorSeq - if ((node.kind === 'user' || node.kind === 'turn-trigger' || node.kind === 'steering') + if (isAtOrOutside(node, 'input') && controlAnchor !== undefined && (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor)) { presentations.set(location.turn.turn, { @@ -466,7 +466,7 @@ function turnProcessPresentations( }) continue } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue + if (isAtOrOutside(node, 'turn')) continue presentations.set(location.turn.turn, { ...current, earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq), @@ -497,7 +497,7 @@ function presentationPosition( const openingInputAnchor = presentation.openingInputAnchor if (openingInputAnchor !== undefined && node.anchorSeq < openingInputAnchor - && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) { + && !isAtOrOutside(node, 'turn')) { return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq } } if (presentation.control !== undefined && node.key === presentation.control.key) { @@ -1148,6 +1148,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder = { // Human input claimed for Step 1 opens an exchange; Notice-woken Turns and // later steering stay in the current exchange, so they never fold earlier Turns. const location = contextLocation(context) - const opensExchange = (context.state.kind === 'user' || context.state.kind === 'steering') - && location.kind === 'step' && location.step.step === 1 + const human = context.state.kind === 'user' || context.state.kind === 'steering' + const opensExchange = human && location.kind === 'step' && location.step.step === 1 return chatNode( context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state, - { location, groupTree: opensExchange ? 'exchange' : undefined }, + { location, groupTree: opensExchange ? 'exchange' : (human || waking) ? 'input' : undefined }, ) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts index 09edc21006..201569449f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts @@ -8,9 +8,9 @@ import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes. import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import { isVisibleChatNode } from '../contract/chat-visibility.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' import { processActivity } from './process-activity.ts' -const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail']) type ProcessInput = ConversationGroupInput function turnOf(node: ChatNode): number | undefined { @@ -44,6 +44,7 @@ function structureChanged(previous: ChatNode | undefined, current: ChatNode): bo return previous === undefined || previous.kind !== current.kind || turnOf(previous) !== turnOf(current) || isVisibleChatNode(previous) !== isVisibleChatNode(current) + || previous.groupTree !== current.groupTree || reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current) } @@ -53,15 +54,6 @@ function readNode(input: ProcessInput, key: NodeKey): ChatNode { return node } -function questionReplyIds(input: ProcessInput, keys: readonly NodeKey[]): ReadonlySet { - const ids = new Set() - for (const key of keys) { - const node: ChatConversationViewNode = readNode(input, key) - if (node.kind === 'question-reply') ids.add(node.id) - } - return ids -} - /** One group's members and cached summary, refreshed together when its content changes. */ class ProcessGroup { private nodes: readonly ChatNode[] = [] @@ -152,23 +144,17 @@ class TurnGroups { } let previous: NodeKey | undefined let followed = false - const keys = input.readTurn(this.turn) - const replies = questionReplyIds(input, keys) - for (const key of keys) { + for (const key of input.readTurn(this.turn)) { const position = readPosition(input, key) if (previous !== undefined && position.previous !== previous) flush(true) previous = key followed = position.next !== undefined const node = readNode(input, key) - // Both Definitions retain the same message id; only its question presentation renders. - if (node.kind === 'turn-trigger' && replies.has(node.id)) continue - if (INDEPENDENT.has(node.kind)) { + if (isAtOrOutside(node, 'process')) { flush(true) emit(key, { kind: 'node', key }) - } else if (node.kind === 'turn-process') { - // A coalesced opening reply can sort before the control that folds its group. - emit(pending[0]?.key ?? key, { kind: 'node', key }) } else if (node.kind === 'assistant-step') { + // One Node, two rows: reasoning joins the step run, a reply stands outside it. if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' }) if (reply(node)) { flush(true) @@ -291,14 +277,9 @@ export class ProcessState { } private rootEntries(input: ProcessInput): RenderEntry[] { - const unscoped = input.order.filter(key => readPosition(input, key).turn === undefined) - const replies = questionReplyIds(input, unscoped) return input.order.flatMap((key) => { const turn = readPosition(input, key).turn - if (turn === undefined) { - const node = readNode(input, key) - return node.kind === 'turn-trigger' && replies.has(node.id) ? [] : [{ kind: 'node', key }] - } + if (turn === undefined) return [{ kind: 'node', key }] const groups = this.turns.get(turn) if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`) return groups.references(key) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index d721821f22..644690f789 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -84,7 +84,7 @@ export const retryDefinition: ConversationNodeDefinition = { const current = attempts.at(-1) if (current === undefined) return null const data: RetryChatData = { attempts, current } - return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data) + return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data, { groupTree: 'process' }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index 445d073639..1f8f190fc5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -91,7 +91,7 @@ export const turnErrorDefinition: ConversationNodeDefinition = { message: failure.message, ...failure.code === undefined ? {} : { code: failure.code }, } - return chatNode(context, 'turn-error', node.seq, node) + return chatNode(context, 'turn-error', node.seq, node, { groupTree: 'turn' }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 746d00bc95..03127b85ae 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -69,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition openingHumanAnchor)) { hasInterleavedInput = true if (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq) compactAnswer = false } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + if (isAtOrOutside(node, 'turn') || node.anchorSeq < spec.processStartSeq || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index f151676c6b..b38b937fea 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -296,6 +296,7 @@ export const turnProcessDefinition: ConversationNodeDefinition 'turn-process', data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl, data, + { groupTree: 'turn' }, ) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index c1bfdd0c4d..4ca63d5375 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -135,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') - return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data) + return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data, { groupTree: 'turn' }) }, } diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index ad4d4357a4..780d2e1f49 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -16,10 +16,10 @@ import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-s import { ChatTurnProcessProjector } from '../src/client/conversation-nodes/turn-process-presentation.ts' import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' import { - isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS, + isSubagentDelegationTool, sameTurnProcessSpec, type TurnProcessSpec, } from '../src/client/contract/turn-process.ts' -import { isAtOrOutside } from '../src/client/contract/group-tree.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' const EMPTY: readonly never[] = [] @@ -245,6 +245,19 @@ class FixtureTurnDataStore implements ConversationLocationDataStore !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind)) + const processStart = inTurn.find(candidate => !isAtOrOutside(candidate, 'turn')) ?? controlAnchor const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true const candidate: TurnProcessSpec = { @@ -463,6 +475,7 @@ export function chatSnapshotFixture(input: { anchorSeq: spec.controlAnchorSeq - 0.1, location: { kind: 'turn', turn }, visibility: 'visible', + groupTree: 'turn', data: spec, }) } @@ -503,6 +516,7 @@ export function chatSnapshotFixture(input: { anchorSeq: endSeq, location: { kind: 'turn', turn }, visibility: 'visible', + groupTree: 'turn', data: tailData, }) } diff --git a/packages/client/ui-user-questions/src/client/question-reply.ts b/packages/client/ui-user-questions/src/client/question-reply.ts index 2d9a9f2245..68973b32df 100644 --- a/packages/client/ui-user-questions/src/client/question-reply.ts +++ b/packages/client/ui-user-questions/src/client/question-reply.ts @@ -142,13 +142,16 @@ function replySource( : null } -/** Late-reply projection owned by the questions UI. */ +/** + * Late-reply projection owned by the questions UI. It claims the reply message + * on the Chat target, so the generic message Definition never presents it. + */ export const questionReplyDefinition: ConversationNodeDefinition = { kind: 'user-question-reply', target: 'chat', match: (event) => { if (event.type !== 'user/message' || !isAppendSurfaceEvent(event)) return null - return replySource(event) === null ? null : { id: String(event.data.id), role: 'start' } + return replySource(event) === null ? null : { id: String(event.data.id), role: 'start', exclusive: true } }, start: (_context, match) => { if (match.event.type !== 'user/message') throw new Error('user-question-reply start requires user/message')