Kaynağa Gözat

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.
imccyu 1 ay önce
ebeveyn
işleme
ba928c5517

+ 1 - 1
packages/client/runtime/src/client/index.ts

@@ -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'

+ 26 - 0
packages/client/runtime/src/client/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
 
 /**

+ 61 - 3
packages/client/runtime/src/client/sessions/fold-adapter.ts

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

+ 4 - 0
packages/client/runtime/tests/event-script.ts

@@ -42,6 +42,10 @@ export const ev = {
     at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
   todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
     at(seq, { type: 'todo/write', data: { todos } }),
+  commandRun: (seq: number, commandId: string, name: string, line: string): SessionEvent =>
+    at(seq, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } }),
+  commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
+    at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
 }
 
 /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */

+ 2 - 2
packages/client/runtime/tests/fake-api.ts

@@ -2,7 +2,7 @@
 // data source on a real clock; behavior tests need per-case responses and
 // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
 import type {
-  ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
+  ClientResponse, CommandDescriptor, HostFrame, IApiClient, MuxFrame,
   RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
   WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-client-connection/client'
@@ -119,7 +119,7 @@ export class FakeApiClient implements IApiClient {
   // skill lists without casts.
   onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
     = () => Promise.resolve(ok({ commands: [] }))
-  onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
+  onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean }>>
     = () => Promise.resolve(ok({ matched: false }))
   onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
     = () => Promise.resolve(ok({ skills: [] }))

+ 71 - 0
packages/client/runtime/tests/fold-adapter.spec.ts

@@ -142,4 +142,75 @@ describe('FoldAdapter', () => {
     const node = adapter.nodes().nodes[0]
     expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
   })
+
+  describe('command lifecycle nodes', () => {
+    it('folds a run/done pair into one settled node merged into flow order by seq', () => {
+      const adapter = new FoldAdapter()
+      adapter.reset([
+        ev.user(0, '先说话'),
+        ev.commandRun(1, 'cmd-1', 'plan', '/plan'),
+        ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
+        ev.assistant(3, 0, '然后回答'),
+      ], 0)
+      const { nodes } = adapter.nodes()
+      expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
+      expect(nodes[1]).toMatchObject({
+        kind: 'command', commandId: 'cmd-1', name: 'plan', line: '/plan',
+        outcome: { kind: 'success', text: '已进入 plan mode' },
+      })
+    })
+
+    it('renders a run with no done as still executing (outcome null)', () => {
+      const adapter = new FoldAdapter()
+      adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', '/goal ship it')], 0)
+      expect(adapter.nodes().nodes[0]).toMatchObject({
+        kind: 'command', name: 'goal', line: '/goal ship it', outcome: null,
+      })
+    })
+
+    it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
+      const adapter = new FoldAdapter()
+      adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
+      expect(adapter.nodes().nodes[0]).toMatchObject({
+        kind: 'command', seq: 80, commandId: 'cmd-3', name: null, line: null,
+        outcome: { kind: 'error', text: '失败了' },
+      })
+    })
+
+    it('settles a live-appended done in place, keeping the node at the run seq', () => {
+      const adapter = new FoldAdapter()
+      adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
+      adapter.append(ev.commandRun(6, 'cmd-4', 'clear', '/clear'))
+      const running = adapter.nodes().nodes.find(n => n.kind === 'command')
+      expect(running).toMatchObject({ outcome: null })
+      adapter.append(ev.commandDone(7, 'cmd-4'))
+      const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
+      expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
+      // Settlement replaced the node object rather than mutating the published one.
+      expect(settled).not.toBe(running)
+    })
+
+    it('tails command nodes whose seq is past every surface node', () => {
+      const adapter = new FoldAdapter()
+      adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan', '/plan')], 0)
+      expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
+    })
+
+    it('command nodes survive the degraded linear-scan branch', () => {
+      const adapter = new FoldAdapter()
+      const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+      try {
+        adapter.reset([
+          ev.commandRun(0, 'cmd-5', 'plan', '/plan'),
+          ev.commandDone(1, 'cmd-5'),
+          at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
+        ], 0)
+        const { nodes, degraded } = adapter.nodes()
+        expect(degraded).toBe(true)
+        expect(nodes.some(n => n.kind === 'command')).toBe(true)
+      } finally {
+        errorSpy.mockRestore()
+      }
+    })
+  })
 })

+ 22 - 0
packages/client/runtime/tests/session.spec.ts

@@ -99,6 +99,28 @@ describe('live event path', () => {
     expect(session.getSnapshot().nodes).toEqual(before.nodes)
   })
 
+  it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
+    // Live path: run mints an executing node, done settles it in the flow.
+    const { session } = await opened()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.commandRun(6, 'cmd-live', 'plan', '/plan'))
+    let command = session.getSnapshot().nodes.at(-1)
+    expect(command).toMatchObject({ kind: 'command', name: 'plan', line: '/plan', outcome: null })
+    feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
+    command = session.getSnapshot().nodes.at(-1)
+    expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
+
+    // Replay path (refresh): the same pair inside the history window folds identically.
+    const replayed = await opened([
+      ...plainTurn(0, 0, 'a', 'b'),
+      ev.commandRun(6, 'cmd-live', 'plan', '/plan'),
+      ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
+    ])
+    expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
+      kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
+    })
+  })
+
   it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
     const { session } = await opened()
     const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

+ 4 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -156,7 +156,10 @@ export function apply(ctx: Context): void {
     id: 'chat',
     order: 0,
     label: 'Chat',
-    children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
+    children: {
+      'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
+      'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
+    },
     store: chatStore,
     inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
       const scoped = scopedConversation(sessions, sessionId)

+ 23 - 1
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -20,7 +20,7 @@ import {
   memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
 } from 'react'
 import type {
-  CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
+  CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -28,6 +28,7 @@ import type { ChatViewSlotProps } from '../contract/slots.ts'
 import type { SelectionTarget } from '../contract/views.ts'
 import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
 import { AssistantMarkdown } from './AssistantMarkdown.tsx'
+import { GenericCommandCard } from './GenericCommandCard.tsx'
 import { GenericToolCard } from './GenericToolCard.tsx'
 import { MessageItem } from './MessageItem.tsx'
 import { PendingCard } from './PendingCard.tsx'
@@ -149,6 +150,24 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
   )
 })
 
+/** 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 }: {
+  renderSlot: RenderToolRow
+  node: CommandNode
+}) {
+  const owner = useMemo(() => ({ node }), [node])
+  return (
+    <div className={css.callRow}>
+      {renderSlot('conversation.chat.commandview', owner, {
+        entryKey: node.name ?? '',
+        fallback: <GenericCommandCard {...owner} />,
+      })}
+    </div>
+  )
+})
+
 /** The streaming partial, isolated so chunk batches re-render only this tail.
  *  onGrow lets the scroll owner follow content the parent never re-renders for. */
 function StreamingTail({ useSession, onGrow }: {
@@ -275,6 +294,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
     if (node.kind === 'assistant') {
       return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
     }
+    if (node.kind === 'command') {
+      return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
+    }
     /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
     if (node.kind === 'tool-result') return null
     return <MessageItem key={item.key} node={node} />

+ 35 - 0
packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx

@@ -0,0 +1,35 @@
+// GenericCommandCard: the default command row — a stripped-down
+// GenericToolCard rendering the dispatched command line and the settlement
+// text. Supplied by the chat view as the keyed commandview slot's render-site
+// fallback (an unregistered command name lands here); registrants may compose
+// it as a base, feeding the same owner payload through.
+
+import { ToolRow } from './ToolRow.tsx'
+import type { ToolRowState } from '../contract/tool-call-model.ts'
+import type { CommandRowOwnerProps } from '../contract/slots.ts'
+import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+
+/** Node state → row state semantic (running while unsettled; outcome kind after). */
+function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
+  if (outcome === null) return 'running'
+  return outcome.kind === 'error' ? 'error' : 'ok'
+}
+
+export function GenericCommandCard({ node }: CommandRowOwnerProps) {
+  const text = node.outcome?.text
+  const summary = node.outcome === null
+    ? '执行中…'
+    : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
+  return (
+    <ToolRow
+      variant="others"
+      icon={<IconApiOutline14 size={16} />}
+      // A cross-window node whose run page fell out of the window has no line.
+      title={node.line ?? '命令'}
+      summary={summary}
+      // Expandable only when the outcome text overflows a one-line summary.
+      body={text !== undefined && text.includes('\n') ? text : null}
+      state={stateOf(node.outcome)}
+    />
+  )
+}

+ 27 - 3
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
 import type {
   MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { CommandNode, ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
 import type { createChatStore } from '../stores.ts'
@@ -33,6 +33,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * `fallback` for unregistered tools.
      */
     'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
+    /**
+     * 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
+     * always lands on the fallback). Declared by the chat view entry; the
+     * render site dispatches via `entryKey: name` with GenericCommandCard as
+     * the `fallback` — a slash command renders durably with zero
+     * registration, and a domain upgrades by registering one row component.
+     */
+    'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
     /**
      * The composer takeover chain: entries are selector-routed replacements
      * of the default InputBar. Declared by this package's 'conversation'
@@ -156,6 +165,21 @@ export interface ToolRowOwnerProps {
  */
 export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
 
+/**
+ * Owner share of the per-command row slot: the frozen {@link CommandNode}
+ * slice off the snapshot (cache-stable reference — memo premise). The node
+ * carries the whole lifecycle (line, pairing id, outcome-or-executing), so a
+ * registrant needs no second data channel; domain state arrives through its
+ * own projection cell.
+ */
+export interface CommandRowOwnerProps {
+  /** Folded command lifecycle node (run + optional done). */
+  node: CommandNode
+}
+
+/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
+export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
+
 /**
  * Base props of a conversation view entry: the framework standard kit for the
  * session-scope 'conversation.view' slot (useSession narrowed to the
@@ -279,9 +303,9 @@ export interface ChatViewInjected {
   loadOlder: () => void
 }
 
-/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
+/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
 export type ChatViewSlotProps =
-  PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
+  PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
   & PropsStore<ChatStore> & ChatViewInjected
 
 /**

+ 2 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -13,7 +13,8 @@ export type {
 } from './contract/views.ts'
 export type { ToolCallBlock } from './contract/tool-call-model.ts'
 export type {
-  ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerBarInjected, ComposerChainProps, ConversationInjected,
+  ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
+  ComposerChainProps, ConversationInjected,
   ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
   EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
 } from './contract/slots.ts'

+ 38 - 1
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Profiler } from 'react'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type {
-  AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
+  AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
   SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -363,4 +363,41 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByText(/等待审批/)).toBeTruthy()
   })
+
+  it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
+    const command = (over: Partial<CommandNode>): CommandNode => ({
+      kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1',
+      name: 'plan', line: '/plan', outcome: { kind: 'success', text: '已进入 plan mode' },
+      ...over,
+    })
+    // Settled success: the command line is the title, the outcome text the summary.
+    const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
+    const view = render(<settled.ChatView {...settled.props} />)
+    expect(view.getByText('/plan')).toBeTruthy()
+    expect(view.getByText('已进入 plan mode')).toBeTruthy()
+
+    // Error outcome flips the row state; a text-less error gets the default copy.
+    const failed = makeHarness({
+      nodes: [command({ seq: 6, commandId: 'cmd-2', outcome: { kind: 'error' } })],
+    })
+    const fv = render(<failed.ChatView {...failed.props} />)
+    expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
+    expect(fv.getByText('命令失败')).toBeTruthy()
+
+    // Still executing: running state with the executing copy.
+    const executing = makeHarness({
+      nodes: [command({ seq: 7, commandId: 'cmd-3', outcome: null })],
+    })
+    const xv = render(<executing.ChatView {...executing.props} />)
+    expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
+    expect(xv.getByText('执行中…')).toBeTruthy()
+
+    // Cross-window soft-fall (run page truncated): generic title, outcome preserved.
+    const orphan = makeHarness({
+      nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, line: null, outcome: { kind: 'success' } })],
+    })
+    const ov = render(<orphan.ChatView {...orphan.props} />)
+    expect(ov.getByText('命令')).toBeTruthy()
+    expect(ov.getByText('已完成')).toBeTruthy()
+  })
 })