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.
This commit is contained in:
imccyu committed 2026-10-12 23:21:31 +08:00
1 parent 533299e6ab
commit 5dbc298ed1
17 files changed
+74 -83

No files matched your search

@@ -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))
@@ -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
@@ -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
}
@@ -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<Record<ChatGroupTree, number>> = { exchange: 0, turn: 1, process: 2 }
const DEPTH: Readonly<Record<ChatGroupTree, number>> = { exchange: 0, input: 1, turn: 2, process: 3 }
/**
* Test whether a Node stands at a group-tree position or outside it.
@@ -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<string> = new Set(
TURN_PROCESS_INDEPENDENT_KIND_LIST,
)
/**
* Compare immutable Turn-process specifications by their published fields.
* @param left - previous specification.
@@ -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. |
@@ -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<ChatConversa
|| previous.kind !== node.kind
|| previous.anchorSeq !== node.anchorSeq
|| previous.visibility !== node.visibility
|| previous.groupTree !== node.groupTree
|| locationIdentity(previous.location) !== locationIdentity(node.location)
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
@@ -118,14 +118,14 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
// 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 },
)
},
}
@@ -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<ChatConversationViewNode>
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<string> {
const ids = new Set<string>()
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<RenderEntry>((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)
@@ -84,7 +84,7 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
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' })
},
}
@@ -91,7 +91,7 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
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' })
},
}
@@ -69,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensSt
turn: state.turn,
step: lastStep(context),
}
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node, { groupTree: 'turn' })
},
}
@@ -3,7 +3,7 @@ import type { ChatNode } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { isAtOrOutside } from '../contract/group-tree.ts'
function nodeTurn(node: ChatNode | undefined): number | undefined {
const location = node?.location
@@ -41,7 +41,7 @@ function derivePresentation(
let openingHumanAnchor: number | undefined
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if ((node?.kind === 'user' || node?.kind === 'steering' || node?.kind === 'turn-trigger')
if (node !== undefined && isAtOrOutside(node, 'input')
&& (spec.controlAnchorSeq === location.turn.start?.seq || node.anchorSeq < spec.controlAnchorSeq)) {
openingHumanAnchor = Math.max(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq)
}
@@ -53,12 +53,12 @@ function derivePresentation(
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue
if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger')
if (isAtOrOutside(node, 'input')
&& (openingHumanAnchor === undefined || node.anchorSeq > 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) {
@@ -296,6 +296,7 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
'turn-process',
data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl,
data,
{ groupTree: 'turn' },
)
},
}
@@ -135,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
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' })
},
}
@@ -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<Conversation
}
}
/** Positions the production Definitions declare for the legacy kinds this fixture projects. */
function fixtureGroupTree(kind: string, inTurn: boolean): ChatGroupTree | undefined {
switch (kind) {
// A fixture user message inside a Turn stands for its opening human input.
case 'user': return inTurn ? 'exchange' : 'input'
case 'steering': return 'input'
case 'turn-error':
case 'turn-max-tokens': return 'turn'
case 'model-retry': return 'process'
default: return undefined
}
}
function assistantData(node: AssistantMessageNode): FinalAssistantChatData {
return {
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
@@ -272,8 +285,7 @@ function settledNode(
? { kind: 'session' as const }
: { kind: 'turn' as const, turn },
visibility: 'visible' as const,
// A fixture user message inside a Turn stands for its opening human input.
groupTree: node.kind === 'user' && turn !== undefined ? 'exchange' as const : undefined,
groupTree: fixtureGroupTree(node.kind, turn !== undefined),
}
switch (node.kind) {
case 'assistant':
@@ -426,7 +438,7 @@ export function chatSnapshotFixture(input: {
const turn = turns.get(turnNumber)
const controlAnchorSeq = controlAnchor?.anchorSeq ?? turn?.start?.seq
if (controlAnchorSeq === undefined) continue
const processStart = inTurn.find(candidate => !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,
})
}
@@ -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<QuestionReplyState> = {
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')