mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(gui): generic command flow node and the conversation.chat.commandview keyed slot
The FoldAdapter folds the log-only command/run + command/done pair (paired by commandId) into a CommandNode outside the surface fold and merges the nodes into the flow by seq; cross-window cuts soft-fall like tool pairs (a done-only window builds the node from the done, a run with no done renders as still executing). ChatView renders command nodes through the new keyed 'conversation.chat.commandview' hole (key = command name) with GenericCommandCard — a stripped-down GenericToolCard showing the command line and outcome text — as the render-site fallback, so any slash command renders durably with zero registration and survives refresh, other tabs, and resume via the mux-broadcast events.
This commit is contained in:
@@ -29,7 +29,7 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
|
||||
@@ -120,6 +120,31 @@ export interface UnknownSurfaceNode {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* One slash-command lifecycle folded from the log-only `command/run` /
|
||||
* `command/done` pair (paired by commandId, mirroring tool call↔result).
|
||||
* Log-only events never enter the surface fold, so the FoldAdapter indexes
|
||||
* them separately and merges the nodes into the flow by seq. A window cut
|
||||
* between the pair soft-falls like tool pairs: a done with no in-window run
|
||||
* still builds a node (name/line null), and a run with no done renders as
|
||||
* still executing.
|
||||
*/
|
||||
export interface CommandNode {
|
||||
kind: 'command'
|
||||
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the anchoring event. */
|
||||
time: number
|
||||
/** Pairing id minted by the host executor. */
|
||||
commandId: string
|
||||
/** Command name (run payload); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Exact dispatched command line (run payload); null when the run fell outside the window. */
|
||||
line: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
@@ -127,6 +152,7 @@ export type ConversationNode =
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
@@ -99,6 +99,15 @@ export class FoldAdapter {
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so the surface fold never
|
||||
* emits it; this index folds the pair (done settles its run's node in
|
||||
* place) and nodes() merges the products into the flow by seq. Window cuts
|
||||
* soft-fall like tool pairs: a done with no in-window run still builds a
|
||||
* node.
|
||||
*/
|
||||
private commandIdx = new Map<string, CommandNode>()
|
||||
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
|
||||
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
@@ -128,10 +137,14 @@ export class FoldAdapter {
|
||||
this.degraded = false
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (event !== undefined) this.indexCall(event, views?.[i])
|
||||
if (event !== undefined) {
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +158,7 @@ export class FoldAdapter {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +194,21 @@ export class FoldAdapter {
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
const value = { nodes: out, degraded: this.degraded }
|
||||
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||
// order share the log order), so one linear merge keeps flow order.
|
||||
let nodes = out
|
||||
if (this.commandIdx.size > 0) {
|
||||
nodes = []
|
||||
const commands = [...this.commandIdx.values()]
|
||||
let next = 0
|
||||
for (const node of out) {
|
||||
while (next < commands.length && commands[next]!.seq < node.seq) nodes.push(commands[next++]!)
|
||||
nodes.push(node)
|
||||
}
|
||||
while (next < commands.length) nodes.push(commands[next++]!)
|
||||
}
|
||||
const value = { nodes, degraded: this.degraded }
|
||||
this.nodesResult = { rev: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -195,6 +223,36 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||
private indexCommand(event: SessionEvent): void {
|
||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
||||
// enter the client program, so this wire consumer narrows structurally
|
||||
// (the same posture as tool/code-dispatch in session.ts).
|
||||
if ((event.type as string) === 'command/run') {
|
||||
const data = event.data as unknown as { commandId: string; name: string; line: string }
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: data.name, line: data.line, outcome: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return
|
||||
const data = event.data as unknown as { commandId: string; kind: 'success' | 'error'; text?: string }
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
if (run === undefined) {
|
||||
// Cross-window cut: the run page fell out of the window — build the
|
||||
// node from the done alone (same soft-fall as a call-less tool result).
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: null, line: null, outcome,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Settle in place: a fresh node object (published references stay immutable).
|
||||
this.commandIdx.set(data.commandId, { ...run, outcome })
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
|
||||
Reference in New Issue
Block a user