refactor(ui-conversation): register business node definitions

This commit is contained in:
imccyu
2026-08-09 15:48:40 +08:00
parent 81d6886006
commit 6d09b3168d
41 changed files with 2744 additions and 745 deletions

View File

@@ -44,6 +44,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",
@@ -53,6 +54,7 @@
"@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^",

View File

@@ -7,8 +7,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts' import type { ViewTab } from './contract/views.ts'
import type { import type {
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected,
DetailsInjected,
} from './contract/slots.ts' } from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts' import type { InputNotice } from './input/contract.ts'
import { createChatStore } from './stores.ts' import { createChatStore } from './stores.ts'
@@ -30,6 +31,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts' import { en, NS, zh, type ConversationKey } from './locales.ts'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { interface LocaleNamespaceMap {
@@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
} }
/** Services required by the conversation plugin. */ /** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale',
'conversationEvents', 'conversationViews',
]
// Static no-session sources for the composer-bar hooks compartment: module // Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps // constants so the render side's per-source hook cache (observableHook) keeps
@@ -63,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = {
subscribe: () => () => {}, subscribe: () => () => {},
} }
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
hooks: {
turnData: ({ useSession }, nodeKey) => function useTurnData(key) {
return useSession((snapshot) => {
const location = snapshot.chat.nodes.get(nodeKey)?.location
return location?.kind === 'turn' || location?.kind === 'step'
? location.turn.data.get(key)
: undefined
})
},
},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation { function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id) const scoped = sessions.scope(id)
@@ -86,6 +105,9 @@ export function apply(ctx: Context): void {
const layout = ctx.layout const layout = ctx.layout
const slots = ctx.slots const slots = ctx.slots
registerConversationNodes(ctx)
registerChatNodeRenderers(ctx)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
// Registration-time text (the view tab label) reads through the bound // Registration-time text (the view tab label) reads through the bound
@@ -296,8 +318,8 @@ export function apply(ctx: Context): void {
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared. // The chat view: first entry of the ring this package just declared.
// ChatView owns ordered Tool placement but delegates each whole root call // ChatView owns only the stable ordered Node list. Business renderers are
// to ui-tool, which owns root/subcall composition and atomic dispatch. // independently keyed behind its one Node seat.
slots.register({ slots.register({
name: 'conversation.view', name: 'conversation.view',
id: 'chat', id: 'chat',
@@ -305,9 +327,7 @@ export function apply(ctx: Context): void {
label: () => t('view.chat'), label: () => t('view.chat'),
locale: NS, locale: NS,
children: { children: {
'conversation.chat.tool': { kind: 'single', scope: 'session' }, 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
}, },
store: chatStore, store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => { inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {

View File

@@ -11,12 +11,9 @@
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' import type { ChatViewSlotProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ReasoningRow } from './ReasoningRow.tsx' import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css' import css from './AssistantMarkdown.module.css'
@@ -25,61 +22,19 @@ export interface AssistantMarkdownProps {
streaming: boolean streaming: boolean
/** Frozen partial of an aborted turn: rendered with a stopped marker. */ /** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined interrupted?: boolean | undefined
/** Unix epoch ms for the IconActions clock; omitted while streaming or when /** Resolved prose file mentions for this Assistant's closing turn. */
* the parent withholds chrome (mid-turn content assistants and every node mentions?: MarkdownFileMentions | undefined
* of a turn that has not ended). */
time?: number | undefined
/** Turn wall time in ms for the IconActions run-time label; omitted when the
* turn's triggering input is outside the loaded window. */
runMs?: number | undefined
/** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput for the IconActions label; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
onFork?: ((seq: number) => void) | undefined
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
/** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */
fileMentions?: ChatViewInjected['fileMentions'] | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */ /** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t'] t: ChatViewSlotProps['t']
} }
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (block.kind === 'text') parts.push(block.text)
}
return parts.join('')
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */ /** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({ export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, blocks, streaming, interrupted, mentions, t,
fileMentions, t,
}: AssistantMarkdownProps) { }: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object // Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk. // per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
// Mention vocabulary for the closing prose. Keyed on the anchor seq, not the
// growing transcript: a settled turn's produced files are final, and a
// fresh identity per append would discard MarkdownText's cached parse for
// every settled closing message on every stream chunk. The window-prepend
// edge (a mid-turn window start later gaining earlier same-turn writes)
// leaves a mention unlinked until remount — never a wrong link.
const owner = turnTail?.owner
const mentions: MarkdownFileMentions | undefined = useMemo(
() => (owner === undefined ? undefined : fileMentions?.(owner)),
// Deliberately not `owner`: its identity changes per append while the
// seq-addressed vocabulary it yields does not.
[fileMentions, owner?.seq],
)
const last = blocks.length - 1 const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so // Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root // a node that is only those heads (or empty) would paint an empty root
@@ -88,10 +43,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true || interrupted === true
|| blocks.some(block => block.kind !== 'tool-call') || blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null if (!hasVisible) return null
// Footer only under settled content text; Think-only / streaming omit it.
const showActions = !streaming && time !== undefined && hasContentText(blocks)
return ( return (
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root> <div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}> <div className={css.body}>
{blocks.map((block, i) => { {blocks.map((block, i) => {
switch (block.kind) { switch (block.kind) {
@@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
})} })}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>} {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div> </div>
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
{showActions && (
<MessageIconActions
text={copyText(blocks)}
time={time}
runMs={runMs}
ttftMs={ttftMs}
tokensPerSecond={tokensPerSecond}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>
)}
</div> </div>
) )
}) })

View File

@@ -0,0 +1,32 @@
import { memo, useMemo } from 'react'
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
export const AssistantNodeView = memo(function AssistantNodeView({
node, useTurnData, openFile, fileMentions, t,
}: ChatNodeViewProps<'assistant-step'>) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
const tail = useTurnData('turn-tail')
const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
return { turn, seq: data.finalNode.seq, openFile }
}, [data.finalNode, openFile, tail, turn])
const mentions = useMemo(
() => owner === undefined ? undefined : fileMentions(owner),
[fileMentions, owner],
)
return (
<AssistantMarkdown
blocks={data.blocks}
streaming={data.status === 'running'}
interrupted={data.status === 'interrupted'}
mentions={mentions}
t={t}
/>
)
})

View File

@@ -0,0 +1,60 @@
import { memo, useMemo } from 'react'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import css from './ChatView.module.css'
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
readonly nodeKey: string
readonly useSession: ChatViewSlotProps['useSession']
readonly renderSlot: ChatViewSlotProps['renderSlot']
readonly t: ChatViewSlotProps['t']
}
type RoutedChatNodeOwner = {
[Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
}[ChatNode['kind']]
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
fileMentions, useSession, renderSlot, t,
}: ChatNodeSeatProps) {
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
const routedNode = node as ChatNode | undefined
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
? null
: {
selectedCallId,
cwd,
openFile,
inspectCall,
forkAt,
fileMentions,
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions])
if (routedNode === undefined || owner === null) return null
// Runtime dispatch owns the correlation: every Node's discriminant is the
// keyed-slot entry passed alongside that same Node. TypeScript does not
// distribute an object containing a union into a union of objects itself.
const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
return (
<div
className={css.flowItem}
data-chat-anchor-key={routedNode.key}
data-chat-flow-key={routedNode.key}
data-chat-flow-kind={routedNode.kind}
>
{renderSlot('conversation.chat.node', routedOwner, {
entryKey: routedNode.kind,
hookContext: nodeKey,
fallback: (
<JsonBlock
label={t('message.unknownSurface', { type: routedNode.kind })}
payload={routedNode.data}
truncatedLabel={total => t('json.truncated', { total })}
/>
),
})}
</div>
)
})

View File

@@ -1,6 +1,5 @@
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool /* Chat flow: one 16px rhythm everywhere through the column gap. Input
runs) via the column gap and between consecutive tool rows via the group padding cap rides the skeleton. Under
gap. Input padding cap rides the skeleton. Under
`[data-conversation-scroll]` the column host owns overflow and this view `[data-conversation-scroll]` the column host owns overflow and this view
is ordinary flow (see ConversationRoot active-phase rules). */ is ordinary flow (see ConversationRoot active-phase rules). */
@@ -51,10 +50,11 @@
min-width: 0; min-width: 0;
} }
.toolGroup { /* A keyed renderer may intentionally decline its row after dispatch (the
display: flex; completed-turn tail does this when it owns neither actions nor extensions).
flex-direction: column; An empty flex item must not consume the column gap. */
gap: 16px; .flowItem:empty {
display: none;
} }
.callRow { .callRow {

View File

@@ -1,41 +1,24 @@
// ChatView: the default conversation view — message flow with user bubbles, // ChatView: the default conversation view — one stable keyed parent list over
// assistant narration, tool summary rows grouped into step runs, pending // final business Nodes, plus paging, pending steering and bottom-follow.
// cards, paging, and bottom-follow. Session stats live on // Each row dispatches through 'conversation.chat.node'; ui-tool owns the
// 'conversation.composer.dock' (sticky with the composer). Pure component // tool-call renderer and its recursive root/subcall composition.
// registered directly; its registration declares the whole-Tool
// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and
// keyed per-tool dispatch behind that boundary.
// //
// Scroll: when nested under `[data-conversation-scroll]` (active conversation // Scroll: when nested under `[data-conversation-scroll]` (active conversation
// column), that host is the scrollport and this view is flow content; when // column), that host is the scrollport and this view is flow content; when
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and // mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
// prepend anchoring always target the resolved scrollport. // prepend anchoring always target the resolved scrollport.
// //
// Render economics (architecture RFC performance model): the list parent // Render economics: order changes only when rows enter, leave or move. Each
// subscribes to snapshot segments that do NOT change per streaming chunk // ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool
// (nodes/runningCalls/pending keep their references across chunk batches), so // lifecycle updates replace only their own row without remounting it.
// during a token storm only StreamingTail re-renders; history rows hold via
// memo on cache-stable node slices. Selection changes re-render the parent
// map but only rows whose own selected bit flipped. renderSlot is
// entry-identity-stable (framework binding cache), so passing it through
// memoized rows never churns them.
import { import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
} from 'react'
import type {
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts' import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { PendingSteeringBubble } from './MessageItem.tsx'
import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts' import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
import css from './ChatView.module.css' import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24 const FOLLOW_THRESHOLD = 24
@@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
return visibleRows[0] ?? rows[0] ?? null return visibleRows[0] ?? rows[0] ?? null
} }
type OpenFile = (path: string) => void
type InspectCall = (callId: string) => void
/** Declared child-slot render share (stable framework binding). */
type RenderChatSlot = ChatViewSlotProps['renderSlot']
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>> type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean {
return callId !== undefined
&& (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId)))
}
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index]
if (node === undefined) continue
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
if (node.kind === 'assistant' || node.kind === 'user') return null
}
return null
}
/** Capture a reflow-resistant reader position from the current rendered window. */ /** Capture a reflow-resistant reader position from the current rendered window. */
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
const row = pagingAnchor(list, scrollport) const row = pagingAnchor(list, scrollport)
@@ -139,77 +95,13 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP
} }
} }
/** One ordered root Tool call handed intact to the Tool presentation plugin. */ function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
const ToolSeat = memo(function ToolSeat({ let latest: number | null = null
renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall, for (const turn of timeline.turns.values()) {
}: { if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time
renderSlot: RenderChatSlot }
callId: string return latest
toolName: string }
block: ToolResultNode | RunningToolCall
openFile: OpenFile
selectedCallId?: string | undefined
cwd: string | undefined
inspectCall: InspectCall
}) {
const owner = useMemo(() => ({
callId, toolName, block, selectedCallId, cwd, openFile, inspectCall,
}), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall])
return renderSlot('conversation.chat.tool', owner)
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: {
renderSlot: RenderChatSlot
results: readonly ToolResultNode[]
openFile: OpenFile
/** Tool ownership resolves whether the selection is this root or one of its children. */
selectedCallId: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
}) {
return (
<div className={css.toolGroup}>
{results.map(node => (
<ToolSeat
key={node.callId}
renderSlot={renderSlot}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
openFile={openFile}
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
)
})
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
renderSlot: RenderChatSlot
node: CommandNode
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
const fallback = node.name === 'compact'
? <CompactionCommandCard {...owner} t={t} />
: <GenericCommandCard {...owner} t={t} />
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback,
})}
</div>
)
})
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */ /** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus({ startTime, t }: { function TurnStatus({ startTime, t }: {
@@ -247,52 +139,32 @@ function TurnStatus({ startTime, t }: {
) )
} }
/** The streaming partial, isolated so chunk batches re-render only this tail;
* the column ResizeObserver owns bottom-follow when its box grows. */
function StreamingTail({ useSession, t }: {
useSession: UseConversation
t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
/** /**
* The chat view slot entry: pure component over the composed props; each * The chat view slot entry: pure component over the composed props; each
* ordered root Tool call crosses the declared whole-Tool render seat. * ordered business Node crosses the keyed renderer seat.
*/ */
export function ChatView({ export function ChatView({
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
fileMentions, t, fileMentions, t,
}: ChatViewSlotProps) { }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes) const order = useSession(s => s.chat.order)
const turnTimings = useSession(s => s.turnTimings) const nodeStore = useSession(s => s.chat.nodes)
const turnEnds = useSession(s => s.turnEnds) const timeline = useSession(s => s.chat.timeline)
const inbox = useSession(s => s.queue) const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it. // Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd) const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running) const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
const openState = useSession(s => s.openState) const openState = useSession(s => s.openState)
const openError = useSession(s => s.openError) const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore) const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder) const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId) const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const pendingSteering = useMemo( const pendingSteering = useMemo(
() => inbox.filter(item => item.placement === 'steering'), () => inbox.filter(item => item.placement === 'steering'),
[inbox], [inbox],
) )
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
// Only the last content assistant of each completed turn owns IconActions;
// mid-turn text and every node of a running turn omit `time`, so
// AssistantMarkdown stays chrome-free until the answer settles.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds])
const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null) const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null) const columnRef = useRef<HTMLDivElement | null>(null)
@@ -312,11 +184,12 @@ export function ChatView({
* scrolls the rest of the way to the floor). */ * scrolls the rest of the way to the floor). */
const followSigRef = useRef<string | null>(null) const followSigRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null const firstKey = order[0]
const lastItem = items[items.length - 1] const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
const lastKey = lastItem?.key ?? null const lastKey = order.at(-1) ?? null
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}`
const toBottom = (el: HTMLElement): void => { const toBottom = (el: HTMLElement): void => {
anchorRef.current = null anchorRef.current = null
@@ -377,8 +250,7 @@ export function ChatView({
firstSeqRef.current = firstSeq firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls // Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there). // (send lives in the composer, so arrival is detected here, not armed there).
const appendedUser = lastKey !== lastKeyRef.current const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const tipMoved = followSigRef.current !== followSig const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey lastKeyRef.current = lastKey
@@ -490,71 +362,6 @@ export function ChatView({
loadOlder() loadOlder()
} }
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
return (
<ToolGroup
renderSlot={renderSlot}
results={item.results}
openFile={openFile}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
/>
)
}
if (item.kind === 'command-compaction') {
return (
<CommandRow
renderSlot={renderSlot}
node={item.command}
compaction={item.compaction}
t={t}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
// Metrics gate on the settled in-window timing: turn/start loaded means
// every step of the turn is loaded, so first-step TTFT is genuine.
const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn)
return (
<AssistantMarkdown
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={actionSeqs.has(node.seq) ? node.time : undefined}
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
ttftMs={metrics?.ttftMs}
tokensPerSecond={metrics?.tokensPerSecond}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
turnTail={actionSeqs.has(node.seq)
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
: undefined}
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
t={t}
/>
)
}
if (node.kind === 'command') {
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (
<MessageItem
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
t={t}
/>
)
}
return ( return (
<div className={css.root}> <div className={css.root}>
<div ref={listRef} className={css.scroll}> <div ref={listRef} className={css.scroll}>
@@ -572,43 +379,21 @@ export function ChatView({
</button> </button>
</div> </div>
)} )}
{items.map(item => ( {order.map(nodeKey => (
<div <ChatNodeSeat
key={item.key} key={nodeKey}
className={css.flowItem} nodeKey={nodeKey}
data-chat-anchor-key={item.kind === 'node' useSession={useSession}
? `node:${String(item.node.seq)}` selectedCallId={selectedCallId}
: item.kind === 'command-compaction' cwd={cwd}
? `node:${String(item.compaction.seq)}` openFile={openFile}
: undefined} inspectCall={inspectCall}
data-chat-flow-key={item.key} forkAt={forkAt}
data-chat-flow-kind={item.kind === 'node' fileMentions={fileMentions}
? item.node.kind renderSlot={renderSlot}
: item.kind === 'command-compaction' t={t}
? item.kind />
: 'tool-group'}
>
{renderItem(item)}
</div>
))} ))}
<StreamingTail useSession={useSession} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
<ToolSeat
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
openFile={openFile}
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
)}
{/* No pending placeholders: questions (ui-question) and approvals {/* No pending placeholders: questions (ui-question) and approvals
(ApprovalPanel) both take over the composer, so a flow card would (ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */} double-render the same wait. */}

View File

@@ -0,0 +1,40 @@
import { memo, useMemo } from 'react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type {
ChatNodeViewProps, CommandRowOwnerProps,
} from '../contract/slots.ts'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import css from './ChatView.module.css'
type CommandNodeViewProps = ChatNodeViewProps<'command'> & PropsRenderSlots<'conversation.chat.commandview'>
/** Ordinary command lifecycle renderer with command-name keyed specialization. */
export const CommandNodeView = memo(function CommandNodeView({ node, renderSlot, t }: CommandNodeViewProps) {
const command = node.data
const owner = useMemo<CommandRowOwnerProps>(() => ({ node: command }), [command])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: command.name ?? '',
fallback: <GenericCommandCard {...owner} t={t} />,
})}
</div>
)
})
/** One integrated `/compact` command and compaction transaction renderer. */
export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({
node, t,
}: ChatNodeViewProps<'manual-compaction'>) {
const data = node.data
return (
<div className={css.callRow}>
<CompactionCommandCard
node={data.command}
{...data.compaction === null ? {} : { compaction: data.compaction }}
t={t}
/>
</div>
)
})

View File

@@ -7,30 +7,15 @@
import { memo, useEffect, useMemo, useState } from 'react' import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, ModelRetryNode, TurnErrorNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx' import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx' import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css' import css from './MessageItem.module.css'
export interface MessageItemProps {
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
const texts: string[] = [] const texts: string[] = []
const rest: unknown[] = [] const rest: unknown[] = []
@@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: {
) )
} }
export const MessageItem = memo(function MessageItem({ /** User and admitted-steering keyed Chat renderer. */
node, retryActive = false, t, export const UserMessageNodeView = memo(function UserMessageNodeView({
}: MessageItemProps) { node, t,
const truncated = (total: number): string => t('json.truncated', { total }) }: ChatNodeViewProps<'user' | 'steering'>) {
switch (node.kind) { const data = node.data
case 'user': return (
case 'steering': <UserStyleBubble
return ( content={data.content}
<UserStyleBubble steering={data.kind === 'steering'}
content={node.content} t={t}
steering={node.kind === 'steering'} actions={text => (
t={t} <MessageIconActions
actions={text => ( text={text}
<MessageIconActions time={data.time}
text={text} clock="start"
time={node.time} className={css.actions}
clock="start"
className={css.actions}
t={t}
/>
)}
/>
)
case 'context':
return (
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
t={t} t={t}
/> />
) )}
case 'compaction': />
return <CompactionItem node={node} t={t} /> )
case 'model-retry': })
return <ModelRetryItem node={node} active={retryActive} t={t} />
case 'turn-error': /** Injected-context keyed Chat renderer. */
return <TurnErrorItem node={node} t={t} /> export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
default: const data = node.data
return ( return (
<div className={css.contextRow}> <ContextInjectionRow
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} /> content={data.content}
</div> source={data.source}
) provenance={data.provenance}
} form={data.form}
t={t}
/>
)
})
/** Automatic compaction keyed Chat renderer. */
export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) {
return <CompactionItem node={node.data} t={t} />
})
/** Correlated retry-chain keyed Chat renderer. */
export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) {
const data = node.data
return <ModelRetryItem node={data.current} active={data.current.retryState === 'scheduled'} t={t} />
})
/** Terminal turn-error keyed Chat renderer. */
export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) {
return <TurnErrorItem node={node.data} t={t} />
})
/** Explicit unknown-surface keyed Chat renderer. */
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
const data = node.data
return (
<div className={css.contextRow}>
<JsonBlock
label={t('message.unknownSurface', { type: data.type })}
payload={data.data}
truncatedLabel={total => t('json.truncated', { total })}
/>
</div>
)
}) })

View File

@@ -157,9 +157,9 @@ export interface StatsLineProps {
} }
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
const nodes = useSession(s => s.nodes) const settledNodes = useSession(s => s.chat.legacy.nodes)
const stats = useMemo(() => deriveStats(settledNodes), [settledNodes])
const usage = useProjection('tokenUsage') const usage = useProjection('tokenUsage')
const stats = useMemo(() => deriveStats(nodes), [nodes])
// Pipe-separated groups (figma stats strip); a group with no data drops out whole. // Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [] const groups: string[] = []
if (stats.steps > 0) { if (stats.steps > 0) {

View File

@@ -0,0 +1,9 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.actions {
margin-left: -6px;
}

View File

@@ -0,0 +1,43 @@
import { memo } from 'react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlotChain, t,
}: TurnTailNodeViewProps) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
if (turn === undefined) return null
const closing = data.closing
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
const tail = renderSlotChain('conversation.chat.turnTail', owner)
if (closing === null) return tail === null ? null : <div className={css.root}>{tail}</div>
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
return (
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
{tail}
<MessageIconActions
text={assistantText(closing.blocks)}
time={closing.time}
runMs={runMs}
ttftMs={data.ttftMs}
tokensPerSecond={data.tokensPerSecond}
clock="end"
onBranch={() => { forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable}
className={css.actions}
t={t}
/>
</div>
)
})

View File

@@ -1,214 +0,0 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration. Consecutive retry notices
* reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. IconActions ownership
* and completed-turn branch points are derived here too so ChatView and the
* flow share their gates.
*/
import type {
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
| {
kind: 'command-compaction'
key: string
command: CommandNode
compaction: CompactionSummaryNode
}
/** Match explicit command outcome references to exactly one compaction checkpoint. */
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
} {
const commandsBySource = new Map<number, CommandNode | null>()
for (const node of nodes) {
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
const source = node.outcome.sourceEventSeq
if (source === undefined) continue
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
}
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
for (const node of nodes) {
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
const summary = node.summaryEventSeq
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
}
const byCommandId = new Map<string, CompactionSummaryNode>()
const byCompactionSeq = new Map<number, CommandNode>()
for (const [source, command] of commandsBySource) {
const compaction = compactionsBySummary.get(source)
if (command === null || compaction === undefined || compaction === null) continue
byCommandId.set(command.commandId, compaction)
byCompactionSeq.set(compaction.seq, command)
}
return { byCommandId, byCompactionSeq }
}
/**
* True when the node has model-visible text content worth IconActions chrome.
* Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting
* cannot diverge.
* @param blocks - assistant blocks of one finalized node.
* @returns Whether any text block carries non-blank content.
*/
export function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
* always render (the 已停止 marker). */
function rendersNothing(node: ConversationNode): boolean {
return node.kind === 'assistant' && node.interrupted !== true
&& node.blocks.every(b => b.kind === 'tool-call'
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* of each *completed* turn. A turn without a `turn/end` in the window is still
* producing steps, so its latest narration is not the settled answer and owns
* nothing; mid-turn narration of a completed turn stays chrome-free too.
* @param nodes - snapshot nodes (surface order).
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())
}
/**
* Exact start time of the latest in-window turn without a matching end time.
* @param turnTimings - In-window turn timings in event order.
* @returns Unix epoch ms, or null when the running turn started outside the window.
*/
export function runningTurnStartTime(
turnTimings: ConversationSnapshot['turnTimings'],
): number | null {
let latest: number | null = null
for (const timing of turnTimings.values()) {
if (timing.endTime === undefined) latest = timing.startTime
}
return latest
}
/**
* Seq set of assistant answers that may fork: the completed turn's transcript
* tail, when that tail is the turn's own content-text assistant. A later tool,
* reasoning, error, or other transcript node leaves the answer's branch action
* unavailable because the Host would include the whole turn. User and steering
* bubbles carry no branch action at all: a fork at their seq cuts at the same
* `turn/end` as the answer's, so the affordance lives only under the settled
* answer.
* @param nodes - snapshot nodes in event order.
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Assistant seq values whose visible position matches the fork boundary.
*/
export function assistantBranchSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
const result = new Set<number>()
const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1])
let nodeIndex = 0
for (const [turn, endSeq] of boundaries) {
let tail: ConversationNode | undefined
while (nodeIndex < nodes.length) {
const candidate = nodes[nodeIndex]
if (candidate === undefined || candidate.seq > endSeq) break
tail = candidate
nodeIndex++
}
if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) {
result.add(tail.seq)
}
}
return result
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
* @returns flow items; consecutive tool results group and retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
const pairs = commandCompactionPairs(nodes)
let group: ToolResultNode[] | null = null
for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
continue
}
if (node.kind === 'compaction') {
group = null
const command = pairs.byCompactionSeq.get(node.seq)
if (command !== undefined) {
items.push({
kind: 'command-compaction',
key: `c${command.commandId}`,
command,
compaction: node,
})
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
continue
}
if (node.kind === 'tool-result') {
if (group === null) {
group = [node]
items.push({ kind: 'tool-group', key: `g${node.seq}`, results: group })
} else {
group.push(node)
}
} else if (node.kind === 'model-retry') {
group = null
const previous = items[items.length - 1]
if (
previous?.kind === 'node'
&& previous.node.kind === 'model-retry'
) {
items[items.length - 1] = { ...previous, node }
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
} else {
group = null
items.push({
kind: 'node',
key: node.kind === 'command' && node.name === 'compact'
? `c${node.commandId}`
: `n${node.seq}`,
node,
})
}
}
return items
}
/**
* Key projection for the list parent's selector (content-blind identity).
* @param items - derived flow items.
* @returns joined key string usable with Object.is short-circuiting.
*/
export function flowKeys(items: readonly ChatFlowItem[]): string {
return items.map(i => i.key).join('|')
}

View File

@@ -0,0 +1,46 @@
import type { Context } from 'cordis'
import { NS } from '../locales.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
} from './MessageItem.tsx'
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
/**
* Register this package's business renderers behind the keyed Chat Node seat.
* @param ctx - owning UI Conversation context.
*/
export function registerChatNodeRenderers(ctx: Context): void {
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'command',
locale: NS,
children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } },
}, CommandNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'manual-compaction', locale: NS }, ManualCompactionNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'compaction', locale: NS }, CompactionNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'turn-tail',
locale: NS,
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
}, TurnTailNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
}

View File

@@ -0,0 +1,46 @@
import type {
ConversationSnapshot, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatNode } from '../contract/chat-nodes.ts'
function toolNode(node: ReturnType<ConversationSnapshot['chat']['nodes']['get']>): ChatNode<'tool-call'> | undefined {
return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
}
/**
* Read one root Tool lifecycle through the internal Chat Node index.
* @param snapshot - current Conversation snapshot.
* @param rootCallId - root call identity and Tool Context identity.
* @returns root lifecycle when it is materialized in the current window.
*/
export function rootToolCall(
snapshot: ConversationSnapshot,
rootCallId: string,
): ToolCallBlock | undefined {
return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root
}
/**
* Find any root or nested Tool lifecycle through the internal Node store.
* @param snapshot - current Conversation snapshot.
* @param callId - root or nested call identity.
* @returns current Tool lifecycle when materialized in the loaded window.
*/
export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined {
const visit = (block: ToolCallBlock): ToolCallBlock | undefined => {
if (block.callId === callId) return block
for (const child of block.subCalls) {
const found = visit(child)
if (found !== undefined) return found
}
return undefined
}
for (const node of snapshot.chat.nodes.values()) {
const root = toolNode(node)?.data.root
if (root === undefined) continue
const found = visit(root)
if (found !== undefined) return found
}
return undefined
}

View File

@@ -0,0 +1,10 @@
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Collect visible prose from one Assistant lifecycle.
* @param blocks - Assistant content blocks.
* @returns concatenated text blocks.
*/
export function assistantText(blocks: readonly AssistantBlock[]): string {
return blocks.flatMap(block => block.kind === 'text' ? [block.text] : []).join('')
}

View File

@@ -1,6 +1,6 @@
// Latency/throughput folds shared by the settled turn footer and StatsLine. // Latency/throughput folds shared by the settled turn footer and StatsLine.
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'
/** Latency and decode-throughput readings for one turn's footer. */ /** Latency and decode-throughput readings for one turn's footer. */
export interface TurnMetrics { export interface TurnMetrics {
@@ -24,7 +24,7 @@ interface UsageLike {
outputTokens?: number outputTokens?: number
} }
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }> type AssistantNode = AssistantMessageNode
function usageOutputTokens(usage: unknown): number | null { function usageOutputTokens(usage: unknown): number | null {
if (typeof usage !== 'object' || usage === null) return null if (typeof usage !== 'object' || usage === null) return null
@@ -67,7 +67,7 @@ interface TurnFold {
* @param nodes - Snapshot nodes of the loaded window. * @param nodes - Snapshot nodes of the loaded window.
* @returns Turn number → available metrics; turns with none are absent. * @returns Turn number → available metrics; turns with none are absent.
*/ */
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> { export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map<number, TurnMetrics> {
const folds = new Map<number, TurnFold>() const folds = new Map<number, TurnFold>()
for (const node of nodes) { for (const node of nodes) {
if (node.kind !== 'assistant') continue if (node.kind !== 'assistant') continue

View File

@@ -0,0 +1,82 @@
import type {
AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode,
CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Merge-extensible payload registry keyed by final Chat renderer kind. */
export interface ChatNodeDataMap {}
/** Renderer kinds contributed by the currently installed Chat business modules. */
export type ChatNodeKind = keyof ChatNodeDataMap & string
/** Final Chat Node narrowed to one registered renderer kind and payload. */
export type ChatNode<Kind extends ChatNodeKind = ChatNodeKind> = {
[RegisteredKind in Kind]: ChatConversationViewNode & {
readonly kind: RegisteredKind
readonly data: ChatNodeDataMap[RegisteredKind]
}
}[Kind]
/** Final Assistant row payload shared by streaming and settled states. */
export interface AssistantChatData {
readonly status: 'running' | 'settled' | 'interrupted'
readonly turn: number
readonly step: number
readonly blocks: readonly AssistantBlock[]
readonly time: number
readonly usage?: unknown
readonly finalNode?: AssistantMessageNode
}
/** Settled or interrupted Assistant payload with its durable presentation node. */
export type FinalAssistantChatData = AssistantChatData & {
readonly finalNode: AssistantMessageNode
}
/** Root Tool row payload; the root lifecycle owns all recursive subcalls. */
export interface ToolChatData {
readonly root: ToolCallBlock
}
/** One manual command and its correlated compaction transaction. */
export interface ManualCompactionChatData {
readonly command: CommandNode
readonly compaction: CompactionSummaryNode | null
}
/** One durable retry chain rendered as a single row. */
export interface RetryChatData {
readonly attempts: readonly ModelRetryNode[]
readonly current: ModelRetryNode
}
/** Turn-local footer row that owns actions and optional feature contributions. */
export interface TurnTailChatData {
readonly turn: number
readonly seq: number
readonly time: number
/** Last finalized content-bearing Assistant in this Turn. */
readonly closing: FinalAssistantChatData | null
/** Whether later Assistant/Step material makes the closing seq non-tail. */
readonly branchUnavailable: boolean
readonly ttftMs?: number
readonly tokensPerSecond?: number
}
/**
* Test whether a Tool root has settled.
* @param block - Tool root lifecycle value.
* @returns whether the root carries its final result.
*/
export function isSettledTool(block: ToolCallBlock): block is Extract<ToolCallBlock, { kind: 'tool-result' }> {
return 'kind' in block
}
/**
* Test whether a Tool root is still running.
* @param block - Tool root lifecycle value.
* @returns whether the root lacks a final result.
*/
export function isRunningTool(block: ToolCallBlock): block is RunningToolCall {
return !isSettledTool(block)
}

View File

@@ -1,15 +1,21 @@
/** Conversation slot declarations and their composed component props. */ /** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react' import type { ReactNode, RefObject } from 'react'
import type { import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
SlotHookFactory, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots' } from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {
CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap,
ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock,
TurnLocation, WorkspaceId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts' import type { createChatStore } from '../stores.ts'
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -31,13 +37,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* conversation snapshot through the standard kit. * conversation snapshot through the standard kit.
*/ */
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/** /** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */
* One root Tool call at its ordered ChatFlow position. The chat view owns 'conversation.chat.node': {
* placement; ui-tool owns root/subcall composition and keyed dispatch. kind: 'keyed'
* The filler preserves the call-anchor DOM contract documented by scope: 'session'
* {@link ToolTreeOwnerProps} for every root and child wrapper. owner: ChatNodeOwnerProps
*/ keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps } hookContext: string
inject: ChatNodeTurnDataInjected
}
/** /**
* The chat view's per-command row hole: keyed dispatch on the command * The chat view's per-command row hole: keyed dispatch on the command
* name (`command/run.name`; a run-less cross-window node has none and * name (`command/run.name`; a run-less cross-window node has none and
@@ -171,7 +179,7 @@ export interface ConvViewOwnerProps {
export interface ChatFileMentions { export interface ChatFileMentions {
/** /**
* Mention vocabulary for the closing message the owner currency names. * Mention vocabulary for the closing message the owner currency names.
* @param owner - Turn-tail owner currency (nodes, closing seq, opener). * @param owner - Turn-tail owner currency (Turn data, closing seq, opener).
* @returns The resolver MarkdownText consumes, or undefined when the turn * @returns The resolver MarkdownText consumes, or undefined when the turn
* produced nothing worth linking. * produced nothing worth linking.
*/ */
@@ -186,14 +194,13 @@ declare module 'cordis' {
} }
/** /**
* Owner currency of the chat view's turn-tail hole: the finalized snapshot * Owner currency of the chat view's turn-tail hole: the engine-owned Turn and
* and the closing assistant's anchor. Registrants derive their own facts * the closing assistant's anchor. Registrants read their own typed Turn data
* from the nodes (the owner never pre-chews a feature's vocabulary), and * and open files through the same opener the tool rows use.
* open files through the same opener the tool rows use.
*/ */
export interface TurnTailOwnerProps { export interface TurnTailOwnerProps {
/** Finalized snapshot nodes in surface order. */ /** Engine-owned closing Turn boundary. */
nodes: readonly ConversationNode[] turn: TurnLocation
/** The closing assistant's seq — the anchor the tail renders under. */ /** The closing assistant's seq — the anchor the tail renders under. */
seq: number seq: number
/** /**
@@ -203,34 +210,34 @@ export interface TurnTailOwnerProps {
openFile: (path: string) => void openFile: (path: string) => void
} }
/** /** Hook constrained to business data published on the current Chat Node's Turn. */
* Owner currency of the chat view's whole-Tool rendering seat. The filler export type UseChatNodeTurnData = <Key extends keyof ConversationTurnDataMap & string>(
* wraps every rendered root and child with `data-chat-anchor-key="call:<id>"` key: Key,
* and `data-chat-call-id="<id>"`, plus `data-selected="true"` for the selected ) => Readonly<ConversationTurnDataMap[Key]> | undefined
* call. ChatView consumes those anchors to restore prepend/paging position.
*/ /** Slot-level Hook factory used by renderers reading their Node's Turn data. */
export interface ToolTreeOwnerProps { export interface ChatNodeTurnDataInjected {
/** Root Tool call identity, stable across running → settled. */ hooks: {
callId: CallId turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData>
/** Root wire Tool name. */ }
toolName: string
/** Frozen root call slice: running call or settled result node. */
block: ToolCallBlock
/** Selected call id; the Tool owner resolves whether it is root or child. */
selectedCallId?: CallId | undefined
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/**
* Open a tool-arg filesystem path with the host OS default application.
* The conversation owner resolves relative paths against the session cwd.
*/
openFile: (path: string) => void
/**
* Jump to any call in this tree in the trajectory view.
*/
inspectCall: (callId: CallId) => void
} }
/** Stable owner currency delivered to one keyed Chat business renderer. */
export interface ChatNodeOwnerProps {
/** Selected Tool call, when the shared details store names one. */
selectedCallId?: CallId | undefined
/** Session workspace root; Tool summaries display paths relative to it. */
cwd?: string | undefined
openFile: (path: string) => void
inspectCall: (callId: CallId) => void
forkAt: (seq: number) => void
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Full props of one registered keyed Chat business renderer. */
export type ChatNodeViewProps<Kind extends ChatNodeKind = ChatNodeKind> =
PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'>
/** Owner currency of the details panel's Tool output renderer. */ /** Owner currency of the details panel's Tool output renderer. */
export interface DetailsToolOwnerProps { export interface DetailsToolOwnerProps {
/** Frozen selected call slice. */ /** Frozen selected call slice. */
@@ -555,7 +562,7 @@ export interface ChatViewInjected {
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
export type ChatViewSlotProps = export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'> & PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/** /**

View File

@@ -0,0 +1,316 @@
import type { Context } from 'cordis'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Streaming, settled, or interrupted Assistant step. */
'assistant-step': AssistantChatData
}
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationStepDataMap {
/** Streaming, settled, or interrupted Assistant material for this Step. */
'assistant-step': AssistantChatData
}
}
interface AssistantState {
readonly turn: number
readonly step: number
readonly blocks: readonly (AssistantBlock | undefined)[]
readonly firstVisibleSeq: number | undefined
readonly firstVisibleTime: number | undefined
readonly firstTokenTime: number | undefined
readonly hidden: boolean
readonly final: ConversationMatch | undefined
readonly usage: unknown
}
function initialState(turn: number, step: number): AssistantState {
return {
turn,
step,
blocks: [],
firstVisibleSeq: undefined,
firstVisibleTime: undefined,
firstTokenTime: undefined,
hidden: false,
final: undefined,
usage: undefined,
}
}
function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] {
return blocks.filter((block): block is AssistantBlock => block !== undefined)
}
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
return blocks.some((block) => {
if (block.kind === 'tool-call') return false
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
})
}
function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
return blocks.some((block) => {
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
})
}
function resetForRetry(state: AssistantState): AssistantState {
return {
...initialState(state.turn, state.step),
firstTokenTime: state.firstTokenTime,
hidden: true,
}
}
function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
if (match.event.type !== 'assistant/chunk') return state
const chunk = match.event.data.chunk
const blocks = [...state.blocks]
switch (chunk.type) {
case 'block-start':
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
break
case 'text-delta': {
const previous = blocks[chunk.index]
blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text }
break
}
case 'reasoning-delta': {
const previous = blocks[chunk.index]
blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text }
break
}
case 'tool-call-delta': {
const previous = blocks[chunk.index]
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
}
break
}
case 'block-end':
blocks[chunk.index] = toAssistantBlock(chunk.block)
break
case 'usage':
return { ...state, usage: chunk.usage }
default:
return state
}
const visible = hasVisibleContent(compactBlocks(blocks))
const firstToken = isTokenDelta(chunk)
return {
...state,
blocks,
hidden: visible ? false : state.hidden,
...visible && state.firstVisibleSeq === undefined
? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
: {},
...firstToken && state.firstTokenTime === undefined
? { firstTokenTime: match.event.time }
: {},
}
}
function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined {
if (location.kind === 'step' && location.step.status === 'closed' && location.step.end !== undefined) {
return location.step.end
}
if ((location.kind === 'step' || location.kind === 'turn')
&& location.turn.status === 'closed' && location.turn.end !== undefined) {
return location.turn.end
}
return undefined
}
function finalNode(
state: AssistantState,
context: ConversationNodeContext<AssistantState>,
): AssistantMessageNode | undefined {
const final = state.final
if (final?.event.type === 'assistant/message') {
const event = final.event
return {
kind: 'assistant',
seq: event.seq,
time: event.time,
turn: state.turn,
step: state.step,
blocks: toAssistantBlocks(event.data.message.content),
usage: event.data.usage,
timing: {
stepStartTime: context.start?.event.time ?? null,
firstTokenTime: state.firstTokenTime ?? null,
completedTime: event.time,
},
}
}
const location = context.start?.location ?? context.matches.at(-1)?.location
const boundary = location === undefined ? undefined : closedBoundary(location)
const blocks = compactBlocks(state.blocks)
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
return {
kind: 'assistant',
seq: boundary.seq - 0.9,
time: boundary.time,
turn: state.turn,
step: state.step,
blocks,
interrupted: true,
}
}
function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
let state: AssistantState | undefined
for (const match of context.matches) {
if (match.event.type === 'assistant/chunk') {
state ??= initialState(match.event.data.turn, match.event.data.step)
state = updateChunk(state, match)
continue
}
if (match.event.type === 'assistant/message') {
state ??= initialState(match.event.data.turn, match.event.data.step)
state = {
...state,
blocks: toAssistantBlocks(match.event.data.message.content),
hidden: false,
final: match,
usage: match.event.data.usage,
}
continue
}
if ((match.event.type as string) === 'llm/retry' && state !== undefined) {
state = resetForRetry(state)
}
}
return state
}
interface AssistantProjection {
readonly data: AssistantChatData
readonly anchorSeq: number
readonly visible: boolean
readonly settled: AssistantMessageNode | undefined
}
function projectAssistant(context: ConversationNodeContext<AssistantState>): AssistantProjection | undefined {
const state = context.state ?? fallbackState(context)
if (state === undefined) return undefined
const settled = finalNode(state, context)
const blocks = settled?.blocks ?? compactBlocks(state.blocks)
const visible = hasVisibleContent(blocks)
const status = settled?.interrupted === true
? 'interrupted'
: settled === undefined ? 'running' : 'settled'
const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0
const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0
return {
anchorSeq,
visible,
settled,
data: {
status,
turn: state.turn,
step: state.step,
blocks,
time,
...state.usage === undefined ? {} : { usage: state.usage },
...settled === undefined ? {} : { finalNode: settled },
},
}
}
/** Per-step Assistant streaming/final/interruption Definition. */
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
kind: 'assistant-step',
match: (event) => {
if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
if (event.type === 'assistant/chunk'
|| (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
if ((event.type as string) === 'llm/retry') {
const data = event.data as unknown as { turn: number; step: number }
return { id: `${data.turn}:${data.step}`, role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'step/start') throw new Error('assistant-step start requires step/start')
return initialState(match.event.data.turn, match.event.data.step)
},
update: (context, match) => {
if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
if (match.event.type === 'assistant/message') {
return {
...context.state,
blocks: toAssistantBlocks(match.event.data.message.content),
hidden: false,
final: match,
usage: match.event.data.usage,
}
}
if ((match.event.type as string) === 'llm/retry') {
return resetForRetry(context.state)
}
return context.state
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
if (match.event.type !== 'assistant/chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
},
buildLocationData: (context, scope) => {
if (scope !== 'step') return null
const projected = projectAssistant(context)
if (projected === undefined) return null
return {
kind: 'step',
turn: projected.data.turn,
step: projected.data.step,
key: 'assistant-step',
value: projected.data,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const projected = projectAssistant(context)
if (projected === undefined) return null
if (projected.settled === undefined && !projected.visible) {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const current = context.current.get('chat')
if (!state.hidden || current === undefined || current === null) return null
}
return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, {
visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden',
})
},
}
/**
* Register the Assistant lifecycle business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerAssistantConversationNode(ctx: Context): void {
ctx.conversationEvents.register(assistantDefinition)
}

View File

@@ -0,0 +1,456 @@
import type { Context } from 'cordis'
import type {
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
ConversationLocation, ConversationNode, ConversationTimelineSnapshot,
ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice,
PartialAssistant, RunningToolCall,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
const EMPTY_KEYS: readonly string[] = []
const EMPTY_TURNS: readonly number[] = []
const EMPTY_LIST: readonly never[] = []
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
class MutableChatNodeStore implements ChatNodeStore {
private readonly byKey = new Map<string, ChatConversationViewNode>()
private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
private valuesDirty = false
get(key: string): ChatConversationViewNode | undefined {
return this.byKey.get(key)
}
values(): readonly ChatConversationViewNode[] {
if (this.valuesDirty) {
this.valuesCache = [...this.byKey.values()]
this.valuesDirty = false
}
return this.valuesCache
}
replace(nodes: readonly ChatConversationViewNode[]): void {
this.byKey.clear()
for (const node of nodes) this.byKey.set(node.key, node)
this.valuesCache = [...this.byKey.values()]
this.valuesDirty = false
}
upsert(nodes: readonly ChatConversationViewNode[]): void {
let changed = false
for (const node of nodes) {
if (this.byKey.get(node.key) === node) continue
this.byKey.set(node.key, node)
changed = true
}
if (changed) this.valuesDirty = true
}
}
class MutableChatLocationIndex implements ChatLocationNodeIndex {
private turns = new Map<number, readonly string[]>()
private steps = new Map<string, readonly string[]>()
getTurn(turn: number): readonly string[] {
return this.turns.get(turn) ?? EMPTY_KEYS
}
getStep(turn: number, step: number): readonly string[] {
return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS
}
rebuild(order: readonly string[], store: ChatNodeStore): void {
const turns = new Map<number, string[]>()
const steps = new Map<string, string[]>()
for (const key of order) {
const location = store.get(key)?.location
if (location === undefined) continue
const coordinates = locationCoordinates(location)
if (coordinates.turn === undefined) continue
const turnKeys = turns.get(coordinates.turn) ?? []
turnKeys.push(key)
turns.set(coordinates.turn, turnKeys)
if (coordinates.step === undefined) continue
const step = stepKey(coordinates.turn, coordinates.step)
const stepKeys = steps.get(step) ?? []
stepKeys.push(key)
steps.set(step, stepKeys)
}
this.turns = updateIndex(this.turns, turns)
this.steps = updateIndex(this.steps, steps)
}
/** Invalidate aggregate readers when member data changes without moving. */
touch(nodes: readonly ChatConversationViewNode[]): void {
const turns = new Set<number>()
const steps = new Set<string>()
for (const node of nodes) {
const coordinates = locationCoordinates(node.location)
if (coordinates.turn === undefined || !this.turns.get(coordinates.turn)?.includes(node.key)) continue
turns.add(coordinates.turn)
if (coordinates.step !== undefined) steps.add(stepKey(coordinates.turn, coordinates.step))
}
for (const turn of turns) {
const keys = this.turns.get(turn)
if (keys === undefined) continue
this.turns.set(turn, [...keys])
}
for (const step of steps) {
const keys = this.steps.get(step)
if (keys === undefined) continue
this.steps.set(step, [...keys])
}
}
}
function updateIndex<Key>(
previous: ReadonlyMap<Key, readonly string[]>,
nextMutable: ReadonlyMap<Key, string[]>,
): Map<Key, readonly string[]> {
const next = new Map<Key, readonly string[]>()
const keys = new Set([...previous.keys(), ...nextMutable.keys()])
for (const key of keys) {
const before = previous.get(key) ?? EMPTY_KEYS
const candidate = nextMutable.get(key) ?? EMPTY_KEYS
const value = sameReferences(before, candidate) ? before : candidate
if (candidate.length > 0) next.set(key, value)
}
return next
}
function stepKey(turn: number, step: number): string {
return `${turn}:${step}`
}
function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } {
if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step }
if (location.kind === 'turn') return { turn: location.turn.turn }
return {}
}
function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] {
return nodes
.filter(node => node.visibility === 'visible')
.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
}
interface LegacyContribution {
readonly anchorSeq: number
readonly nodes: readonly ConversationNode[]
readonly partial: PartialAssistant | null
readonly running: RunningToolCall | null
}
const EMPTY_CONTRIBUTION: LegacyContribution = {
anchorSeq: 0,
nodes: EMPTY_LIST,
partial: null,
running: null,
}
function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
const node = raw as ChatNode
if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION
switch (node.kind) {
case 'user':
case 'steering':
case 'context':
case 'command':
case 'compaction':
case 'turn-error':
case 'unknown':
return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null }
case 'assistant-step': {
const data = node.data
if (data.status === 'running') {
if (raw.visibility !== 'visible') return EMPTY_CONTRIBUTION
return {
anchorSeq: node.anchorSeq,
nodes: EMPTY_LIST,
partial: { turn: data.turn, step: data.step, blocks: data.blocks },
running: null,
}
}
return {
anchorSeq: node.anchorSeq,
nodes: data.finalNode === undefined ? EMPTY_LIST : [data.finalNode],
partial: null,
running: null,
}
}
case 'tool-call': {
const root = node.data.root
return isRunningTool(root)
? { anchorSeq: node.anchorSeq, nodes: EMPTY_LIST, partial: null, running: root }
: { anchorSeq: node.anchorSeq, nodes: [root], partial: null, running: null }
}
case 'manual-compaction': {
const data = node.data
return {
anchorSeq: node.anchorSeq,
nodes: data.compaction === null ? [data.command] : [data.command, data.compaction],
partial: null,
running: null,
}
}
case 'model-retry':
return {
anchorSeq: node.anchorSeq,
nodes: node.data.attempts,
partial: null,
running: null,
}
case 'turn-tail':
return EMPTY_CONTRIBUTION
default:
return EMPTY_CONTRIBUTION
}
}
function sameContribution(left: LegacyContribution | undefined, right: LegacyContribution): boolean {
return left !== undefined
&& left.anchorSeq === right.anchorSeq
&& left.partial?.blocks === right.partial?.blocks
&& left.partial?.turn === right.partial?.turn
&& left.partial?.step === right.partial?.step
&& left.running === right.running
&& sameReferences(left.nodes, right.nodes)
}
/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */
class LegacySliceBuilder {
private readonly contributions = new Map<string, LegacyContribution>()
private readonly finalizedContributions = new Map<string, LegacyContribution>()
private readonly runningContributions = new Map<string, LegacyContribution>()
private readonly partialContributions = new Map<string, LegacyContribution>()
private finalized: readonly ConversationNode[] = EMPTY_LIST
private runningCalls: readonly RunningToolCall[] = EMPTY_LIST
private partial: PartialAssistant | null = null
private timeline: ConversationTimelineSnapshot | undefined
private turnTimings: LegacyConversationSlice['turnTimings'] = new Map()
private turnEnds: LegacyConversationSlice['turnEnds'] = new Map()
replace(
nodes: readonly ChatConversationViewNode[],
timeline: ConversationTimelineSnapshot,
): LegacyConversationSlice {
this.contributions.clear()
this.finalizedContributions.clear()
this.runningContributions.clear()
this.partialContributions.clear()
for (const node of nodes) {
const contribution = legacyContribution(node)
this.contributions.set(node.key, contribution)
this.indexContribution(node.key, contribution)
}
this.rebuildFinalized()
this.rebuildRunning()
this.rebuildPartial()
this.updateTimeline(timeline)
return this.snapshot()
}
apply(
upserts: readonly ChatConversationViewNode[],
timeline: ConversationTimelineSnapshot,
): LegacyConversationSlice {
let finalizedChanged = false
let runningChanged = false
let partialChanged = false
for (const node of upserts) {
const contribution = legacyContribution(node)
const previous = this.contributions.get(node.key)
if (sameContribution(previous, contribution)) continue
finalizedChanged ||= finalizedContributionChanged(previous, contribution)
runningChanged ||= runningContributionChanged(previous, contribution)
partialChanged ||= partialContributionChanged(previous, contribution)
this.contributions.set(node.key, contribution)
this.indexContribution(node.key, contribution)
}
if (finalizedChanged) this.rebuildFinalized()
if (runningChanged) this.rebuildRunning()
if (partialChanged) this.rebuildPartial()
this.updateTimeline(timeline)
return this.snapshot()
}
private indexContribution(key: string, contribution: LegacyContribution): void {
updateContributionIndex(this.finalizedContributions, key, contribution, contribution.nodes.length > 0)
updateContributionIndex(this.runningContributions, key, contribution, contribution.running !== null)
updateContributionIndex(this.partialContributions, key, contribution, contribution.partial !== null)
}
private rebuildFinalized(): void {
const finalized = [...this.finalizedContributions.values()]
.flatMap(value => value.nodes)
.sort((left, right) => left.seq - right.seq)
if (!sameReferences(this.finalized, finalized)) this.finalized = finalized
}
private rebuildRunning(): void {
const runningCalls = [...this.runningContributions.values()]
.sort((left, right) => left.anchorSeq - right.anchorSeq)
.flatMap(value => value.running === null ? [] : [value.running])
if (!sameReferences(this.runningCalls, runningCalls)) this.runningCalls = runningCalls
}
private rebuildPartial(): void {
const partial = [...this.partialContributions.values()]
.sort((left, right) => left.anchorSeq - right.anchorSeq)
.findLast(value => value.partial !== null)?.partial ?? null
if (this.partial?.blocks !== partial?.blocks
|| this.partial?.turn !== partial?.turn
|| this.partial?.step !== partial?.step) this.partial = partial
}
private updateTimeline(timeline: ConversationTimelineSnapshot): void {
if (this.timeline === timeline) return
this.timeline = timeline
const turnTimings = new Map<number, { startTime: number; endTime?: number }>()
const turnEnds = new Map<number, number>()
for (const turn of timeline.turns.values()) {
if (turn.start !== undefined) {
turnTimings.set(turn.turn, {
startTime: turn.start.time,
...turn.end === undefined ? {} : { endTime: turn.end.time },
})
}
if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq)
}
this.turnTimings = turnTimings
this.turnEnds = turnEnds
}
private snapshot(): LegacyConversationSlice {
return {
nodes: this.finalized,
turnTimings: this.turnTimings,
turnEnds: this.turnEnds,
partial: this.partial,
runningCalls: this.runningCalls,
}
}
}
function updateContributionIndex(
index: Map<string, LegacyContribution>,
key: string,
contribution: LegacyContribution,
present: boolean,
): void {
if (present) index.set(key, contribution)
else index.delete(key)
}
function finalizedContributionChanged(
previous: LegacyContribution | undefined,
next: LegacyContribution,
): boolean {
const previousNodes = previous?.nodes ?? EMPTY_LIST
return !sameReferences(previousNodes, next.nodes)
|| ((previousNodes.length > 0 || next.nodes.length > 0) && previous?.anchorSeq !== next.anchorSeq)
}
function runningContributionChanged(
previous: LegacyContribution | undefined,
next: LegacyContribution,
): boolean {
return previous?.running !== next.running
|| ((previous.running !== null || next.running !== null)
&& previous.anchorSeq !== next.anchorSeq)
}
function partialContributionChanged(
previous: LegacyContribution | undefined,
next: LegacyContribution,
): boolean {
return previous?.partial?.blocks !== next.partial?.blocks
|| previous?.partial?.turn !== next.partial?.turn
|| previous?.partial?.step !== next.partial?.step
|| (((previous?.partial ?? null) !== null || next.partial !== null)
&& previous?.anchorSeq !== next.anchorSeq)
}
/** Incremental keyed Chat builder registered under the `chat` target. */
export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversationViewNode, ChatSnapshot> {
private readonly store = new MutableChatNodeStore()
private readonly locations = new MutableChatLocationIndex()
private readonly legacy = new LegacySliceBuilder()
private order: readonly string[] = EMPTY_KEYS
readonly empty: ChatSnapshot
constructor() {
this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() })
}
replace(input: {
readonly nodes: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
this.store.replace(input.nodes)
this.order = orderedVisible(input.nodes).map(node => node.key)
this.locations.rebuild(this.order, this.store)
return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline))
}
apply(input: {
readonly upserts: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
let structural = false
const contentOnly: ChatConversationViewNode[] = []
for (const node of input.upserts) {
const previous = this.store.get(node.key)
const nodeStructural = previous === undefined
|| previous.anchorSeq !== node.anchorSeq
|| previous.visibility !== node.visibility
|| locationIdentity(previous.location) !== locationIdentity(node.location)
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
}
this.store.upsert(input.upserts)
if (structural) {
const next = orderedVisible(this.store.values()).map(node => node.key)
this.order = sameReferences(this.order, next) ? this.order : next
this.locations.rebuild(this.order, this.store)
}
this.locations.touch(contentOnly)
return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline))
}
private snapshot(
timeline: ConversationTimelineSnapshot,
legacy = this.legacy.replace(EMPTY_LIST, timeline),
): ChatSnapshot {
return {
order: this.order,
nodes: this.store,
locations: this.locations,
timeline,
legacy,
}
}
}
function locationIdentity(location: ConversationLocation): string {
const coordinates = locationCoordinates(location)
return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}`
}
/** Chat target factory contributed to the Runtime view registry. */
export const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
target: 'chat',
create: () => new ChatSnapshotBuilder(),
}
/**
* Register the incremental Chat target builder.
* @param ctx - owning UI Conversation context.
*/
export function registerChatConversationView(ctx: Context): void {
ctx.conversationViews.register(chatViewDefinition)
}

View File

@@ -0,0 +1,243 @@
import type { Context } from 'cordis'
import type {
CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ManualCompactionChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Ordinary slash-command lifecycle. */
command: CommandNode
/** Manual compact command combined with its compaction transaction. */
'manual-compaction': ManualCompactionChatData
}
}
type CommandId = CommandNode['commandId']
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
interface CommandState {
readonly command: CommandNode
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
interface CompactionEvidence {
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
interface CommandRunData {
readonly commandId: CommandId
readonly name: string
readonly args?: string
}
interface CommandDoneData {
readonly commandId: CommandId
readonly kind: 'success' | 'error'
readonly text?: string
readonly sourceEventSeq?: number
}
function commandFromRun(match: ConversationMatch): CommandNode {
const data = match.event.data as unknown as CommandRunData
return {
kind: 'command',
seq: match.event.seq,
time: match.event.time,
commandId: data.commandId,
name: data.name,
args: data.args ?? null,
outcome: null,
}
}
function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode {
const data = match.event.data as unknown as CommandDoneData
const sourceEventSeq = data.kind === 'success'
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
? data.sourceEventSeq as number
: undefined
return {
kind: 'command',
seq: previous?.seq ?? match.event.seq,
time: previous?.time ?? match.event.time,
commandId: data.commandId,
name: previous?.name ?? null,
args: previous?.args ?? null,
outcome: {
kind: data.kind,
...data.text === undefined ? {} : { text: data.text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
},
}
}
/**
* Read correlation identity from a compaction replacement checkpoint.
* @param event - candidate Session event.
* @returns correlated compaction and optional command identity.
*/
function compactSource(event: Parameters<ConversationNodeDefinition['match']>[0]): {
compactionId: string
sourceCommandId?: CommandId
} | undefined {
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return undefined
const source = event.data.source as unknown as {
kind?: unknown
plugin?: unknown
compactionId?: unknown
sourceCommandId?: CommandId
}
if (source.kind !== 'plugin' || source.plugin !== COMPACT_PLUGIN || typeof source.compactionId !== 'string') return undefined
return {
compactionId: source.compactionId,
...source.sourceCommandId === undefined ? {} : { sourceCommandId: source.sourceCommandId },
}
}
/**
* Build the visible summary marker from optional lifecycle evidence.
* @param match - compact/summary Match, when loaded.
* @param checkpoint - replacement checkpoint Match.
* @returns final compaction summary Node data.
*/
function compactSummary(match: ConversationMatch | undefined, checkpoint: ConversationMatch): CompactionSummaryNode {
let summary: string | null = null
let shadowedItemCount: number | null = null
let shadowedTokenCount: number | null = null
if (match !== undefined) {
const data = match.event.data as unknown as {
summary?: unknown
shadowedSeqs?: unknown
shadowedTokenCount?: unknown
}
if (Array.isArray(data.summary)) {
const text = data.summary
.map((block: unknown) => {
const value = block as { type?: unknown; text?: unknown }
return value.type === 'text' && typeof value.text === 'string' ? value.text : ''
})
.join('')
summary = text.trim() === '' ? null : text
}
shadowedItemCount = Array.isArray(data.shadowedSeqs)
&& data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0)
? data.shadowedSeqs.length
: null
shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount)
&& (data.shadowedTokenCount as number) >= 0
? data.shadowedTokenCount as number
: null
}
return {
kind: 'compaction',
seq: checkpoint.event.seq,
time: checkpoint.event.time,
summary,
summaryEventSeq: match?.event.seq ?? null,
shadowedItemCount,
shadowedTokenCount,
}
}
function fallbackState(context: ConversationNodeContext<CommandState>): CommandState | undefined {
const done = context.matches.find(match => (match.event.type as string) === 'command/done')
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary')
if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) }
const source = compactSource(checkpoint.event)
if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) }
const fallbackCommand = done === undefined
? {
kind: 'command' as const,
seq: checkpoint.event.seq,
time: checkpoint.event.time,
commandId: source.sourceCommandId,
name: 'compact',
args: null,
outcome: null,
}
: { ...commandFromDone(done), name: 'compact' }
return {
command: fallbackCommand,
checkpoint,
...summary === undefined ? {} : { summary },
}
}
/**
* Fold shared compaction evidence into a Definition-owned State.
* @param state - current business State carrying optional compaction evidence.
* @param match - next compaction lifecycle Match.
* @returns adopted State, preserving reference identity when the Match adds no evidence.
*/
export function updateCompactionState<State extends CompactionEvidence>(
state: State,
match: ConversationMatch,
): State {
if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match }
if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match }
return state
}
/** Slash-command lifecycle, including integrated manual compaction, Definition. */
export const commandDefinition: ConversationNodeDefinition<CommandState> = {
kind: 'command',
match: (event) => {
if ((event.type as string) === 'command/run') {
return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' }
}
if ((event.type as string) === 'command/done') {
return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' }
}
const checkpoint = compactSource(event)
if (checkpoint?.sourceCommandId !== undefined) {
return { id: String(checkpoint.sourceCommandId), role: 'update' }
}
if ((event.type as string) === 'compact/start'
|| (event.type as string) === 'compact/summary'
|| (event.type as string) === 'compact/end') {
const data = event.data as unknown as { sourceCommandId?: CommandId }
if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' }
}
return null
},
start: (_context, match) => ({ command: commandFromRun(match) }),
update: (context, match) => {
if ((match.event.type as string) === 'command/done') {
return { ...context.state, command: commandFromDone(match, context.state.command) }
}
return updateCompactionState(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
if (state.command.name !== 'compact') {
return chatNode(context, 'command', state.command.seq, state.command)
}
const compaction = state.checkpoint === undefined
? null
: compactSummary(state.summary, state.checkpoint)
const data: ManualCompactionChatData = { command: state.command, compaction }
return chatNode(context, 'manual-compaction', compaction?.seq ?? state.command.seq, data)
},
}
/**
* Register the command lifecycle business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerCommandConversationNode(ctx: Context): void {
ctx.conversationEvents.register(commandDefinition)
}
/** Shared structural checkpoint recognizer for automatic compaction. */
export { compactSource, compactSummary }

View File

@@ -0,0 +1,55 @@
import type {
ConversationLocation, ConversationNodeContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
ChatNode, ChatNodeDataMap, ChatNodeKind,
} from '../contract/chat-nodes.ts'
/**
* Resolve one Context's best currently loaded event Location.
* @param context - assembled business Context.
* @returns start or first-match Location, otherwise unresolved.
*/
export function contextLocation(context: ConversationNodeContext): ConversationLocation {
return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
}
/**
* Build one final Chat target Node with the engine-owned stable key.
* @param context - assembled business Context.
* @param kind - Chat renderer dispatch key.
* @param anchorSeq - sortable render position.
* @param data - renderer-owned payload.
* @param options - optional Location and visibility overrides.
* @returns final Chat view Node.
*/
export function chatNode<Kind extends ChatNodeKind>(
context: ConversationNodeContext,
kind: Kind,
anchorSeq: number,
data: ChatNodeDataMap[Kind],
options: {
readonly location?: ConversationLocation
readonly visibility?: 'visible' | 'hidden'
} = {},
): ChatNode<Kind> {
return {
key: context.key,
kind,
id: context.id,
target: 'chat',
anchorSeq,
location: options.location ?? contextLocation(context),
visibility: options.visibility ?? 'visible',
data,
}
}
/**
* Read a finite non-negative integer from a structurally narrowed payload.
* @param value - untrusted payload field.
* @returns valid coordinate, otherwise undefined.
*/
export function coordinate(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}

View File

@@ -0,0 +1,63 @@
import type { Context } from 'cordis'
import type {
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { chatNode } from './common.ts'
import { compactSource, compactSummary, updateCompactionState } from './command.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Automatic compaction checkpoint marker. */
compaction: CompactionSummaryNode
}
}
interface CompactionState {
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
function fallbackState(context: ConversationNodeContext<CompactionState>): CompactionState {
const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary')
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
return {
...summary === undefined ? {} : { summary },
...checkpoint === undefined ? {} : { checkpoint },
}
}
/** Automatic compaction lifecycle and landed checkpoint Definition. */
export const compactionDefinition: ConversationNodeDefinition<CompactionState> = {
kind: 'compaction',
match: (event) => {
const checkpoint = compactSource(event)
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
return { id: checkpoint.compactionId, role: 'update' }
}
if ((event.type as string) === 'compact/start'
|| (event.type as string) === 'compact/summary'
|| (event.type as string) === 'compact/end') {
const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown }
if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null
return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' }
}
return null
},
start: () => ({}),
update: (context, match) => updateCompactionState(context.state, match),
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state.checkpoint === undefined) return null
const marker = compactSummary(state.summary, state.checkpoint)
return chatNode(context, 'compaction', marker.seq, marker)
},
}
/**
* Register the automatic-compaction business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerCompactionConversationNode(ctx: Context): void {
ctx.conversationEvents.register(compactionDefinition)
}

View File

@@ -0,0 +1,40 @@
import type { Context } from 'cordis'
import type {
ConversationNodeDefinition, UnknownSurfaceNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Generic presentation of an unclaimed append-surface event. */
unknown: UnknownSurfaceNode
}
}
/** Unclaimed append-surface fallback Definition. */
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
kind: 'unknown-surface',
match: event => isAppendSurfaceEvent(event)
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match) => ({
kind: 'unknown',
seq: match.event.seq,
time: match.event.time,
type: match.event.type,
data: match.event.data,
}),
update: context => context.state,
buildViewNode: (context, target) => target !== 'chat' || context.state === undefined
? null
: chatNode(context, 'unknown', context.state.seq, context.state),
}
/**
* Register the unmatched append-surface fallback contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerUnknownConversationFallback(ctx: Context): void {
ctx.conversationEvents.registerFallback(unknownFallbackDefinition)
}

View File

@@ -0,0 +1,71 @@
import type { Context } from 'cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
type InboxTarget = 'next-turn' | 'next-step'
interface InboxIdentity {
readonly id: string
}
interface InboxSplice {
readonly target: InboxTarget
readonly start: number
readonly removedCount?: number
readonly inserted: readonly InboxIdentity[]
readonly outcome?: 'canceled'
}
/** Cumulative state after one durable inbox splice. */
export interface InboxState {
readonly pending: readonly InboxIdentity[]
readonly claimed: ReadonlySet<string>
}
function applySplice(
previous: ConversationPreviousContext<InboxState> | undefined,
splice: InboxSplice,
): InboxState {
const pending = [...(previous?.state.pending ?? [])]
const claimed = new Set(previous?.state.claimed ?? [])
const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
for (const identity of splice.inserted) claimed.delete(identity.id)
if (splice.target === 'next-step' && splice.outcome !== 'canceled') {
for (const identity of removed) claimed.add(identity.id)
}
return { pending, claimed }
}
function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxState> {
const kind = `inbox-${target}`
return {
kind,
match: event => (event.type as string) === 'agent/inbox/spliced'
&& (event.data as unknown as { target?: unknown }).target === target
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => applySplice(
reader.previous<InboxState>(kind),
match.event.data as unknown as InboxSplice,
),
update: context => context.state,
publication: () => 'none',
buildViewNode: () => null,
}
}
/** Cumulative next-turn inbox splice Definition. */
export const nextTurnInboxDefinition = inboxDefinition('next-turn')
/** Cumulative next-step inbox splice Definition used to classify steering. */
export const nextStepInboxDefinition = inboxDefinition('next-step')
/**
* Register the two durable Inbox-state contributions.
* @param ctx - owning UI Conversation context.
*/
export function registerInboxConversationNodes(ctx: Context): void {
ctx.conversationEvents.register(nextTurnInboxDefinition)
ctx.conversationEvents.register(nextStepInboxDefinition)
}

View File

@@ -0,0 +1,83 @@
import type { Context } from 'cordis'
import type {
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxState } from './inbox.ts'
import { chatNode } from './common.ts'
type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Ordinary turn-opening user message. */
user: UserMessageNode
/** User message admitted into an active turn. */
steering: SteeringMessageNode
/** Non-user context injected into model history. */
context: ContextMessageNode
}
}
function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false
const source = event.data.source
return source.kind === 'plugin' && source.plugin === 'compact'
}
/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'input-message',
match: event => event.type === 'user/message'
&& isAppendSurfaceEvent(event)
&& !isCompactionCheckpoint(event)
? { id: String(event.data.id), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
const event = match.event
if (event.data.source.kind !== 'user') {
return {
kind: 'context',
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
return claimed
? {
kind: 'steering',
messageId: event.data.id,
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
}
: {
kind: 'user',
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
}
},
update: context => context.state,
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined) return null
return chatNode(context, context.state.kind, context.state.seq, context.state)
},
}
/**
* Register the user, steering, and injected-context message contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerMessageConversationNode(ctx: Context): void {
ctx.conversationEvents.register(messageDefinition)
}

View File

@@ -0,0 +1,30 @@
import type { Context } from 'cordis'
import { registerAssistantConversationNode } from './assistant.ts'
import { registerChatConversationView } from './chat-snapshot-builder.ts'
import { registerCommandConversationNode } from './command.ts'
import { registerCompactionConversationNode } from './compaction.ts'
import { registerUnknownConversationFallback } from './fallback.ts'
import { registerInboxConversationNodes } from './inbox.ts'
import { registerMessageConversationNode } from './message.ts'
import { registerRetryConversationNode } from './retry.ts'
import { registerToolConversationNode } from './tool.ts'
import { registerTurnErrorConversationNode } from './turn-error.ts'
import { registerTurnTailConversationNode } from './turn-tail.ts'
/**
* Register the Chat business Definitions and target builder contributed by this package.
* @param ctx - owning UI Conversation context.
*/
export function registerConversationNodes(ctx: Context): void {
registerInboxConversationNodes(ctx)
registerMessageConversationNode(ctx)
registerAssistantConversationNode(ctx)
registerToolConversationNode(ctx)
registerCommandConversationNode(ctx)
registerCompactionConversationNode(ctx)
registerRetryConversationNode(ctx)
registerTurnErrorConversationNode(ctx)
registerTurnTailConversationNode(ctx)
registerUnknownConversationFallback(ctx)
registerChatConversationView(ctx)
}

View File

@@ -0,0 +1,116 @@
import type { Context } from 'cordis'
import type {
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { RetryChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Producer-correlated model retry chain. */
'model-retry': RetryChatData
}
}
type WithoutRetryProjection<Node> = Node extends unknown
? Omit<Node, 'kind' | 'seq' | 'time' | 'retryState'>
: never
type RetryEventData = WithoutRetryProjection<ModelRetryNode>
/** Accumulated retry attempts sharing one producer-owned RetryId. */
export interface RetryState {
readonly turn: number
readonly step: number
readonly attempts: readonly ModelRetryNode[]
}
function retryData(value: unknown): RetryEventData | undefined {
if (value === null || typeof value !== 'object') return undefined
const data = value as Record<string, unknown>
if (typeof data.retryId !== 'string' || data.retryId === ''
|| !Number.isSafeInteger(data.turn) || (data.turn as number) < 0
|| !Number.isSafeInteger(data.step) || (data.step as number) < 0
|| !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0
|| typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0
|| typeof data.provider !== 'string' || typeof data.policyKey !== 'string'
|| (data.mode !== 'normal' && data.mode !== 'always')
|| data.failure === null || typeof data.failure !== 'object') return undefined
if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) {
return undefined
}
return data as unknown as RetryEventData
}
function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined {
const data = retryData(event.data)
return data === undefined ? undefined : {
kind: 'model-retry',
seq: event.seq,
time: event.time,
retryState: 'scheduled',
...data,
}
}
function isClosed(location: ConversationLocation): boolean {
return (location.kind === 'step' && location.step.status === 'closed')
|| ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed')
}
/** Producer-correlated model retry chain Definition. */
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
kind: 'model-retry',
match: (event) => {
if ((event.type as string) === 'llm/retry') {
const data = retryData(event.data)
if (data === undefined) return null
return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' }
}
if ((event.type as string) === 'llm/retry-started') {
const data = event.data as unknown as { retryId?: unknown }
return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null
}
return null
},
start: (_context, match) => {
const node = scheduledNode(match.event)
if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event')
return { turn: node.turn, step: node.step, attempts: [node] }
},
update: (context, match) => {
if ((match.event.type as string) === 'llm/retry') {
const node = scheduledNode(match.event)
return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] }
}
if ((match.event.type as string) !== 'llm/retry-started') return context.state
const data = match.event.data as unknown as { retry: number }
return {
...context.state,
attempts: context.state.attempts.map(attempt =>
attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt),
}
},
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const }
const stateAttempts = context.state.attempts
const attempts = stateAttempts.map((attempt, index) =>
index === stateAttempts.length - 1
&& attempt.retryState === 'scheduled'
&& isClosed(location)
? { ...attempt, retryState: 'cancelled' as const }
: attempt)
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)
},
}
/**
* Register the correlated model-retry business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerRetryConversationNode(ctx: Context): void {
ctx.conversationEvents.register(retryDefinition)
}

View File

@@ -0,0 +1,271 @@
import type { Context } from 'cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Root Tool lifecycle with recursively nested subcalls. */
'tool-call': ToolChatData
}
}
const MAX_DEPTH = 256
interface ToolState {
readonly root: ToolCallBlock
readonly children: ReadonlyMap<string, readonly ToolCallBlock[]>
readonly parents: ReadonlyMap<string, string>
}
interface ProjectedBlockCache {
readonly children: readonly ToolCallBlock[]
readonly interruptionSeq: number | undefined
readonly interruptionTime: number | undefined
readonly value: ToolCallBlock
}
const projectedBlocks = new WeakMap<ToolCallBlock, ProjectedBlockCache>()
function jsonArguments(value: unknown): string {
return JSON.stringify(value)
}
function rootCall(match: ConversationMatch): RunningToolCall {
if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call')
return {
callId: String(match.event.data.callId),
name: match.event.data.name,
argsRaw: match.event.data.arguments,
turn: match.event.data.turn,
step: match.event.data.step,
time: match.event.time,
callView: match.view?.for === 'call' ? match.view.view : null,
subCalls: [],
}
}
function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const result = match.event.data.message.content[0]
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
callId: String(match.event.data.message.source.callId),
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
content: result.content,
isError: result.isError === true,
...match.event.data.error === undefined ? {} : { error: match.event.data.error },
meta: match.event.data.meta,
callView: previous?.callView ?? null,
resultView: match.view?.for === 'result' ? match.view.view : null,
subCalls: [],
}
}
interface DispatchData {
readonly parentCallId: string
readonly subCallId: string
readonly name: string
readonly arguments: unknown
readonly isError?: boolean
readonly content?: ToolResultNode['content']
}
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
return {
callId: data.subCallId,
name: data.name,
argsRaw: jsonArguments(data.arguments),
turn: locationTurn(match),
step: locationStep(match),
time: match.event.time,
callView: null,
subCalls: [],
}
}
function childResult(match: ConversationMatch, data: DispatchData, previous?: ToolCallBlock): ToolResultNode {
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: jsonArguments(data.arguments) },
callTime: previous?.time ?? null,
content: data.content ?? [],
isError: data.isError === true,
callView: null,
resultView: null,
subCalls: [],
}
}
function locationTurn(match: ConversationMatch): number {
return match.location.kind === 'step' || match.location.kind === 'turn' ? match.location.turn.turn : 0
}
function locationStep(match: ConversationMatch): number {
return match.location.kind === 'step' ? match.location.step.step : 0
}
function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
if (parent === child || state.parents.has(child)) return false
let cursor: string | undefined = parent
let parentDepth = 0
const ancestors = new Set<string>()
while (cursor !== undefined) {
if (cursor === child || ancestors.has(cursor)) return false
ancestors.add(cursor)
parentDepth++
cursor = state.parents.get(cursor)
}
const pending = [{ callId: child, depth: 1 }]
const descendants = new Set<string>()
let subtreeDepth = 0
for (const candidate of pending) {
if (descendants.has(candidate.callId)) return false
descendants.add(candidate.callId)
subtreeDepth = Math.max(subtreeDepth, candidate.depth)
for (const nested of state.children.get(candidate.callId) ?? []) {
pending.push({ callId: nested.callId, depth: candidate.depth + 1 })
}
}
return parentDepth + subtreeDepth <= MAX_DEPTH
}
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
const data = match.event.data as unknown as DispatchData
const siblings = state.children.get(data.parentCallId) ?? []
const index = siblings.findIndex(candidate => candidate.callId === data.subCallId)
if ((match.event.type as string) === 'tool/code-dispatch-start') {
if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state
const children = new Map(state.children)
children.set(data.parentCallId, [...siblings, childCall(match, data)])
const parents = new Map(state.parents)
parents.set(data.subCallId, data.parentCallId)
return { ...state, children, parents }
}
if ((match.event.type as string) !== 'tool/code-dispatch') return state
if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state
const previous = index < 0 ? undefined : siblings[index]
const settled = childResult(match, data, previous)
const children = new Map(state.children)
children.set(data.parentCallId, index < 0
? [...siblings, settled]
: siblings.map((child, at) => at === index ? settled : child))
const parents = new Map(state.parents)
if (index < 0) parents.set(data.subCallId, data.parentCallId)
return { ...state, children, parents }
}
function projectBlock(
block: ToolCallBlock,
state: ToolState,
interruptedAt: { seq: number; time: number } | undefined,
visited = new Set<string>(),
depth = 1,
): ToolCallBlock {
if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] }
const nextVisited = new Set(visited)
nextVisited.add(block.callId)
const children = (state.children.get(block.callId) ?? block.subCalls)
.map(child => projectBlock(child, state, interruptedAt, nextVisited, depth + 1))
const interruptionSeq = 'kind' in block ? undefined : interruptedAt?.seq
const interruptionTime = 'kind' in block ? undefined : interruptedAt?.time
const cached = projectedBlocks.get(block)
if (cached !== undefined
&& cached.interruptionSeq === interruptionSeq
&& cached.interruptionTime === interruptionTime
&& sameReferences(cached.children, children)) {
return cached.value
}
const projected: ToolCallBlock = 'kind' in block || interruptedAt === undefined
? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children }
: {
kind: 'tool-result',
seq: interruptedAt.seq - 0.8,
time: interruptedAt.time,
callId: block.callId,
call: { name: block.name, argsRaw: block.argsRaw },
callTime: block.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: block.callView,
resultView: null,
subCalls: children,
}
projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected })
return projected
}
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function interruption(context: ConversationNodeContext<ToolState>): { seq: number; time: number } | undefined {
const location = context.start?.location
if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
if ((location?.kind === 'step' || location?.kind === 'turn') && location.turn.status === 'closed') {
return location.turn.end
}
return undefined
}
function fallbackState(context: ConversationNodeContext<ToolState>): ToolState | undefined {
const match = context.matches.find(candidate => candidate.event.type === 'tool/result')
const root = match === undefined ? undefined : rootResult(match)
if (root === undefined) return undefined
let state: ToolState = { root, children: new Map(), parents: new Map() }
for (const candidate of context.matches) state = updateDispatch(state, candidate)
return state
}
/** Root Tool lifecycle and nested Code Dispatch Definition. */
export const toolDefinition: ConversationNodeDefinition<ToolState> = {
kind: 'tool-call',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
return { id: String(event.data.message.source.callId), role: 'update' }
}
if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as { rootCallId: string }
return { id: data.rootCallId, role: 'update' }
}
return null
},
start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }),
update: (context, match) => {
if (match.event.type === 'tool/result') {
const running = 'kind' in context.state.root ? undefined : context.state.root
const result = rootResult(match, running)
return result === undefined ? context.state : { ...context.state, root: result }
}
return updateDispatch(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const projected = projectBlock(state.root, state, interruption(context))
const anchor = context.start?.event.seq
?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0)
return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData)
},
}
/**
* Register the root Tool lifecycle and nested-subcall contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerToolConversationNode(ctx: Context): void {
ctx.conversationEvents.register(toolDefinition)
}

View File

@@ -0,0 +1,112 @@
import type { Context } from 'cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Terminal turn failure not superseded by retry. */
'turn-error': TurnErrorNode
}
}
interface TurnErrorState {
readonly turn: number
readonly hidden: boolean
readonly failure?: {
readonly seq: number
readonly time: number
readonly message: string
readonly code?: string
}
}
function lastStep(context: ConversationNodeContext<TurnErrorState>): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return 0
return location.turn.steps.at(-1)?.step ?? 0
}
function retryTurn(event: Parameters<ConversationNodeDefinition['match']>[0]): number | undefined {
if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined
const turn = (event.data as unknown as { turn?: unknown }).turn
return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined
}
function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined
const failure = match.event.data.reason.error
return {
seq: match.event.seq,
time: match.event.time,
message: displayFailureMessage(failure),
code: failure.code,
}
}
function fallbackState(context: ConversationNodeContext<TurnErrorState>): TurnErrorState | undefined {
const end = context.matches.find(match => failureFrom(match) !== undefined)
if (end?.event.type !== 'turn/end') return undefined
const failure = failureFrom(end)
if (failure === undefined) return undefined
const turn = end.event.data.turn
return {
turn,
hidden: context.matches.some(match => retryTurn(match.event) === turn),
failure,
}
}
/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */
export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
kind: 'turn-error',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
return { id: String(event.data.turn), role: 'update' }
}
const turn = retryTurn(event)
return turn === undefined ? null : { id: String(turn), role: 'update' }
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('turn-error start requires turn/start')
return { turn: match.event.data.turn, hidden: false }
},
update: (context, match) => {
const failure = failureFrom(match)
if (failure !== undefined) return { ...context.state, failure }
return retryTurn(match.event) === context.state.turn
? { ...context.state, hidden: true }
: context.state
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state?.failure === undefined) return null
const failure = state.failure
const node: TurnErrorNode = {
kind: 'turn-error',
seq: failure.seq,
time: failure.time,
turn: state.turn,
step: lastStep(context),
message: failure.message,
...failure.code === undefined ? {} : { code: failure.code },
}
if (!state.hidden) return chatNode(context, 'turn-error', node.seq, node)
const current = context.current.get('chat')
return current === undefined || current === null
? null
: chatNode(context, 'turn-error', node.seq, node, { visibility: 'hidden' })
},
}
/**
* Register the terminal Turn-error business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnErrorConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnErrorDefinition)
}

View File

@@ -0,0 +1,180 @@
import type { Context } from 'cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client'
import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Completed-turn actions and extension tail. */
'turn-tail': TurnTailChatData
}
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationTurnDataMap {
/** Closing Assistant and footer facts derived for this completed Turn. */
'turn-tail': TurnTailChatData
}
}
interface TurnTailState {
readonly turn: number
readonly end?: ConversationMatch
}
interface StepEvidence {
readonly streamedText: boolean
readonly finalized: boolean
}
function hasTextAssistant(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
return event.type === 'assistant/message'
&& isAppendSurfaceEvent(event)
&& toAssistantBlocks(event.data.message.content)
.some(block => block.kind === 'text' && block.text.trim() !== '')
}
function chunkHasText(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
if (event.type !== 'assistant/chunk') return false
const chunk = event.data.chunk
if (chunk.type === 'text-delta') return chunk.text.trim() !== ''
return chunk.type === 'block-end'
&& chunk.block.type === 'text'
&& chunk.block.text.trim() !== ''
}
function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[0]): {
readonly turn: number
readonly step?: number
} | undefined {
if (event.type === 'assistant/message'
|| event.type === 'assistant/chunk'
|| event.type === 'step/end') {
return { turn: event.data.turn, step: event.data.step }
}
if ((event.type as string) === 'llm/retry') {
return event.data as unknown as { turn: number; step: number }
}
return undefined
}
function closingAnchor(context: ConversationNodeContext<TurnTailState>): number {
let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq
?? context.start?.event.seq
?? context.matches[0]?.event.seq
?? 0
const steps = new Map<number, StepEvidence>()
for (const match of context.matches) {
const event = match.event
if (event.type === 'turn/end') continue
const coordinates = turnCoordinates(event)
if (coordinates?.step === undefined) continue
const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false }
if (event.type === 'assistant/chunk') {
steps.set(coordinates.step, {
...previous,
streamedText: previous.streamedText || chunkHasText(event),
})
continue
}
if (event.type === 'assistant/message') {
steps.set(coordinates.step, { streamedText: false, finalized: true })
if (hasTextAssistant(event)) anchor = event.seq + 0.1
continue
}
if ((event.type as string) === 'llm/retry') {
steps.set(coordinates.step, { streamedText: false, finalized: false })
continue
}
if (event.type === 'step/end' && previous.streamedText && !previous.finalized) {
anchor = event.seq - 0.8
}
}
return anchor
}
function turnLocation(context: ConversationNodeContext<TurnTailState>): TurnLocation | undefined {
const location = context.start?.location ?? context.matches[0]?.location
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
}
function hasText(data: AssistantChatData): data is FinalAssistantChatData {
return data.finalNode !== undefined
&& data.blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChatData | null {
const end = context.state?.end
?? context.matches.find(match => match.event.type === 'turn/end')
if (end?.event.type !== 'turn/end') return null
const turn = turnLocation(context)
if (turn === undefined) return null
const assistants = turn.steps
.map(step => step.data.get('assistant-step'))
.filter((candidate): candidate is Readonly<AssistantChatData> => candidate !== undefined)
const finalized = assistants
.filter((candidate): candidate is Readonly<FinalAssistantChatData> => candidate.finalNode !== undefined)
.sort((left, right) => left.finalNode.seq - right.finalNode.seq)
const closing = finalized.findLast(hasText) ?? null
const latest = finalized.at(-1)
const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn)
return {
turn: end.event.data.turn,
seq: end.event.seq,
time: end.event.time,
closing,
branchUnavailable: closing === null || latest?.finalNode.seq !== closing.finalNode.seq,
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
}
}
/** Completed-turn footer Definition independent of any Assistant row. */
export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
kind: 'turn-tail',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' }
const coordinates = turnCoordinates(event)
if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' }
return null
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('turn-tail start requires turn/start')
return { turn: match.event.data.turn }
},
update: (context, match) => match.event.type === 'turn/end'
? { ...context.state, end: match }
: context.state,
publication: match => match.event.type === 'turn/end' ? 'immediate' : 'none',
buildLocationData: (context, scope) => {
if (scope !== 'turn') return null
const value = tailData(context)
return value === null ? null : {
kind: 'turn',
turn: value.turn,
key: 'turn-tail',
value,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const turn = turnLocation(context)
const data = turn?.data.get('turn-tail')
return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data)
},
}
/**
* Register completed-Turn footer data and its Chat node contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnTailConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnTailDefinition)
}

View File

@@ -5,6 +5,17 @@
*/ */
export { apply, inject } from './apply.ts' export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts' export { ConversationService } from './service.ts'
export { registerAssistantConversationNode } from './conversation-nodes/assistant.ts'
export { registerChatConversationView } from './conversation-nodes/chat-snapshot-builder.ts'
export { registerCommandConversationNode } from './conversation-nodes/command.ts'
export { registerCompactionConversationNode } from './conversation-nodes/compaction.ts'
export { registerUnknownConversationFallback } from './conversation-nodes/fallback.ts'
export { registerInboxConversationNodes } from './conversation-nodes/inbox.ts'
export { registerMessageConversationNode } from './conversation-nodes/message.ts'
export { registerRetryConversationNode } from './conversation-nodes/retry.ts'
export { registerToolConversationNode } from './conversation-nodes/tool.ts'
export { registerTurnErrorConversationNode } from './conversation-nodes/turn-error.ts'
export { registerTurnTailConversationNode } from './conversation-nodes/turn-tail.ts'
export type { IConversation } from './service.ts' export type { IConversation } from './service.ts'
export type { export type {
@@ -12,12 +23,16 @@ export type {
} from './contract/views.ts' } from './contract/views.ts'
export type { ConversationKey } from './locales.ts' export type { ConversationKey } from './locales.ts'
export type { export type {
ChatFileMentions, AssistantChatData, ChatNode, ChatNodeDataMap, ChatNodeKind, ManualCompactionChatData,
RetryChatData, ToolChatData, TurnTailChatData,
} from './contract/chat-nodes.ts'
export type {
ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected, ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
ToolTreeOwnerProps, TurnTailOwnerProps, TurnTailOwnerProps, UseChatNodeTurnData,
} from './contract/slots.ts' } from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md. // Export discipline: packages/client/AGENTS.md.

View File

@@ -17,6 +17,7 @@ import { useMemo, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
import { rootToolCall } from '../chat/tool-node-reader.ts'
import css from './ApprovalPanel.module.css' import css from './ApprovalPanel.module.css'
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ /** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
@@ -40,8 +41,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined
*/ */
export function ApprovalPanel(props: ApprovalComposerProps) { export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf( const command = props.useSession((snapshot) => {
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId))) if (approval.callId === undefined) return undefined
const root = rootToolCall(snapshot, approval.callId)
if (root === undefined) return undefined
return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined
})
return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} /> return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
} }

View File

@@ -12,6 +12,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts' import type { DetailsSlotProps } from '../contract/slots.ts'
import { findToolCall } from '../chat/tool-node-reader.ts'
import css from './DetailsPanel.module.css' import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (automatic shares & injected share). */ /** Full props composed by reference from the contract (automatic shares & injected share). */
@@ -40,30 +41,10 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
return { name: call.name, argsRaw: call.argsRaw, block: call } return { name: call.name, argsRaw: call.argsRaw, block: call }
} }
function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined {
if (block.callId === callId) return block
for (const child of block.subCalls) {
const found = findCall(child, callId)
if (found !== undefined) return found
}
return undefined
}
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
for (const node of s.nodes) { const found = findToolCall(s, callId)
if (node.kind !== 'tool-result') continue if (found === undefined) return null
const found = findCall(node, callId) return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
}
for (const root of s.runningCalls) {
const found = findCall(root, callId)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
}
return null
} }
function pretty(raw: string): string { function pretty(raw: string): string {

View File

@@ -17,7 +17,7 @@ export const inject = ['invariants']
/** /**
* No runtime invariant: the conversation service emits no cordis events, and * No runtime invariant: the conversation service emits no cordis events, and
* both rings this package owns (the 'conversation.view' tab ring and the * both rings this package owns (the 'conversation.view' tab ring and the
* 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger * 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger
* invariants live with the runtime slots package. * invariants live with the runtime slots package.
*/ */
const install: InvariantInstaller = () => {} const install: InvariantInstaller = () => {}

View File

@@ -12,7 +12,9 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c
import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ProducedFiles } from './ProducedFiles.tsx' import { ProducedFiles } from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' import {
deliverablesDefinition, producedFileMentions, selectProducedFiles,
} from './turn-deliverables.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { interface LocaleNamespaceMap {
@@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts' export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */ /** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale'] export const inject = ['slots', 'locale', 'conversationEvents']
/** /**
* Client plugin body: register the dictionaries and the turn-tail entry. * Client plugin body: register the dictionaries and the turn-tail entry.
* @param ctx - client root context. * @param ctx - client root context.
*/ */
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject( ctx.slots.inject(
'conversation.chat.turnTail', 'conversation.chat.turnTail',

View File

@@ -1,12 +1,37 @@
/** /**
* Pure derivation of one turn's produced files from finalized snapshot * Turn-scoped produced-file Definition and readers. Client-only and
* nodes. Client-only and model-free: the vocabulary is the mutation tools' * model-free: the vocabulary is the mutation tools' own follow-along
* own follow-along `locations`, never the closing prose. * `locations`, never the closing prose.
*/ */
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type {
ConversationNodeDefinition, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
interface ProducedPath {
readonly seq: number
readonly path: string
}
/** Immutable produced-file facts published against one Turn. */
export interface DeliverablesTurnData {
readonly produced: readonly ProducedPath[]
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationTurnDataMap {
/** Successful mutation paths accumulated in this Turn. */
deliverables: DeliverablesTurnData
}
}
interface DeliverablesState extends DeliverablesTurnData {
readonly turn: number
readonly calls: ReadonlyMap<string, ToolResultNode['callView']>
}
/** /**
* Paths a call view reports having created or changed, by render intent rather * Paths a call view reports having created or changed, by render intent rather
* than tool name: a diff card, or a generic card whose kind is `edit` (the * than tool name: a diff card, or a generic card whose kind is `edit` (the
@@ -23,9 +48,7 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] {
} }
/** /**
* Files produced by the turn the assistant at `seq` closes — the anchor the * Files produced by one Turn data value.
* render site elects, so the row lands under the message that reports the
* work rather than after some mid-turn narration.
* *
* The source is the mutation tools' own follow-along `locations`, not the * The source is the mutation tools' own follow-along `locations`, not the
* closing prose: a produced file must be listed whether or not the model * closing prose: a produced file must be listed whether or not the model
@@ -37,46 +60,26 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] {
* failed calls. Paths keep first-seen order and appear once, so a file written * failed calls. Paths keep first-seen order and appear once, so a file written
* and then edited in the same turn is one entry. * and then edited in the same turn is one entry.
* *
* Accumulation resets on the turn boundary — a user message, or a node * The Conversation Location index owns turn membership before this function
* reporting a different turn number — so a turn that mutates files and then * runs, so paths cannot spill across turns and this derivation does not infer
* ends without content text cannot spill its paths into the next turn's row, * boundaries from neighboring presentation Nodes.
* nor leave the dedup set suppressing a file the next turn legitimately * @param data - engine-published Deliverables data for one Turn.
* rewrites. Tool results carry no turn of their own; the boundary is read off * @param seq - closing Assistant seq; later Tool settlements are excluded.
* the nodes that do, and a user message resets the tracked turn to undefined
* because the next node to report one is stating the current turn, not
* entering a new one.
* @param nodes - snapshot nodes (surface order).
* @param seq - the closing assistant's seq (the render site's anchor).
* @returns Produced paths in first-seen order; empty when the turn wrote nothing. * @returns Produced paths in first-seen order; empty when the turn wrote nothing.
*/ */
export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { export function producedForClosing(
let pending: string[] = [] data: Readonly<DeliverablesTurnData> | undefined,
let seen = new Set<string>() seq = Number.POSITIVE_INFINITY,
let turn: number | undefined ): readonly string[] {
for (const node of nodes) { if (data === undefined) return []
if (node.kind === 'tool-result') { const paths: string[] = []
if (node.isError) continue const seen = new Set<string>()
for (const path of producedPaths(node.callView)) { for (const produced of data.produced) {
if (seen.has(path)) continue if (produced.seq > seq || seen.has(produced.path)) continue
seen.add(path) seen.add(produced.path)
pending.push(path) paths.push(produced.path)
}
continue
}
if (node.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if ('turn' in node) {
if (turn !== undefined && node.turn !== turn) {
pending = []
seen = new Set()
}
turn = node.turn
}
if (node.kind === 'assistant' && node.seq === seq) return pending
} }
return [] return paths
} }
/** /**
@@ -85,11 +88,55 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb
* @returns Produced paths as the component's match, or null to decline before mount. * @returns Produced paths as the component's match, or null to decline before mount.
*/ */
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
const { nodes, seq } = owner const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq)
const paths = producedForClosing(nodes, seq)
return paths.length === 0 ? null : paths return paths.length === 0 ? null : paths
} }
/** Turn-local successful mutation accumulator; it publishes no view Node. */
export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesState> = {
kind: 'deliverables',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
return { id: String(event.data.turn), role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start')
return { turn: match.event.data.turn, calls: new Map(), produced: [] }
},
update: (context, match) => {
if (match.event.type === 'tool/call') {
const calls = new Map(context.state.calls)
calls.set(
String(match.event.data.callId),
match.view?.for === 'call' ? match.view.view : null,
)
return { ...context.state, calls }
}
if (match.event.type !== 'tool/result') return context.state
const result = match.event.data.message.content[0]
if (result.isError === true) return context.state
const callId = String(match.event.data.message.source.callId)
const additions = producedPaths(context.state.calls.get(callId) ?? null)
.map(path => ({ seq: match.event.seq, path }))
return additions.length === 0
? context.state
: { ...context.state, produced: [...context.state.produced, ...additions] }
},
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined
? null
: {
kind: 'turn',
turn: context.state.turn,
key: 'deliverables',
value: { produced: context.state.produced },
},
buildViewNode: () => null,
}
/** /**
* Trailing path segment, the part that identifies the file at a glance. * Trailing path segment, the part that identifies the file at a glance.
* @param path - Slash- or backslash-separated path. * @param path - Slash- or backslash-separated path.

View File

@@ -20,8 +20,9 @@ export const inject = ['slots']
* @param ctx - Client root context. * @param ctx - Client root context.
*/ */
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({ ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.tool', name: 'conversation.chat.node',
key: 'tool-call',
locale: NS, locale: NS,
children: { children: {
'tool.call.toolview': { kind: 'keyed', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' },

View File

@@ -30,8 +30,8 @@ export interface ToolCallOwnerProps {
/** Full props of a registered atomic Tool view. */ /** Full props of a registered atomic Tool view. */
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
/** Full props of the Tool call-tree renderer registered into the chat flow. */ /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */
export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'> export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
& PropsRenderSlots<'tool.call.toolview'> & PropsRenderSlots<'tool.call.toolview'>
& PropsLocale<'conversation'> & PropsLocale<'conversation'>

View File

@@ -88,8 +88,9 @@ const ToolCallBranch = memo(function ToolCallBranch({
* @returns the Tool call tree. * @returns the Tool call tree.
*/ */
export function ToolCallTree({ export function ToolCallTree({
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t, renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) { }: ToolTreeProps) {
const block = node.data.root
return ( return (
<ToolCallBranch <ToolCallBranch
renderSlot={renderSlot} renderSlot={renderSlot}