refactor(ui-conversation): register business node definitions

This commit is contained in:
imccyu
2026-08-09 18:13:52 +08:00
parent 81d6886006
commit 6d09b3168d
41 changed files with 2744 additions and 745 deletions
@@ -44,6 +44,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^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-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7",
@@ -53,6 +54,7 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -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 { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected,
ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected,
DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { createChatStore } from './stores.ts'
@@ -30,6 +31,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
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' {
interface LocaleNamespaceMap {
@@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** 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
// constants so the render side's per-source hook cache (observableHook) keeps
@@ -63,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = {
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. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
@@ -86,6 +105,9 @@ export function apply(ctx: Context): void {
const layout = ctx.layout
const slots = ctx.slots
registerConversationNodes(ctx)
registerChatNodeRenderers(ctx)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
// 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)
// The chat view: first entry of the ring this package just declared.
// ChatView owns ordered Tool placement but delegates each whole root call
// to ui-tool, which owns root/subcall composition and atomic dispatch.
// ChatView owns only the stable ordered Node list. Business renderers are
// independently keyed behind its one Node seat.
slots.register({
name: 'conversation.view',
id: 'chat',
@@ -305,9 +327,7 @@ export function apply(ctx: Context): void {
label: () => t('view.chat'),
locale: NS,
children: {
'conversation.chat.tool': { kind: 'single', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
@@ -11,12 +11,9 @@
import { memo, useMemo } from 'react'
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 type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -25,61 +22,19 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants and every node
* 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
/** Resolved prose file mentions for this Assistant's closing turn. */
mentions?: MarkdownFileMentions | undefined
/** The owning view's locale seat, passed down as a plain prop. */
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). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
fileMentions, t,
blocks, streaming, interrupted, mentions, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
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
// 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
@@ -88,10 +43,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
// Footer only under settled content text; Think-only / streaming omit it.
const showActions = !streaming && time !== undefined && hasContentText(blocks)
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}>
{blocks.map((block, i) => {
switch (block.kind) {
@@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
})}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</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>
)
})
@@ -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}
/>
)
})
@@ -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>
)
})
@@ -1,6 +1,5 @@
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
runs) via the column gap and between consecutive tool rows via the group
gap. Input padding cap rides the skeleton. Under
/* Chat flow: one 16px rhythm everywhere through the column gap. Input
padding cap rides the skeleton. Under
`[data-conversation-scroll]` the column host owns overflow and this view
is ordinary flow (see ConversationRoot active-phase rules). */
@@ -51,10 +50,11 @@
min-width: 0;
}
.toolGroup {
display: flex;
flex-direction: column;
gap: 16px;
/* A keyed renderer may intentionally decline its row after dispatch (the
completed-turn tail does this when it owns neither actions nor extensions).
An empty flex item must not consume the column gap. */
.flowItem:empty {
display: none;
}
.callRow {
@@ -1,41 +1,24 @@
// ChatView: the default conversation view — message flow with user bubbles,
// assistant narration, tool summary rows grouped into step runs, pending
// cards, paging, and bottom-follow. Session stats live on
// 'conversation.composer.dock' (sticky with the composer). Pure component
// 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.
// ChatView: the default conversation view — one stable keyed parent list over
// final business Nodes, plus paging, pending steering and bottom-follow.
// Each row dispatches through 'conversation.chat.node'; ui-tool owns the
// tool-call renderer and its recursive root/subcall composition.
//
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
// column), that host is the scrollport and this view is flow content; when
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
// prepend anchoring always target the resolved scrollport.
//
// Render economics (architecture RFC performance model): the list parent
// subscribes to snapshot segments that do NOT change per streaming chunk
// (nodes/runningCalls/pending keep their references across chunk batches), so
// 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.
// Render economics: order changes only when rows enter, leave or move. Each
// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool
// lifecycle updates replace only their own row without remounting it.
import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} 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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { PendingSteeringBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
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']>>
/** 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. */
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
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. */
const ToolSeat = memo(function ToolSeat({
renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall,
}: {
renderSlot: RenderChatSlot
callId: string
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>
)
})
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
let latest: number | null = null
for (const turn of timeline.turns.values()) {
if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time
}
return latest
}
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
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
* ordered root Tool call crosses the declared whole-Tool render seat.
* ordered business Node crosses the keyed renderer seat.
*/
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,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
const turnEnds = useSession(s => s.turnEnds)
const order = useSession(s => s.chat.order)
const nodeStore = useSession(s => s.chat.nodes)
const timeline = useSession(s => s.chat.timeline)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
const openState = useSession(s => s.openState)
const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const pendingSteering = useMemo(
() => inbox.filter(item => item.placement === 'steering'),
[inbox],
)
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// 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 runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
const listRef = 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). */
const followSigRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const lastKey = lastItem?.key ?? null
const firstKey = order[0]
const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
const lastKey = order.at(-1) ?? null
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
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 => {
anchorRef.current = null
@@ -377,8 +250,7 @@ export function ChatView({
firstSeqRef.current = firstSeq
// 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).
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
@@ -490,71 +362,6 @@ export function ChatView({
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 (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
@@ -572,43 +379,21 @@ export function ChatView({
</button>
</div>
)}
{items.map(item => (
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node'
? `node:${String(item.node.seq)}`
: item.kind === 'command-compaction'
? `node:${String(item.compaction.seq)}`
: undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node'
? item.node.kind
: item.kind === 'command-compaction'
? item.kind
: 'tool-group'}
>
{renderItem(item)}
</div>
{order.map(nodeKey => (
<ChatNodeSeat
key={nodeKey}
nodeKey={nodeKey}
useSession={useSession}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={openFile}
inspectCall={inspectCall}
forkAt={forkAt}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
/>
))}
<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
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
@@ -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>
)
})
@@ -7,30 +7,15 @@
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
ModelRetryNode, TurnErrorNode,
} from '@deepseek-ai/dsh-client-runtime/client'
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 { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
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[] } {
const texts: string[] = []
const rest: unknown[] = []
@@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: {
)
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user':
case 'steering':
return (
<UserStyleBubble
content={node.content}
steering={node.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={node.time}
clock="start"
className={css.actions}
t={t}
/>
)}
/>
)
case 'context':
return (
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
return (
<UserStyleBubble
content={data.content}
steering={data.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={data.time}
clock="start"
className={css.actions}
t={t}
/>
)
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
case 'turn-error':
return <TurnErrorItem node={node} t={t} />
default:
return (
<div className={css.contextRow}>
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
</div>
)
}
)}
/>
)
})
/** Injected-context keyed Chat renderer. */
export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
const data = node.data
return (
<ContextInjectionRow
content={data.content}
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>
)
})
@@ -157,9 +157,9 @@ export interface 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 stats = useMemo(() => deriveStats(nodes), [nodes])
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = []
if (stats.steps > 0) {
@@ -0,0 +1,9 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.actions {
margin-left: -6px;
}
@@ -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>
)
})
@@ -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('|')
}
@@ -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))
}
@@ -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
}
@@ -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('')
}
@@ -1,6 +1,6 @@
// 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. */
export interface TurnMetrics {
@@ -24,7 +24,7 @@ interface UsageLike {
outputTokens?: number
}
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
type AssistantNode = AssistantMessageNode
function usageOutputTokens(usage: unknown): number | null {
if (typeof usage !== 'object' || usage === null) return null
@@ -67,7 +67,7 @@ interface TurnFold {
* @param nodes - Snapshot nodes of the loaded window.
* @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>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
@@ -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)
}
@@ -1,15 +1,21 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
SlotHookFactory, SnapshotSelectorHook,
} 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 {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.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'
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.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
* One root Tool call at its ordered ChatFlow position. The chat view owns
* placement; ui-tool owns root/subcall composition and keyed dispatch.
* The filler preserves the call-anchor DOM contract documented by
* {@link ToolTreeOwnerProps} for every root and child wrapper.
*/
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
/** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */
'conversation.chat.node': {
kind: 'keyed'
scope: 'session'
owner: ChatNodeOwnerProps
keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
hookContext: string
inject: ChatNodeTurnDataInjected
}
/**
* 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
@@ -171,7 +179,7 @@ export interface ConvViewOwnerProps {
export interface ChatFileMentions {
/**
* 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
* produced nothing worth linking.
*/
@@ -186,14 +194,13 @@ declare module 'cordis' {
}
/**
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
* and the closing assistant's anchor. Registrants derive their own facts
* from the nodes (the owner never pre-chews a feature's vocabulary), and
* open files through the same opener the tool rows use.
* Owner currency of the chat view's turn-tail hole: the engine-owned Turn and
* the closing assistant's anchor. Registrants read their own typed Turn data
* and open files through the same opener the tool rows use.
*/
export interface TurnTailOwnerProps {
/** Finalized snapshot nodes in surface order. */
nodes: readonly ConversationNode[]
/** Engine-owned closing Turn boundary. */
turn: TurnLocation
/** The closing assistant's seq — the anchor the tail renders under. */
seq: number
/**
@@ -203,34 +210,34 @@ export interface TurnTailOwnerProps {
openFile: (path: string) => void
}
/**
* Owner currency of the chat view's whole-Tool rendering seat. The filler
* wraps every rendered root and child with `data-chat-anchor-key="call:<id>"`
* and `data-chat-call-id="<id>"`, plus `data-selected="true"` for the selected
* call. ChatView consumes those anchors to restore prepend/paging position.
*/
export interface ToolTreeOwnerProps {
/** Root Tool call identity, stable across running → settled. */
callId: CallId
/** 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
/** Hook constrained to business data published on the current Chat Node's Turn. */
export type UseChatNodeTurnData = <Key extends keyof ConversationTurnDataMap & string>(
key: Key,
) => Readonly<ConversationTurnDataMap[Key]> | undefined
/** Slot-level Hook factory used by renderers reading their Node's Turn data. */
export interface ChatNodeTurnDataInjected {
hooks: {
turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData>
}
}
/** 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. */
export interface DetailsToolOwnerProps {
/** 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. */
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'>
/**
@@ -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)
}
@@ -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)
}
@@ -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 }
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -5,6 +5,17 @@
*/
export { apply, inject } from './apply.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 {
@@ -12,12 +23,16 @@ export type {
} from './contract/views.ts'
export type { ConversationKey } from './locales.ts'
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,
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
ToolTreeOwnerProps, TurnTailOwnerProps,
TurnTailOwnerProps, UseChatNodeTurnData,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
@@ -17,6 +17,7 @@ import { useMemo, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
import { rootToolCall } from '../chat/tool-node-reader.ts'
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. */
@@ -40,8 +41,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined
*/
export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf(
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
const command = props.useSession((snapshot) => {
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 }} />
}
@@ -12,6 +12,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } 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 { findToolCall } from '../chat/tool-node-reader.ts'
import css from './DetailsPanel.module.css'
/** 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 }
}
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 {
for (const node of s.nodes) {
if (node.kind !== 'tool-result') continue
const found = findCall(node, callId)
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
const found = findToolCall(s, callId)
if (found === undefined) return null
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
function pretty(raw: string): string {
@@ -17,7 +17,7 @@ export const inject = ['invariants']
/**
* No runtime invariant: the conversation service emits no cordis events, and
* 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.
*/
const install: InvariantInstaller = () => {}
@@ -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 { ProducedFiles } from './ProducedFiles.tsx'
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' {
interface LocaleNamespaceMap {
@@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** 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.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
'conversation.chat.turnTail',
@@ -1,12 +1,37 @@
/**
* Pure derivation of one turn's produced files from finalized snapshot
* nodes. Client-only and model-free: the vocabulary is the mutation tools'
* own follow-along `locations`, never the closing prose.
* Turn-scoped produced-file Definition and readers. Client-only and
* model-free: the vocabulary is the mutation tools' own follow-along
* `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 { 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
* 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
* render site elects, so the row lands under the message that reports the
* work rather than after some mid-turn narration.
* Files produced by one Turn data value.
*
* 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
@@ -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
* and then edited in the same turn is one entry.
*
* Accumulation resets on the turn boundary — a user message, or a node
* reporting a different turn number — so a turn that mutates files and then
* ends without content text cannot spill its paths into the next turn's row,
* nor leave the dedup set suppressing a file the next turn legitimately
* rewrites. Tool results carry no turn of their own; the boundary is read off
* 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).
* The Conversation Location index owns turn membership before this function
* runs, so paths cannot spill across turns and this derivation does not infer
* boundaries from neighboring presentation Nodes.
* @param data - engine-published Deliverables data for one Turn.
* @param seq - closing Assistant seq; later Tool settlements are excluded.
* @returns Produced paths in first-seen order; empty when the turn wrote nothing.
*/
export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] {
let pending: string[] = []
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.isError) continue
for (const path of producedPaths(node.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(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
export function producedForClosing(
data: Readonly<DeliverablesTurnData> | undefined,
seq = Number.POSITIVE_INFINITY,
): readonly string[] {
if (data === undefined) return []
const paths: string[] = []
const seen = new Set<string>()
for (const produced of data.produced) {
if (produced.seq > seq || seen.has(produced.path)) continue
seen.add(produced.path)
paths.push(produced.path)
}
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.
*/
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
const { nodes, seq } = owner
const paths = producedForClosing(nodes, seq)
const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq)
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.
* @param path - Slash- or backslash-separated path.
+3 -2
View File
@@ -20,8 +20,9 @@ export const inject = ['slots']
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({
name: 'conversation.chat.tool',
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'tool-call',
locale: NS,
children: {
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
@@ -30,8 +30,8 @@ export interface ToolCallOwnerProps {
/** Full props of a registered atomic Tool view. */
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
/** Full props of the Tool call-tree renderer registered into the chat flow. */
export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'>
/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */
export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
& PropsRenderSlots<'tool.call.toolview'>
& PropsLocale<'conversation'>
@@ -88,8 +88,9 @@ const ToolCallBranch = memo(function ToolCallBranch({
* @returns the Tool call tree.
*/
export function ToolCallTree({
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) {
const block = node.data.root
return (
<ToolCallBranch
renderSlot={renderSlot}