Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
#	apps/web/tests/scaffold.ts
#	docs/module-graph.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/package.json
#	packages/client/test-runtime/README.i18n.yaml
#	packages/client/test-runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-theme/README.i18n.yaml
#	packages/client/ui-theme/README.md
#	packages/client/ui-theme/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 12:50:43 +08:00
3485 changed files with 86192 additions and 25255 deletions

View File

@@ -9,8 +9,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'
@@ -32,6 +33,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'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -42,7 +45,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection']
export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
'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
@@ -66,6 +72,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)
@@ -89,6 +108,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
@@ -143,7 +165,7 @@ export function apply(ctx: Context): void {
// way: this package must not import the plugins that would know.
const composerBlocks = new ComposerBlockRegistry()
// Decision 19/20: the input machine feeds every session-scope slot
// The input machine feeds every session-scope slot
// component through the standard provide channel — the 'input' hook plus
// the two public actions. Materialization is the shell creation trigger
// (per-session lazy; scope disposer tears down).
@@ -175,6 +197,7 @@ export function apply(ctx: Context): void {
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
@@ -224,7 +247,7 @@ export function apply(ctx: Context): void {
}, ConversationSessionHeader)
// The default composer body: its own single slot inside the composer
// chain's fallback (decision 20). Public machine surface arrives via the
// chain's fallback. Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
// verbs ride this inject (package-internal — hub and bar are one plugin).
// Session-maybe: with no current session the machine faces are absent and
@@ -235,7 +258,7 @@ export function apply(ctx: Context): void {
locale: NS,
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register (B ruling).
// register.
children: {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
@@ -301,8 +324,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',
@@ -310,9 +333,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 => {
@@ -322,6 +343,7 @@ export function apply(ctx: Context): void {
actions.select(target)
layout.openDetails()
},
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {
@@ -366,8 +388,8 @@ export function apply(ctx: Context): void {
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
// The read-only queue dock entry (T9 file territory) rides the same
// registration seam into the input dock declared above.
// The read-only queue dock entry rides the same
// registration path into the input dock declared above.
ctx.plugin(queueDockEntry)
slots.register({

View File

@@ -11,11 +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 { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -24,41 +22,15 @@ 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
/** 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, 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.
@@ -71,15 +43,19 @@ 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) {
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
@@ -96,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>
)
})

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
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 {

View File

@@ -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,51 +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, t,
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)
@@ -307,15 +180,16 @@ export function ChatView({
const lastKeyRef = useRef<string | null>(null)
const lastSteeringIdRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (that was snapping inertial
* scroll-driven at-bottom chrome re-render (which would snap inertial
* 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
@@ -376,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
@@ -489,70 +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}
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}>
@@ -570,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. */}

View File

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

View File

@@ -3,7 +3,7 @@
// marker reports where the model stopped seeing that history — it never
// replaces it. The framed checkpoint payload is written for the model and is
// not rendered; the disclosure shows the summary from the checkpoint's own
// provenance, and a window cut that left that provenance outside makes the row
// cited `compact/summary` event, and a window cut that left that event outside makes the row
// non-expandable rather than empty.
import { memo, useState } from 'react'

View File

@@ -9,7 +9,7 @@
overflow-wrap: anywhere;
}
/* Provenance beneath the text: dimmer than the content it describes. */
/* Source fields beneath the text: dimmer than the content they describe. */
.fields {
display: flex;
flex-direction: column;

View File

@@ -66,7 +66,7 @@ function boundedText(text: string, t: Translate): string {
/**
* One source field rendered as a value row; nested shapes stay compact JSON.
* Bounded on its own, because provenance is as unbounded as the text: an unknown
* Bounded on its own, because source fields are as unbounded as the text: an unknown
* producer may record an arbitrarily large string or array.
*/
function fieldValue(value: unknown, t: Translate): string {
@@ -77,7 +77,7 @@ function fieldValue(value: unknown, t: Translate): string {
}
/**
* Provenance fields as a key/value list. `kind` is always omitted because the
* Source fields as a key/value list. `kind` is always omitted because the
* row header already names the producer. `form` is omitted only when a
* dedicated body rendered for it — then the presentation the reader is looking
* at IS that value. On the opaque fallback the declaration is kept, because
@@ -159,7 +159,7 @@ function ModelFacingContent({ content, t }: {
/**
* Default presentation: the model-facing text as text, with its real line
* breaks, and the remaining provenance beneath it. This is what every form
* breaks, and the remaining source fields beneath it. This is what every form
* this UI version does not recognize renders as.
* @param props - Durable content, its source, and the locale seat.
* @returns The opaque context body.
@@ -428,7 +428,7 @@ export function NoticeBody({ content, t }: {
/**
* `relay` form: which agent sent this, then what it said.
*
* The sender is an opaque session id; it is shown as provenance rather than a
* The sender is an opaque session id; it is shown as a field rather than a
* label, because this client cannot resolve it to a title.
* @param props - Durable content, its source, and the locale seat.
* @returns The relay context body.

View File

@@ -25,7 +25,7 @@ export interface ContextInjectionRowProps {
* from a workspace instruction file or a recalled session without expanding.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected provenance and form, and the locale seat.
* @param props - Durable content, its projected producer role/name and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {

View File

@@ -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[] = []
@@ -138,8 +123,8 @@ function TurnErrorItem({ node, t }: {
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
* logged model text remains the single truth; this is presentation only.
* Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21:
* the sent text IS the reference — the bubble uses the same plainest token
* Plain-text `/name` / `@name` word-boundary tokens decorate (the sent text
* IS the reference — the bubble uses the same plainest token
* scan as the composer, minus the lexicon: sent tokens were validated at
* compose time, so shape alone decorates).
*/
@@ -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>
)
})

View File

@@ -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) {

View File

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

View File

@@ -0,0 +1,45 @@
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, useSession,
}: TurnTailNodeViewProps) {
const data = node.data
const hasLaterChatNode = useSession(snapshot =>
snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key)
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 || hasLaterChatNode}
className={css.actions}
t={t}
/>
</div>
)
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// Latency/throughput folds shared by the settled turn footer and StatsLine.
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

View File

@@ -0,0 +1,82 @@
import type {
AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode,
CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Merge-extensible payload registry keyed by final Chat renderer kind. */
export interface ChatNodeDataMap {}
/** Renderer kinds contributed by the currently installed Chat business modules. */
export type ChatNodeKind = Extract<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 non-rendered later evidence makes the closing seq non-tail. */
readonly branchUnavailable: boolean
readonly ttftMs?: number
readonly tokensPerSecond?: number
}
/**
* Test whether a Tool root has settled.
* @param block - Tool root lifecycle value.
* @returns whether the root carries its final result.
*/
export function isSettledTool(block: ToolCallBlock): block is Extract<ToolCallBlock, { kind: 'tool-result' }> {
return 'kind' in block
}
/**
* Test whether a Tool root is still running.
* @param block - Tool root lifecycle value.
* @returns whether the root lacks a final result.
*/
export function isRunningTool(block: ToolCallBlock): block is RunningToolCall {
return !isSettledTool(block)
}

View File

@@ -1,14 +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' {
@@ -30,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
@@ -47,11 +56,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The chat view's turn-tail chain: rendered between a closing assistant
* message's body and its IconActions footer, once per turn (the render
* site elects the closing seq). Entries derive a match from the owner
* currency before mounting, so presentation components never mount only
* to return null; an all-declined chain renders nothing.
* The completed Turn Node's extension chain, rendered before that Node's
* IconActions. Entries derive a match from the engine-owned Turn and
* closing seq before mounting, so presentation components never mount
* only to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/** Selected Tool call output inside the details panel. */
@@ -71,14 +79,20 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* reads the global workspace list.
*/
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
// 'conversation.input.overlay' merges in ui-slash (dedup ruling: the
// dependency direction is the hard constraint — ui-slash cannot import
/**
* The agent-preset chip beside the workspace picker on the new-session
* screen. Root scope: no session exists yet, so the choice is staged for
* the next one rather than applied to a current one.
*/
'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
// 'conversation.input.overlay' merges in ui-slash (the dependency
// direction is the hard constraint — ui-slash cannot import
// this package, while this package's input contract already imports
// ui-slash, so the type arrives transitively). The runtime declaration
// (children table in apply.ts) stays here with the other input slots.
/**
* Stacked strip above the input (queue rows / GoalBar / attachments;
* design §6 MIX evidence: entries coexist in fixed order).
* entries coexist in fixed order).
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
@@ -89,7 +103,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
/**
* The default composer body: a single slot rendered as the composer
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
* chain's fallback (a real entry, not a chain rider, so a
* takeover election hides rather than unmounts it and the textarea DOM
* survives). Session-maybe: the bar stays mounted across the
* no-session/session transition — the no-workspace hero renders the SAME
@@ -103,7 +117,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* The Plan-mode status seat in the composer tool row (left group,
* right of the access-mode control). Declared by the composer-bar
* entry; empty until a plan plugin registers (B ruling: no placeholder
* entry; empty until a plan plugin registers (no placeholder
* fallback).
*/
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
@@ -116,7 +130,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* ui-conversation's members of the session standard kit, provided through
* `sessions.provide` (decision 19/20): every session-scope slot component
* `sessions.provide`: every session-scope slot component
* receives the input machine's state hook and the two public actions.
*/
interface SessionStandardProps {
@@ -133,11 +147,33 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Owner share of the hero agent-preset chip: the shell supplies nothing. */
export interface HeroAgentPresetOwnerProps {
/** Marker field: the chip owns its own roster, staging, and menu state. */
children?: never
}
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/** Header actions derive their state from the standard session/global kit. */
export interface ConversationHeaderActionOwnerProps {}
/**
* The input-region slot currency (plan §1.4): dock/left/right entries read
* The input-region slot currency: dock/left/right entries read
* the conversation snapshot and the live input state as owner props (both
* are point-in-time snapshots — the dispatching skeleton re-renders on
* either store's change, so entries stay current without subscribing).
@@ -161,14 +197,37 @@ export interface ConvViewOwnerProps {
}
/**
* 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.
* Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')`
* (optional-service convention): the chat view asks it for a closing message's
* inline-code vocabulary and threads the result into MarkdownText. Absent
* service — the providing plugin composed out of cordis.yml — turns the
* surface off; the prose renders inert code.
*/
export interface ChatFileMentions {
/**
* Mention vocabulary for the closing message the owner currency names.
* @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.
*/
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
}
declare module 'cordis' {
interface Context {
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
chatFileMentions: ChatFileMentions
}
}
/**
* 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
/**
@@ -178,34 +237,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 Extract<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. */
@@ -322,7 +381,7 @@ export interface ComposerBarOwnerProps {
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
/** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
resolveSubmitMode: (
@@ -349,7 +408,8 @@ export interface ComposerBarInjected {
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
/** Hot plain-text reference lexicon for the decoration scan (plain-text-reference decision;
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
/** Source name opened by the programmatic menu launcher, or null. */
menuLauncher: ObservableSnapshot<string | null>
@@ -398,6 +458,7 @@ export type ConversationSlotProps =
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.hero.workspace'
| 'conversation.hero.agentPreset'
>
& InjectFace<ConversationInjected>
& PropsLocale<'conversation'>
@@ -519,11 +580,18 @@ export interface ChatViewInjected {
}
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void
/**
* Prose file-mention vocabulary for one closing message, from the optional
* {@link ChatFileMentions} service (resolved lazily per call, so composing
* the provider in or out takes effect live). Undefined when the service is
* absent or the turn produced nothing worth linking.
*/
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** 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'>
/**

View File

@@ -0,0 +1,316 @@
import type { Context } from 'cordis'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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 + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant,
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 === '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 === 'llm/retry') {
return { id: `${event.data.turn}:${event.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 === 'llm/retry') {
return resetForRetry(context.state)
}
return context.state
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
if (match.event.type !== 'assistant/chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
},
buildLocationData: (context, scope) => {
if (scope !== 'step') return null
const projected = projectAssistant(context)
if (projected === undefined) return null
return {
kind: 'step',
turn: projected.data.turn,
step: projected.data.step,
key: 'assistant-step',
value: projected.data,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const projected = projectAssistant(context)
if (projected === undefined) return null
if (projected.settled === undefined && !projected.visible) {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const current = context.current.get('chat')
if (!state.hidden || current === undefined || current === null) return null
}
return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, {
visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden',
})
},
}
/**
* Register the Assistant lifecycle business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerAssistantConversationNode(ctx: Context): void {
ctx.conversationEvents.register(assistantDefinition)
}

View File

@@ -0,0 +1,459 @@
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
// Content-free settled Assistants remain in the finalized compatibility
// stream so StatsLine preserves its pre-assembly step counts; hidden running
// attempts have no final Node to contribute.
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 for StatsLine and legacy top-level snapshot fields. */
class LegacySliceBuilder {
private readonly contributions = new Map<string, LegacyContribution>()
private readonly finalizedContributions = new Map<string, LegacyContribution>()
private readonly runningContributions = new Map<string, LegacyContribution>()
private readonly partialContributions = new Map<string, LegacyContribution>()
private finalized: readonly ConversationNode[] = EMPTY_LIST
private runningCalls: readonly RunningToolCall[] = EMPTY_LIST
private partial: PartialAssistant | null = null
private timeline: ConversationTimelineSnapshot | undefined
private turnTimings: LegacyConversationSlice['turnTimings'] = new Map()
private turnEnds: LegacyConversationSlice['turnEnds'] = new Map()
replace(
nodes: readonly ChatConversationViewNode[],
timeline: ConversationTimelineSnapshot,
): LegacyConversationSlice {
this.contributions.clear()
this.finalizedContributions.clear()
this.runningContributions.clear()
this.partialContributions.clear()
for (const node of nodes) {
const contribution = legacyContribution(node)
this.contributions.set(node.key, contribution)
this.indexContribution(node.key, contribution)
}
this.rebuildFinalized()
this.rebuildRunning()
this.rebuildPartial()
this.updateTimeline(timeline)
return this.snapshot()
}
apply(
upserts: readonly ChatConversationViewNode[],
timeline: ConversationTimelineSnapshot,
): LegacyConversationSlice {
let finalizedChanged = false
let runningChanged = false
let partialChanged = false
for (const node of upserts) {
const contribution = legacyContribution(node)
const previous = this.contributions.get(node.key)
if (sameContribution(previous, contribution)) continue
finalizedChanged ||= finalizedContributionChanged(previous, contribution)
runningChanged ||= runningContributionChanged(previous, contribution)
partialChanged ||= partialContributionChanged(previous, contribution)
this.contributions.set(node.key, contribution)
this.indexContribution(node.key, contribution)
}
if (finalizedChanged) this.rebuildFinalized()
if (runningChanged) this.rebuildRunning()
if (partialChanged) this.rebuildPartial()
this.updateTimeline(timeline)
return this.snapshot()
}
private indexContribution(key: string, contribution: LegacyContribution): void {
updateContributionIndex(this.finalizedContributions, key, contribution, contribution.nodes.length > 0)
updateContributionIndex(this.runningContributions, key, contribution, contribution.running !== null)
updateContributionIndex(this.partialContributions, key, contribution, contribution.partial !== null)
}
private rebuildFinalized(): void {
const finalized = [...this.finalizedContributions.values()]
.flatMap(value => value.nodes)
.sort((left, right) => left.seq - right.seq)
if (!sameReferences(this.finalized, finalized)) this.finalized = finalized
}
private rebuildRunning(): void {
const runningCalls = [...this.runningContributions.values()]
.sort((left, right) => left.anchorSeq - right.anchorSeq)
.flatMap(value => value.running === null ? [] : [value.running])
if (!sameReferences(this.runningCalls, runningCalls)) this.runningCalls = runningCalls
}
private rebuildPartial(): void {
const partial = [...this.partialContributions.values()]
.sort((left, right) => left.anchorSeq - right.anchorSeq)
.findLast(value => value.partial !== null)?.partial ?? null
if (this.partial?.blocks !== partial?.blocks
|| this.partial?.turn !== partial?.turn
|| this.partial?.step !== partial?.step) this.partial = partial
}
private updateTimeline(timeline: ConversationTimelineSnapshot): void {
if (this.timeline === timeline) return
this.timeline = timeline
const turnTimings = new Map<number, { startTime: number; endTime?: number }>()
const turnEnds = new Map<number, number>()
for (const turn of timeline.turns.values()) {
if (turn.start !== undefined) {
turnTimings.set(turn.turn, {
startTime: turn.start.time,
...turn.end === undefined ? {} : { endTime: turn.end.time },
})
}
if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq)
}
this.turnTimings = turnTimings
this.turnEnds = turnEnds
}
private snapshot(): LegacyConversationSlice {
return {
nodes: this.finalized,
turnTimings: this.turnTimings,
turnEnds: this.turnEnds,
partial: this.partial,
runningCalls: this.runningCalls,
}
}
}
function updateContributionIndex(
index: Map<string, LegacyContribution>,
key: string,
contribution: LegacyContribution,
present: boolean,
): void {
if (present) index.set(key, contribution)
else index.delete(key)
}
function finalizedContributionChanged(
previous: LegacyContribution | undefined,
next: LegacyContribution,
): boolean {
const previousNodes = previous?.nodes ?? EMPTY_LIST
return !sameReferences(previousNodes, next.nodes)
|| ((previousNodes.length > 0 || next.nodes.length > 0) && previous?.anchorSeq !== next.anchorSeq)
}
function runningContributionChanged(
previous: LegacyContribution | undefined,
next: LegacyContribution,
): boolean {
return previous?.running !== next.running
|| ((previous.running !== null || next.running !== null)
&& previous.anchorSeq !== next.anchorSeq)
}
function partialContributionChanged(
previous: LegacyContribution | undefined,
next: LegacyContribution,
): boolean {
return previous?.partial?.blocks !== next.partial?.blocks
|| previous?.partial?.turn !== next.partial?.turn
|| previous?.partial?.step !== next.partial?.step
|| (((previous?.partial ?? null) !== null || next.partial !== null)
&& previous?.anchorSeq !== next.anchorSeq)
}
/** Incremental keyed Chat builder registered under the `chat` target. */
export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversationViewNode, ChatSnapshot> {
private readonly store = new MutableChatNodeStore()
private readonly locations = new MutableChatLocationIndex()
private readonly legacy = new LegacySliceBuilder()
private order: readonly string[] = EMPTY_KEYS
readonly empty: ChatSnapshot
constructor() {
this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() })
}
replace(input: {
readonly nodes: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
this.store.replace(input.nodes)
this.order = orderedVisible(input.nodes).map(node => node.key)
this.locations.rebuild(this.order, this.store)
return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline))
}
apply(input: {
readonly upserts: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
let structural = false
const contentOnly: ChatConversationViewNode[] = []
for (const node of input.upserts) {
const previous = this.store.get(node.key)
const nodeStructural = previous === undefined
|| previous.anchorSeq !== node.anchorSeq
|| previous.visibility !== node.visibility
|| locationIdentity(previous.location) !== locationIdentity(node.location)
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
}
this.store.upsert(input.upserts)
if (structural) {
const next = orderedVisible(this.store.values()).map(node => node.key)
this.order = sameReferences(this.order, next) ? this.order : next
this.locations.rebuild(this.order, this.store)
}
this.locations.touch(contentOnly)
return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline))
}
private snapshot(
timeline: ConversationTimelineSnapshot,
legacy = this.legacy.replace(EMPTY_LIST, timeline),
): ChatSnapshot {
return {
order: this.order,
nodes: this.store,
locations: this.locations,
timeline,
legacy,
}
}
}
function locationIdentity(location: ConversationLocation): string {
const coordinates = locationCoordinates(location)
return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}`
}
/** Chat target factory contributed to the Runtime view registry. */
export const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
target: 'chat',
create: () => new ChatSnapshotBuilder(),
}
/**
* Register the incremental Chat target builder.
* @param ctx - owning UI Conversation context.
*/
export function registerChatConversationView(ctx: Context): void {
ctx.conversationViews.register(chatViewDefinition)
}

View File

@@ -0,0 +1,229 @@
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 { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
import type {} from '@deepseek-ai/dsh-compact/types'
import type {} from '@deepseek-ai/dsh-commands/types'
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: CompactCheckpointSource['plugin'] = 'compact'
interface CommandState {
readonly command: CommandNode
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
interface CompactionEvidence {
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
function commandFromRun(match: ConversationMatch): CommandNode {
if (match.event.type !== 'command/run') throw new Error('command start requires command/run')
const data = match.event.data
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 {
if (match.event.type !== 'command/done') throw new Error('command update requires command/done')
const data = match.event.data
const sourceEventSeq = data.kind === 'success'
&& data.sourceEventSeq !== undefined
&& Number.isSafeInteger(data.sourceEventSeq) && data.sourceEventSeq >= 0
? data.sourceEventSeq
: 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?.event.type === 'compact/summary') {
const data = match.event.data
if (Array.isArray(data.summary)) {
const text = data.summary
.map(block => block.type === 'text' ? block.text : '')
.join('')
summary = text.trim() === '' ? null : text
}
shadowedItemCount = Array.isArray(data.shadowedSeqs)
&& data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0)
? data.shadowedSeqs.length
: null
shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount)
&& data.shadowedTokenCount >= 0
? data.shadowedTokenCount
: 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 === 'command/done')
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
const summary = context.matches.find(match => match.event.type === '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 === '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 === 'command/run') {
return { id: String(event.data.commandId), role: 'start' }
}
if (event.type === 'command/done') {
return { id: String(event.data.commandId), role: 'update' }
}
const checkpoint = compactSource(event)
if (checkpoint?.sourceCommandId !== undefined) {
return { id: String(checkpoint.sourceCommandId), role: 'update' }
}
if (event.type === 'compact/start'
|| event.type === 'compact/summary'
|| event.type === 'compact/end') {
if (event.data.sourceCommandId !== undefined) {
return { id: String(event.data.sourceCommandId), role: 'update' }
}
}
return null
},
start: (_context, match) => ({ command: commandFromRun(match) }),
update: (context, match) => {
if (match.event.type === 'command/done') {
return { ...context.state, command: commandFromDone(match, context.state.command) }
}
return updateCompactionState(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
if (state.command.name !== 'compact') {
return chatNode(context, 'command', state.command.seq, state.command)
}
const compaction = state.checkpoint === undefined
? null
: compactSummary(state.summary, state.checkpoint)
const data: ManualCompactionChatData = { command: state.command, compaction }
return chatNode(context, 'manual-compaction', compaction?.seq ?? state.command.seq, data)
},
}
/**
* Register the command lifecycle business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerCommandConversationNode(ctx: Context): void {
ctx.conversationEvents.register(commandDefinition)
}
/** Shared structural checkpoint recognizer for automatic compaction. */
export { compactSource, compactSummary }

View File

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

View File

@@ -0,0 +1,65 @@
import type { Context } from 'cordis'
import type {
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-compact/types'
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 === '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 === 'compact/start'
|| event.type === 'compact/summary'
|| event.type === 'compact/end') {
if (event.data.sourceCommandId !== undefined) return null
const compactionId: unknown = event.data.compactionId
if (typeof compactionId !== 'string' || compactionId === '') return null
return { id: compactionId, role: event.type === 'compact/start' ? 'start' : 'update' }
}
return null
},
start: () => ({}),
update: (context, match) => updateCompactionState(context.state, match),
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state.checkpoint === undefined) return null
const marker = compactSummary(state.summary, state.checkpoint)
return chatNode(context, 'compaction', marker.seq, marker)
},
}
/**
* Register the automatic-compaction business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerCompactionConversationNode(ctx: Context): void {
ctx.conversationEvents.register(compactionDefinition)
}

View File

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

View File

@@ -0,0 +1,70 @@
import type { Context } from 'cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
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 === 'agent/inbox/spliced'
&& event.data.target === target
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`)
return applySplice(reader.previous<InboxState>(kind), match.event.data)
},
update: context => context.state,
publication: () => 'none',
buildViewNode: () => null,
}
}
/** Cumulative next-turn inbox splice Definition. */
export const nextTurnInboxDefinition = inboxDefinition('next-turn')
/** Cumulative next-step inbox splice Definition used to classify steering. */
export const nextStepInboxDefinition = inboxDefinition('next-step')
/**
* Register the two durable Inbox-state contributions.
* @param ctx - owning UI Conversation context.
*/
export function registerInboxConversationNodes(ctx: Context): void {
ctx.conversationEvents.register(nextTurnInboxDefinition)
ctx.conversationEvents.register(nextStepInboxDefinition)
}

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
import type { Context } from 'cordis'
import type {
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
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
}
}
/** Accumulated retry attempts sharing one producer-owned RetryId. */
export interface RetryState {
readonly turn: number
readonly step: number
readonly attempts: readonly ModelRetryNode[]
}
function scheduledNode(match: Parameters<ConversationNodeDefinition['start']>[1]): ModelRetryNode | undefined {
if (match.event.type !== 'llm/retry') return undefined
return {
kind: 'model-retry',
seq: match.event.seq,
time: match.event.time,
retryState: 'scheduled',
...match.event.data,
}
}
/** A scheduled attempt is cancelled once either owning boundary closes. */
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 === 'llm/retry') {
const retryId: unknown = event.data.retryId
if (typeof retryId !== 'string' || retryId === '') return null
return { id: retryId, role: event.data.retry === 1 ? 'start' : 'update' }
}
if (event.type === 'llm/retry-started') {
const retryId: unknown = event.data.retryId
return typeof retryId === 'string' && retryId !== '' ? { id: retryId, role: 'update' } : null
}
return null
},
start: (_context, match) => {
const node = scheduledNode(match)
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 === 'llm/retry') {
const node = scheduledNode(match)
return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] }
}
if (match.event.type !== 'llm/retry-started') return context.state
const retry = match.event.data.retry
return {
...context.state,
attempts: context.state.attempts.map(attempt =>
attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt),
}
},
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const }
const stateAttempts = context.state.attempts
const attempts = stateAttempts.map((attempt, index) =>
index === stateAttempts.length - 1
&& attempt.retryState === 'scheduled'
&& isClosed(location)
? { ...attempt, retryState: 'cancelled' as const }
: attempt)
const current = attempts.at(-1)
if (current === undefined) return null
const data: RetryChatData = { attempts, current }
return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data)
},
}
/**
* Register the correlated model-retry business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerRetryConversationNode(ctx: Context): void {
ctx.conversationEvents.register(retryDefinition)
}

View File

@@ -0,0 +1,277 @@
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 {} from '@deepseek-ai/dsh-tools/types'
import type { ToolChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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 event = match.event
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state
const data = event.data
const parentCallId = String(data.parentCallId)
const subCallId = String(data.subCallId)
const siblings = state.children.get(parentCallId) ?? []
const index = siblings.findIndex(candidate => candidate.callId === subCallId)
if (event.type === 'tool/code-dispatch-start') {
if (index >= 0 || !acceptsEdge(state, parentCallId, subCallId)) return state
const children = new Map(state.children)
children.set(parentCallId, [...siblings, childCall(match, data)])
const parents = new Map(state.parents)
parents.set(subCallId, parentCallId)
return { ...state, children, parents }
}
if (index < 0 && !acceptsEdge(state, parentCallId, subCallId)) return state
const previous = index < 0 ? undefined : siblings[index]
const settled = childResult(match, data, previous)
const children = new Map(state.children)
children.set(parentCallId, index < 0
? [...siblings, settled]
: siblings.map((child, at) => at === index ? settled : child))
const parents = new Map(state.parents)
if (index < 0) parents.set(subCallId, 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 + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
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 === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
const rootCallId: unknown = event.data.rootCallId
return typeof rootCallId === 'string' && rootCallId !== ''
? { id: rootCallId, role: 'update' }
: null
}
return null
},
start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }),
update: (context, match) => {
if (match.event.type === 'tool/result') {
const running = 'kind' in context.state.root ? undefined : context.state.root
const result = rootResult(match, running)
return result === undefined ? context.state : { ...context.state, root: result }
}
return updateDispatch(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const projected = projectBlock(state.root, state, interruption(context))
const anchor = context.start?.event.seq
?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0)
return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData)
},
}
/**
* Register the root Tool lifecycle and nested-subcall contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerToolConversationNode(ctx: Context): void {
ctx.conversationEvents.register(toolDefinition)
}

View File

@@ -0,0 +1,113 @@
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 type {} from '@deepseek-ai/dsh-llm-retry/types'
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 {
return event.type === 'llm/retry' || event.type === 'llm/retry-started'
? event.data.turn
: undefined
}
function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined
const failure = match.event.data.reason.error
return {
seq: match.event.seq,
time: match.event.time,
message: displayFailureMessage(failure),
code: failure.code,
}
}
function fallbackState(context: ConversationNodeContext<TurnErrorState>): TurnErrorState | undefined {
const end = context.matches.find(match => failureFrom(match) !== undefined)
if (end?.event.type !== 'turn/end') return undefined
const failure = failureFrom(end)
if (failure === undefined) return undefined
const turn = end.event.data.turn
return {
turn,
hidden: context.matches.some(match => retryTurn(match.event) === turn),
failure,
}
}
/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */
export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
kind: 'turn-error',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
return { id: String(event.data.turn), role: 'update' }
}
const turn = retryTurn(event)
return turn === undefined ? null : { id: String(turn), role: 'update' }
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('turn-error start requires turn/start')
return { turn: match.event.data.turn, hidden: false }
},
update: (context, match) => {
const failure = failureFrom(match)
if (failure !== undefined) return { ...context.state, failure }
return retryTurn(match.event) === context.state.turn
? { ...context.state, hidden: true }
: context.state
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const state = context.state ?? fallbackState(context)
if (state?.failure === undefined) return null
const failure = state.failure
const node: TurnErrorNode = {
kind: 'turn-error',
seq: failure.seq,
time: failure.time,
turn: state.turn,
step: lastStep(context),
message: failure.message,
...failure.code === undefined ? {} : { code: failure.code },
}
if (!state.hidden) return chatNode(context, 'turn-error', node.seq, node)
const current = context.current.get('chat')
return current === undefined || current === null
? null
: chatNode(context, 'turn-error', node.seq, node, { visibility: 'hidden' })
},
}
/**
* Register the terminal Turn-error business contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnErrorConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnErrorDefinition)
}

View File

@@ -0,0 +1,196 @@
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 {} from '@deepseek-ai/dsh-llm-retry/types'
import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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 === 'llm/retry') return { turn: event.data.turn, step: event.data.step }
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 + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup
}
continue
}
if (event.type === 'llm/retry') {
steps.set(coordinates.step, { streamedText: false, finalized: false })
continue
}
if (event.type === 'step/end' && previous.streamedText && !previous.finalized) {
anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup
}
}
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
let latestTranscriptSeq = finalized.at(-1)?.finalNode.seq
for (const match of context.matches) {
const event = match.event
const candidate = event.type === 'tool/call'
|| (event.type === 'tool/result' && isAppendSurfaceEvent(event))
|| (event.type === 'turn/end' && event.data.reason.kind === 'error')
|| event.type === 'llm/retry'
? event.seq
: undefined
if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) {
latestTranscriptSeq = candidate
}
}
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 || latestTranscriptSeq !== 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' }
if (event.type === 'tool/call' || event.type === 'tool/result') {
return { id: String(event.data.turn), role: 'update' }
}
const coordinates = turnCoordinates(event)
if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' }
return null
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('turn-tail start requires turn/start')
return { turn: match.event.data.turn }
},
update: (context, match) => match.event.type === 'turn/end'
? { ...context.state, end: match }
: context.state,
publication: match => match.event.type === 'turn/end' ? 'immediate' : 'none',
buildLocationData: (context, scope) => {
if (scope !== 'turn') return null
const value = tailData(context)
return value === null ? null : {
kind: 'turn',
turn: value.turn,
key: 'turn-tail',
value,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
const turn = turnLocation(context)
const data = turn?.data.get('turn-tail')
return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data)
},
}
/**
* Register completed-Turn footer data and its Chat node contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnTailConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnTailDefinition)
}

View File

@@ -3,6 +3,16 @@
* between the independently implemented skeleton and chat domains; `apply.ts`
* owns their slot assembly.
*/
export type {} from './conversation-nodes/assistant.ts'
export type {} from './conversation-nodes/command.ts'
export type {} from './conversation-nodes/compaction.ts'
export type {} from './conversation-nodes/fallback.ts'
export type {} from './conversation-nodes/message.ts'
export type {} from './conversation-nodes/retry.ts'
export type {} from './conversation-nodes/tool.ts'
export type {} from './conversation-nodes/turn-error.ts'
export type {} from './conversation-nodes/turn-tail.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export type { IConversation } from './service.ts'
@@ -12,11 +22,16 @@ export type {
} from './contract/views.ts'
export type { ConversationKey } from './locales.ts'
export type {
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.

View File

@@ -1,5 +1,5 @@
/**
* Frozen input-machine contract (design §9.1, eng. plan §3.9-3.12). Types
* Frozen input-machine contract. Types
* only. Three-tier visibility: business packages see InputState via the
* InputZone currency; the scoped input events carry the mutation verbs; the
* conversation wiring layer alone sees the full SessionInput. InputMachine
@@ -56,7 +56,7 @@ export interface InputService {
/**
* The public input action face provided to every session-scope slot
* component (decision 20): two stable-identity void callbacks, mirroring the
* component: two stable-identity void callbacks, mirroring the
* useStore+actions convention. Command-style handles (track/arbitrate/space/
* undo/paste/…) stay InputBar-private and never ride this face.
*/
@@ -75,7 +75,7 @@ export interface InputNotice {
}
/**
* The InputBar-exclusive keyboard/DOM command face (decision 20): synchronous
* The InputBar-exclusive keyboard/DOM command face: synchronous
* returns and event-handler semantics that must not enter the public provide
* channel. Handed to the composer-bar entry through its own inject —
* package-internal, never across a plugin boundary. The session shell
@@ -128,7 +128,7 @@ export interface EditRange extends EditSelection {
/**
* One reference chip occurrence, backing exactly one U+FFFC placeholder in
* the draft (design §9.1 底层表示). Identity is occurrenceId — same-named
* the draft. Identity is occurrenceId — same-named
* references stay independently addressable. label/clipboardText are the
* owner's insert-time projections, cached so the chip survives owner loss
* (invalid flips instead of dropping the occurrence).
@@ -157,7 +157,7 @@ export interface PasteComponent extends EditSelection {
/**
* Live paste-match attempt published while async matching may still upgrade
* pasted tokens (design §9.1 剪贴板 round-trip). Any non-paste transaction,
* pasted tokens (the clipboard round-trip). Any non-paste transaction,
* submit start, invalidate-paste, or release ends it; a paste-upgrade keeps
* it current (later tokens re-CAS against the advanced draftRev).
*/

View File

@@ -1,5 +1,5 @@
/**
* Draft decoration pure core (design §9.1: chips render from the occurrence
* Draft decoration pure core (chips render from the occurrence
* table at placeholder offsets; the claim token renders as a mirror-layer
* highlight, the claim hint as ghost text). Zero React — the skeleton renders
* the instructions; tests drive this directly.
@@ -24,7 +24,9 @@ export interface ChipRender {
}
/**
* One plain-text reference range (decision 21): a `/name` or `@name` token
* One plain-text reference range (the plain-text-reference decision;
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* a `/name` or `@name` token
* whose name is on the trigger's lexicon. Pure derivation — editing the text
* out of match shape simply drops the range next scan.
*/
@@ -50,8 +52,8 @@ export interface DraftDecorations {
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
/**
* Scan the draft for plain-text reference tokens against the hot lexicons
* (decision 21). Word-boundary discipline: the trigger must sit at the draft
* Scan the draft for plain-text reference tokens against the hot lexicons.
* Word-boundary discipline: the trigger must sit at the draft
* start or after whitespace ('x/name' never matches); the name must be an
* exact lexicon member.
* @param draft - draft text.
@@ -82,7 +84,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/**
* Derive the mirror-layer decorations from the input state.
* @param state - published input state.
* @param lexicon - optional per-trigger reference lexicons (decision 21 scan).
* @param lexicon - optional per-trigger reference lexicons (plain-text-reference scan).
* @returns token range, chip instructions, text-ref ranges, and the ghost hint.
*/
export function deriveDecorations(

View File

@@ -25,7 +25,7 @@ export interface PopupDismissFace {
}
/**
* Construction seams of one facade. The slash/popup faces are THUNKS: the
* Construction dependencies of one facade. The slash/popup faces are THUNKS: the
* shell is created inside the sessions provide materialization (before the
* scope record is queryable), where `slash.sessionOf`/`command.popupFor`
* cannot resolve yet — resolution defers to first interactive use.
@@ -66,7 +66,7 @@ export class SessionInputShell implements SessionInput {
readonly state: SnapshotStore<InputState>
/** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */
readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
/** The public provide-channel action face (one stable identity per session — decision 20). */
/** The public provide-channel action face (one stable identity per session). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
submit: () => { this.submit('queue') },
@@ -198,7 +198,9 @@ export class SessionInputShell implements SessionInput {
/**
* Hot plain-text reference lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* (the plain-text-reference decision;
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
@@ -253,7 +255,8 @@ export class SessionInputShell implements SessionInput {
/**
* Insert plain reference text over the pick-time span (scoped insert-text
* event listener body, decision 21). Same CAS-then-splice shape as the
* event listener body; plain-text-reference decision, web-input-machine
* note). Same CAS-then-splice shape as the
* consume-token span branch: the machine sees an ordinary draft-changed
* transaction (one undo step), no occurrence is minted — the chip look is
* a scan-derived decoration, never state.
@@ -339,7 +342,7 @@ export class SessionInputShell implements SessionInput {
}
/**
* Prompt serialization before the sink (design §3.12): expand each
* Prompt serialization before the sink: expand each
* placeholder to its owner's model form via the session controller's
* codec routing. Owner missing / serialize failure / disposal blocks the
* send — notice + draft and chips retained, never a silent downgrade to

View File

@@ -1,7 +1,7 @@
/**
* InputHub: the InputService implementation (`ctx.conversation.input`) — one
* SessionInputShell per session, created inside the sessions provide
* materialization (decision 19: the 'input' standard-kit entry IS the
* materialization (the 'input' standard-kit entry IS the
* creation trigger) and torn down by the scope disposer (instance-and-scope
* share one lifecycle). The hub registers the three scoped input-mutation
* listeners on each session's actx (the sole consumer side of the ui-slash
@@ -61,7 +61,7 @@ export class InputHub implements InputService {
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
// scope fiber (decision 12 — nothing here outlives the scope).
// scope fiber (nothing here outlives the scope).
actx.effect(() => {
const offs = [
actx.on('slash/input-begin-command', req =>
@@ -97,7 +97,7 @@ export class InputHub implements InputService {
}
/**
* The InputBar-exclusive keyboard command face (decision 20): the shell
* The InputBar-exclusive keyboard command face: the shell
* satisfies it structurally; package-internal — handed through the
* composer-bar entry's inject, never across a plugin boundary.
* @param id - session id.

View File

@@ -1,6 +1,6 @@
/**
* InputMachine: the pure per-session input state machine (design §9.1, eng.
* plan §3.9-3.12). Events in, effects out; zero React / DOM / cordis / ambient
* InputMachine: the pure per-session input state machine.
* Events in, effects out; zero React / DOM / cordis / ambient
* clock. Package-private — the SessionInput shell is the only caller and the
* sole executor of the returned effects.
*
@@ -23,10 +23,10 @@ import type {
/** The object-replacement character backing every chip occurrence in the draft. */
export const PLACEHOLDER = ''
/** The machine never writes the queue; the wiring layer overlays the T9 store projection. */
/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */
const EMPTY_QUEUE: InputState['queue'] = []
/** Undo ring depth (design §9.1: bounded self-managed transaction log). */
/** Undo ring depth (bounded self-managed transaction log). */
const LOG_LIMIT = 100
/** Exhaustiveness backstop for the closed InputEvent / guard unions. */
@@ -68,7 +68,7 @@ function diffEdit(prev: string, next: string): EditRange {
/**
* Expand the draft's placeholders into their occurrences' clipboard text
* (decision 16: the persistence mirror and clipboard both write this
* (the persistence mirror and clipboard both write this
* projection — U+FFFC never leaves the machine). Table order is offset
* order, so one linear walk pairs placeholders with entries.
* @param state - published input state.
@@ -194,7 +194,7 @@ export class InputMachine {
/**
* Reconcile the occurrence table with one edit (old-draft coordinates):
* entries past the range shift by the length delta; entries whose
* placeholder sits inside the replaced range go away whole (design §9.1: a
* placeholder sits inside the replaced range go away whole (a
* deletion/replacement intersecting a placeholder acts on the whole chip).
*/
private reconcile(range: EditRange): void {
@@ -338,7 +338,7 @@ export class InputMachine {
/**
* Owner-resolution style bits: exactly the listed occurrences render
* invalid. Not a transaction — the draft, revision, and undo log are
* untouched (design §9.1: invalidation never deletes or rewrites chips).
* untouched (invalidation never deletes or rewrites chips).
*/
private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
const ids = new Set(invalidIds)

View File

@@ -17,6 +17,7 @@ export const zh = {
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.commands': '命令',
@@ -159,6 +160,7 @@ export const en = {
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.commands': 'Commands',

View File

@@ -214,7 +214,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
/**
* The dock entry as a plain registrant plugin. The conversation service is
* the action seam; the slot declaration is its independent lifecycle seam.
* the action contract; the slot declaration has an independent lifecycle boundary.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',

View File

@@ -10,7 +10,7 @@ import type { QueuedMessage } from '../input/contract.ts'
/**
* Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* The wiring layer overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally
* identical.
* @param session - the resident session face.

View File

@@ -57,7 +57,7 @@ export interface IConversation {
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/** The per-session composer-block registry. */
readonly blocks: ComposerBlocks

View File

@@ -10,13 +10,13 @@
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
// resolved frame.
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 +40,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 }} />
}

View File

@@ -1,8 +1,8 @@
/** Composer context-occupancy meter: a ring beside the send button fed by the
* `contextPressure` projection, with a click-open panel of the heuristic
* `contextBreakdown` composition (system prompt, tools, conversation).
* Renders nothing until a provider reports both pressure and a route capacity
* (same gate as the stats row used). */
* Renders nothing until a provider reports both pressure and a route
* capacity. */
import { useEffect, useRef, useState } from 'react'
import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -81,7 +81,6 @@ export function ConversationRoot({
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
// Flow optimization — worth a close PR review for code/boundary issues.
// The chip is a selector; label resolution walks the flow top-down:
// 1. a just-picked workspace (pending) → its title;
// 2. cold start, no session yet → placeholder ("Choose workspace");
@@ -120,6 +119,7 @@ export function ConversationRoot({
},
onClose: () => { setPickerOpen(false) },
})}
{renderSlot('conversation.hero.agentPreset', {})}
</div>
)

View File

@@ -1,4 +1,4 @@
/* Details third column, minimal P-I fill: header (name + close) over a
/* Details third column, minimal fill: header (name + close) over a
scrolling body of Input/Output code sections. Panel width/squeeze belongs
to layout; this fills whatever the column gives. */

View File

@@ -1,7 +1,6 @@
// DetailsPanel, P-I minimal form: close button + the selected call's args and
// DetailsPanel: close button + the selected call's args and
// result — args as JSON, the result raw except for a terminal-card call, whose
// Output section is the command's terminal card. The three-段 Switch /
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
// Output section is the command's terminal card. Reads the
// selection from the shared chat
// store (conversation writes, this panel reads — the cross-registration
// share the store seat exists for) and derives the call material from the
@@ -12,6 +11,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 +40,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 {

View File

@@ -10,7 +10,7 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
row below, primary action controls bottom-right. Input width rides the
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size — layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the
@@ -225,9 +225,9 @@
so by construction now that all three sit INSIDE .scroll — a scrollbar
that consumes layout space narrows the scrollport, which is their shared
containing block, so it costs all three the same width on every engine.
Scrolling the textarea itself is what used to break this, and no property
fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
and not for the overflow:hidden layers beside it, leaving them 8px apart
A textarea that scrolls itself would break this, and no property fixes
it: WebKit reserves gutter space for an overflow-y:auto textarea and not
for the overflow:hidden layers beside it, leaving them 8px apart
(768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
}
@@ -395,7 +395,7 @@
cursor: pointer;
}
/* Plain-text reference highlight (decision 21): a pure range mark over the
/* Plain-text reference highlight: a pure range mark over the
draft's own glyphs — advance untouched, so the two layers cannot drift.
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
.textRef {

View File

@@ -1,5 +1,5 @@
/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
/** The default composer body: the 'conversation.composer.bar' slot entry.
* Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
@@ -83,11 +83,15 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live || blocked !== undefined
// A continuable child without its live parent cannot accept human input,
// but its independent Stop below stays available while it runs.
const continuable = subagent?.address.mode === 'continuable'
const parentOffline = continuable && !subagent.parentAvailable
// Running input stays free; locked = session removed, the
// inert no-workspace state, the machine faces absent (no session), or a
// parent-offline continuable child. An owner block also disables input;
// adjudicating and submitting render read-only so the draft stays visible.
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
@@ -350,11 +354,14 @@ export function InputBar({
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const ordinary = subagent === null
const stopping = running && ordinary
const primaryLabel = stopping ? t('input.stop') : t('input.send')
// Ordinary sessions retain their primary Send/Stop toggle. A continuable
// child keeps Send as the primary action and exposes Stop independently so
// pointer users can queue follow-ups while its current turn is running.
const primaryStops = running && subagent === null
const interruptible = running && continuable
const primaryLabel = primaryStops ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (stopping) {
if (primaryStops) {
stop?.()
return
}
@@ -378,7 +385,7 @@ export function InputBar({
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
// text-ref range (decision 21) — merged in draft order (the sources never
// text-ref range — merged in draft order (the sources never
// overlap: chips sit on placeholders, text-refs on plain tokens, the
// claim token only leads).
let cursor = 0
@@ -423,7 +430,7 @@ export function InputBar({
)
cursor = chip.offset + 1 // the placeholder char the chip stands for
} else {
// Plain-range highlight (decision 21): the glyphs stay the
// Plain-range highlight: the glyphs stay the
// textarea's (advance untouched); the mark paints the chip look.
backdrop.push(
<mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
@@ -478,9 +485,11 @@ export function InputBar({
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
placeholder={placeholder ?? (parentOffline
? t('placeholder.parentOffline')
: disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -520,17 +529,32 @@ export function InputBar({
{rightItems}
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
{interruptible && (
<Tooltip label={t('input.stop')} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={t('input.stop')}
disabled={stop === undefined}
onMouseDown={keepFocus}
onClick={stop}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
</button>
</Tooltip>
)}
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
{primaryStops ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>

View File

@@ -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 = () => {}