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:
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user