Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
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 { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { ComposerBlockRegistry } from './input/blocks.ts'
|
||||
import type { ComposerBlock } from './input/blocks.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
@@ -22,30 +24,28 @@ import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
|
||||
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { readToolview } from './toolviews/read-row.tsx'
|
||||
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
|
||||
import { searchToolview } from './toolviews/search-row.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
|
||||
/** The conversation skeleton, chat flow, commands, details, and docks copy. */
|
||||
conversation: ConversationKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale',
|
||||
'conversationEvents', 'conversationViews',
|
||||
]
|
||||
|
||||
// Static no-session sources for the composer-bar hooks compartment: module
|
||||
// constants so the render side's per-source hook cache (observableHook) keeps
|
||||
@@ -54,6 +54,11 @@ const ABSENT_NOTICES = {
|
||||
getSnapshot: (): InputNotice | null => null,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
/** No session, therefore nothing to block; same one-identity rule as above. */
|
||||
const ABSENT_BLOCK = {
|
||||
getSnapshot: (): ComposerBlock | undefined => undefined,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
const ABSENT_LEXICON = {
|
||||
getSnapshot: () => EMPTY_LEXICON,
|
||||
@@ -64,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = {
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
|
||||
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
hooks: {
|
||||
turnData: ({ useSession }, nodeKey) => function useTurnData(key) {
|
||||
return useSession((snapshot) => {
|
||||
const location = snapshot.chat.nodes.get(nodeKey)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get(key)
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
@@ -87,6 +105,9 @@ export function apply(ctx: Context): void {
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
registerConversationNodes(ctx)
|
||||
registerChatNodeRenderers(ctx)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
|
||||
|
||||
// Registration-time text (the view tab label) reads through the bound
|
||||
@@ -133,7 +154,13 @@ export function apply(ctx: Context): void {
|
||||
// ctx.conversation.input by the service below sharing this one instance).
|
||||
const inputHub = new InputHub(ctx, t)
|
||||
|
||||
// Decision 19/20: the input machine feeds every session-scope slot
|
||||
// The composer-block registry: a plugin that knows a session cannot send —
|
||||
// ui-model, when no adapter serves the session's route — raises a block
|
||||
// here, and the bar reads its own session's store. It cannot flow the other
|
||||
// way: this package must not import the plugins that would know.
|
||||
const composerBlocks = new ComposerBlockRegistry()
|
||||
|
||||
// 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).
|
||||
@@ -167,6 +194,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
|
||||
selectWorkspace: async (workspaceId) => {
|
||||
const nextId = await workspaces.connectWorkspace(workspaceId)
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
@@ -213,7 +241,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
|
||||
@@ -224,7 +252,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' },
|
||||
@@ -290,10 +318,8 @@ export function apply(ctx: Context): void {
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
// store, so its selection writes land in the same per-session instance the
|
||||
// details panel reads.
|
||||
// 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',
|
||||
@@ -301,8 +327,7 @@ export function apply(ctx: Context): void {
|
||||
label: () => t('view.chat'),
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
@@ -312,9 +337,10 @@ 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(resolveToolPath(cwd, path)).catch(() => {
|
||||
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {
|
||||
// Host/OS open failures stay silent in the chat row; the native
|
||||
// app surfaces its own error dialog when the path is unusable.
|
||||
})
|
||||
@@ -351,46 +377,21 @@ export function apply(ctx: Context): void {
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Presentation registrants depend directly on their slot declarations;
|
||||
// this service remains only where conversation actions are required.
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
|
||||
// The bash sample rides the same declaration seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The read row rides the same seam (a product registration, not a sample):
|
||||
// Read · {path} chrome with the file's read card resident below it.
|
||||
ctx.plugin(readToolview)
|
||||
|
||||
// The write/edit rows ride the same seam: a file-mutation call declares the
|
||||
// diff render intent, so these rows stack the applied diff card under their
|
||||
// path-link summary (the terminal card's posture, applied to diffs).
|
||||
ctx.plugin(fileMutationToolview)
|
||||
|
||||
// The grep/glob search row rides the same seam: one component registered
|
||||
// under both tool names, since both declare the same search render intent.
|
||||
ctx.plugin(searchToolview)
|
||||
|
||||
// The web rows ride the same seam: one WebRow registered under both
|
||||
// web_search and web_fetch, rendering the completed retrieval's web card
|
||||
// resident under the summary (a product registration, not a sample).
|
||||
ctx.plugin(webToolview)
|
||||
|
||||
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||
ctx.plugin(todoToolview)
|
||||
|
||||
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
|
||||
ctx.plugin(askQuestionToolview)
|
||||
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
|
||||
|
||||
// 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({
|
||||
name: 'details',
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.details.tool': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
export interface AssistantMarkdownProps {
|
||||
@@ -25,65 +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
|
||||
/** 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']
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const nl = text.indexOf('\n')
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
/** Latest non-blank reasoning line while the block is still streaming. */
|
||||
function latestLine(text: string): string {
|
||||
const visible = text.trimEnd()
|
||||
const nl = visible.lastIndexOf('\n')
|
||||
return nl === -1 ? visible : visible.slice(nl + 1)
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
summary={running ? latestLine(text) : firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, 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.
|
||||
@@ -96,17 +43,21 @@ 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 <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
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.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
@@ -121,20 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
runMs={runMs}
|
||||
ttftMs={ttftMs}
|
||||
tokensPerSecond={tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
|
||||
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
|
||||
export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
node, useTurnData, openFile, fileMentions, t,
|
||||
}: ChatNodeViewProps<'assistant-step'>) {
|
||||
const data = node.data
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
const tail = useTurnData('turn-tail')
|
||||
const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
|
||||
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
|
||||
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
|
||||
return { turn, seq: data.finalNode.seq, openFile }
|
||||
}, [data.finalNode, openFile, tail, turn])
|
||||
const mentions = useMemo(
|
||||
() => owner === undefined ? undefined : fileMentions(owner),
|
||||
[fileMentions, owner],
|
||||
)
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={data.blocks}
|
||||
streaming={data.status === 'running'}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
mentions={mentions}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
|
||||
readonly nodeKey: string
|
||||
readonly useSession: ChatViewSlotProps['useSession']
|
||||
readonly renderSlot: ChatViewSlotProps['renderSlot']
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
type RoutedChatNodeOwner = {
|
||||
[Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
|
||||
}[ChatNode['kind']]
|
||||
|
||||
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
|
||||
export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
fileMentions, useSession, renderSlot, t,
|
||||
}: ChatNodeSeatProps) {
|
||||
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
|
||||
const routedNode = node as ChatNode | undefined
|
||||
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
|
||||
? null
|
||||
: {
|
||||
selectedCallId,
|
||||
cwd,
|
||||
openFile,
|
||||
inspectCall,
|
||||
forkAt,
|
||||
fileMentions,
|
||||
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions])
|
||||
if (routedNode === undefined || owner === null) return null
|
||||
// Runtime dispatch owns the correlation: every Node's discriminant is the
|
||||
// keyed-slot entry passed alongside that same Node. TypeScript does not
|
||||
// distribute an object containing a union into a union of objects itself.
|
||||
const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
|
||||
return (
|
||||
<div
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={routedNode.key}
|
||||
data-chat-flow-key={routedNode.key}
|
||||
data-chat-flow-kind={routedNode.kind}
|
||||
>
|
||||
{renderSlot('conversation.chat.node', routedOwner, {
|
||||
entryKey: routedNode.kind,
|
||||
hookContext: nodeKey,
|
||||
fallback: (
|
||||
<JsonBlock
|
||||
label={t('message.unknownSurface', { type: routedNode.kind })}
|
||||
payload={routedNode.data}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
|
||||
runs) via the column gap and between consecutive tool rows via the group
|
||||
gap. Input padding cap rides the skeleton. Under
|
||||
/* Chat flow: one 16px rhythm everywhere through the column gap. Input
|
||||
padding cap rides the skeleton. Under
|
||||
`[data-conversation-scroll]` the column host owns overflow and this view
|
||||
is ordinary flow (see ConversationRoot active-phase rules). */
|
||||
|
||||
@@ -51,10 +50,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
/* A keyed renderer may intentionally decline its row after dispatch (the
|
||||
completed-turn tail does this when it owns neither actions nor extensions).
|
||||
An empty flex item must not consume the column gap. */
|
||||
.flowItem:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.callRow {
|
||||
@@ -64,17 +64,6 @@
|
||||
/* Selection still sets data-selected for details linkage; no outline —
|
||||
tool rows match Think chrome (no selected ring). */
|
||||
|
||||
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
|
||||
the code turn reads as one unit; each nested row is itself a .callRow. */
|
||||
.subCalls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 22px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Turn activity keeps the former loader's one-line footprint. A pale
|
||||
brand-blue band sweeps from left to right; reduced-motion keeps it static. */
|
||||
.turnStatus {
|
||||
|
||||
@@ -1,42 +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 keyed
|
||||
// 'conversation.chat.toolview' hole, so tool rows render through the props
|
||||
// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
|
||||
// fallback).
|
||||
// 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 {
|
||||
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, 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 { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.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
|
||||
@@ -99,30 +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
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = 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 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)
|
||||
@@ -135,153 +95,13 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP
|
||||
}
|
||||
}
|
||||
|
||||
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
inspect: () => { inspectCall(node.callId) },
|
||||
}), [node, toolName, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<div
|
||||
className={css.callRow}
|
||||
data-chat-anchor-key={`call:${node.callId}`}
|
||||
data-chat-call-id={node.callId}
|
||||
data-selected={selected || undefined}
|
||||
>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} t={t} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One tool call row (result or running): dispatches through the keyed
|
||||
* toolview slot with the owner payload; unregistered tools fall back to
|
||||
* GenericToolCard at this render site. A `run_code` call additionally
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, openFile, cwd,
|
||||
inspect: () => { inspectCall(callId) },
|
||||
}), [callId, toolName, block, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<div
|
||||
className={css.callRow}
|
||||
data-chat-anchor-key={`call:${callId}`}
|
||||
data-chat-call-id={callId}
|
||||
data-selected={selected || undefined}
|
||||
>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} t={t} />,
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map(node => (
|
||||
<SubCallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
node={node}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map(node => (
|
||||
<CallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</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, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CommandNode
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node }), [node])
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
})}
|
||||
</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 }: {
|
||||
@@ -319,52 +139,32 @@ function TurnStatus({ startTime, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** The streaming partial, isolated so chunk batches re-render only this tail;
|
||||
* the column ResizeObserver owns bottom-follow when its box grows. */
|
||||
function StreamingTail({ useSession, t }: {
|
||||
useSession: UseConversation
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const partial = useSession(s => s.partial)
|
||||
if (partial === null) return null
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
* The chat view slot entry: pure component over the composed props; each
|
||||
* ordered business Node crosses the keyed renderer seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, 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 codeDispatches = useSession(s => s.codeDispatches)
|
||||
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)
|
||||
@@ -372,9 +172,6 @@ export function ChatView({
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
/** Last position delivered or written on the main thread. */
|
||||
const observedTopRef = useRef(0)
|
||||
/** Pre-input position for the current wheel gesture. */
|
||||
const wheelStartRef = useRef<number | null>(null)
|
||||
const wheelEpochRef = useRef(0)
|
||||
/** Paging anchor: semantic row/position at click, updated by reader scrolls
|
||||
* while the request is pending and restored after the prepend lands. */
|
||||
const anchorRef = useRef<PagingAnchor | null>(null)
|
||||
@@ -383,19 +180,18 @@ 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 => {
|
||||
wheelStartRef.current = null
|
||||
wheelEpochRef.current += 1
|
||||
anchorRef.current = null
|
||||
el.scrollTop = el.scrollHeight
|
||||
observedTopRef.current = el.scrollTop
|
||||
@@ -454,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
|
||||
@@ -472,17 +267,19 @@ export function ChatView({
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
// Only wheel input may make raw scroll geometry change follow ownership.
|
||||
// Browser clamping and delayed programmatic scroll events otherwise have
|
||||
// the same event shape and must preserve the current ownership state.
|
||||
// Only reader input may make raw scroll geometry change follow ownership:
|
||||
// a delivered position that deviates from the observed-top ledger (every
|
||||
// programmatic write records itself there synchronously). This covers
|
||||
// wheel, touch, scrollbar, and keyboard alike without naming devices.
|
||||
// Browser shrink-clamps land exactly on the floor min and delayed
|
||||
// programmatic deliveries land on the ledger itself, so both preserve
|
||||
// the current ownership state.
|
||||
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const wheelStart = wheelStartRef.current
|
||||
const movedByWheel = wheelStart !== null
|
||||
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
|
||||
const isAtBottom = movedByWheel
|
||||
const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5
|
||||
const isAtBottom = movedByReader
|
||||
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
|
||||
: atBottomRef.current
|
||||
if (!movedByWheel && isAtBottom) {
|
||||
if (!movedByReader && isAtBottom) {
|
||||
toBottom(el)
|
||||
return
|
||||
}
|
||||
@@ -501,34 +298,18 @@ export function ChatView({
|
||||
observedTopRef.current = el.scrollTop
|
||||
}
|
||||
|
||||
// Bind scroll and the wheel provenance needed to distinguish reader input
|
||||
// from layout-driven scrolls on the resolved scrollport once per mount.
|
||||
// Bind the scroll listener on the resolved scrollport once per mount;
|
||||
// reader-input attribution rides the observed-top ledger, not per-device
|
||||
// input listeners.
|
||||
useEffect(() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const onScroll = (): void => { onScrollRef.current() }
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.ctrlKey || event.deltaY === 0) return
|
||||
const startTop = observedTopRef.current
|
||||
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
|
||||
if (!canMove) return
|
||||
wheelStartRef.current = startTop
|
||||
const epoch = ++wheelEpochRef.current
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
|
||||
})
|
||||
})
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
return () => {
|
||||
wheelStartRef.current = null
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('wheel', onWheel, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -581,62 +362,6 @@ export function ChatView({
|
||||
loadOlder()
|
||||
}
|
||||
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some(r => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||||
return (
|
||||
<ToolGroup
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
openFile={openFile}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
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)}
|
||||
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}>
|
||||
@@ -654,38 +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)}` : undefined}
|
||||
data-chat-flow-key={item.key}
|
||||
data-chat-flow-kind={item.kind === 'node' ? item.node.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 => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
openFile={openFile}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
ChatNodeViewProps, CommandRowOwnerProps,
|
||||
} from '../contract/slots.ts'
|
||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
type CommandNodeViewProps = ChatNodeViewProps<'command'> & PropsRenderSlots<'conversation.chat.commandview'>
|
||||
|
||||
/** Ordinary command lifecycle renderer with command-name keyed specialization. */
|
||||
export const CommandNodeView = memo(function CommandNodeView({ node, renderSlot, t }: CommandNodeViewProps) {
|
||||
const command = node.data
|
||||
const owner = useMemo<CommandRowOwnerProps>(() => ({ node: command }), [command])
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: command.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One integrated `/compact` command and compaction transaction renderer. */
|
||||
export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({
|
||||
node, t,
|
||||
}: ChatNodeViewProps<'manual-compaction'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
<CompactionCommandCard
|
||||
node={data.command}
|
||||
{...data.compaction === null ? {} : { compaction: data.compaction }}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
// CompactionCommandCard: the `/compact` command's running row and its
|
||||
// successful checkpoint disclosure. Outcomes without a checkpoint keep the
|
||||
// generic command card so no-history, cancellation, and failures retain their
|
||||
// complete handler-authored text.
|
||||
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
|
||||
interface CompactionCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */
|
||||
export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) {
|
||||
if (compaction !== undefined) {
|
||||
return (
|
||||
<CompactionItem
|
||||
node={compaction}
|
||||
title="compact"
|
||||
fallbackSummary={node.outcome?.text ?? null}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.outcome !== null) return <GenericCommandCard node={node} t={t} />
|
||||
return <GenericCommandCard node={node} t={t} runningSummary={t('message.compaction.running')} />
|
||||
}
|
||||
@@ -3,12 +3,13 @@
|
||||
// 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'
|
||||
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconApiOutline14,
|
||||
IconChevronDownOutline14,
|
||||
IconChevronRightOutline14,
|
||||
MarkdownText,
|
||||
@@ -18,6 +19,10 @@ import css from './MessageItem.module.css'
|
||||
|
||||
interface CompactionItemProps {
|
||||
node: CompactionSummaryNode
|
||||
/** Optional command title for a manual compaction folded into this marker. */
|
||||
title?: string
|
||||
/** Command settlement text used when structured compaction counts are unavailable. */
|
||||
fallbackSummary?: string | null
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -27,10 +32,22 @@ interface CompactionItemProps {
|
||||
* @param props - the marker node off the snapshot cache.
|
||||
* @returns the marker row, with the summary disclosure when one is available.
|
||||
*/
|
||||
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
|
||||
export const CompactionItem = memo(function CompactionItem({
|
||||
node,
|
||||
title,
|
||||
fallbackSummary,
|
||||
t,
|
||||
}: CompactionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = node.summary !== null
|
||||
const open = expandable && expanded
|
||||
const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null
|
||||
? t('message.compaction.completed', {
|
||||
items: node.shadowedItemCount,
|
||||
tokens: node.shadowedTokenCount,
|
||||
})
|
||||
: fallbackSummary
|
||||
?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable'))
|
||||
return (
|
||||
<div className={css.compactionRow}>
|
||||
<button
|
||||
@@ -40,14 +57,20 @@ export const CompactionItem = memo(function CompactionItem({ node, t }: Compacti
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={() => { setExpanded(value => !value) }}
|
||||
>
|
||||
<span className={css.compactionLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
<span className={css.compactionLeading} aria-hidden>
|
||||
<span className={css.compactionContextIcon} data-compaction-icon="context">
|
||||
<IconApiOutline14 />
|
||||
</span>
|
||||
<span
|
||||
className={css.compactionDisclosureIcon}
|
||||
data-compaction-disclosure={open ? 'expanded' : 'collapsed'}
|
||||
>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.compactionTitle}>{t('message.compaction')}</span>
|
||||
<span className={css.compactionTitle}>{title ?? t('message.compaction')}</span>
|
||||
<span className={css.compactionSep} aria-hidden />
|
||||
<span className={css.compactionSummary}>
|
||||
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
|
||||
</span>
|
||||
<span className={css.compactionSummary}>{summary}</span>
|
||||
</button>
|
||||
{open && node.summary !== null
|
||||
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { contextBody } from './ContextBody.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
@@ -26,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) {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
button.leading {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './DisclosureRow.module.css'
|
||||
|
||||
/** Shared 24px disclosure chrome for conversation flow rows. */
|
||||
export interface DisclosureRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
open: boolean
|
||||
expandable: boolean
|
||||
onToggle: () => void
|
||||
/** Makes the complete title row the disclosure target. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
||||
previewChevron?: boolean | undefined
|
||||
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
|
||||
keepContentWhenOpen?: boolean | undefined
|
||||
collapsedContent?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string | undefined
|
||||
rowClassName?: string | undefined
|
||||
leadingClassName?: string | undefined
|
||||
chevronClassName?: string | undefined
|
||||
titleClassName?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one disclosure header and its controlled expanded content.
|
||||
* @param props - Visual content, controlled state, and interaction policy.
|
||||
* @returns The disclosure row.
|
||||
*/
|
||||
export function DisclosureRow({
|
||||
icon,
|
||||
title,
|
||||
open,
|
||||
expandable,
|
||||
onToggle,
|
||||
expandOnRowClick = false,
|
||||
previewChevron = expandable,
|
||||
keepContentWhenOpen = false,
|
||||
collapsedContent,
|
||||
children,
|
||||
className,
|
||||
rowClassName,
|
||||
leadingClassName,
|
||||
chevronClassName,
|
||||
titleClassName,
|
||||
}: DisclosureRowProps) {
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
const collapsedLeading = previewChevron
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={chevronClassName} />
|
||||
: collapsedLeading
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, className)} data-open={open || undefined}>
|
||||
<div
|
||||
className={clsx(css.row, rowClassName)}
|
||||
data-disclosure-row
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? onToggle : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.leading, leadingClassName)}
|
||||
aria-expanded={open}
|
||||
onClick={toggleFromLeading}
|
||||
>
|
||||
{leading}
|
||||
</button>
|
||||
) : (
|
||||
<span className={clsx(css.leading, leadingClassName)}>
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(css.title, titleClassName)}>{title}</span>
|
||||
{(keepContentWhenOpen || !open) && collapsedContent}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-command-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-command-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary[data-error],
|
||||
.body[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: 260px;
|
||||
margin: 4px 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root[data-state='running'] .row::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -4,42 +4,70 @@
|
||||
// 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 { useState, type ReactNode } from 'react'
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow, IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './GenericCommandCard.module.css'
|
||||
|
||||
type CommandRowState = 'running' | 'ok' | 'error'
|
||||
|
||||
/** Node state → row state semantic (running while unsettled; outcome kind after). */
|
||||
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
|
||||
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState {
|
||||
if (outcome === null) return 'running'
|
||||
return outcome.kind === 'error' ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
function leadingFor(state: CommandRowState): ReactNode {
|
||||
return state === 'error' ? <StateDot state="error" /> : <IconApiOutline14 size={14} />
|
||||
}
|
||||
|
||||
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
|
||||
export interface GenericCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
/** Command-specific running copy; absent uses the generic command label. */
|
||||
runningSummary?: string | undefined
|
||||
}
|
||||
|
||||
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
||||
export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const text = node.outcome?.text
|
||||
const summary = node.outcome === null
|
||||
? t('command.running')
|
||||
? runningSummary ?? t('command.running')
|
||||
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
|
||||
// Title is the bare command name: the row already reads `name · outcome`,
|
||||
// and the dispatched line's own `/` and arguments only restate what the
|
||||
// settlement text says (`permission · preset workspace-write`). A
|
||||
// cross-window node whose run page fell out of the window has no name.
|
||||
const title = node.name ?? t('command.title')
|
||||
const state = stateOf(node.outcome)
|
||||
const body = text !== undefined && text.includes('\n') ? text : null
|
||||
const open = expanded && body !== null
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={14} />}
|
||||
title={title}
|
||||
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)}
|
||||
/>
|
||||
<div className={css.root} data-variant="others" data-state={state}>
|
||||
{state === 'running' && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
|
||||
{state === 'error' && <span className={a11yCss.visuallyHidden}>{t('row.failed')}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(state)}
|
||||
title={title}
|
||||
open={open}
|
||||
expandable={body !== null}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.summary} data-error={state === 'error' || undefined}>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// GenericToolCard: the default tool row — classifies the tool into one of
|
||||
// the five figma row variants and renders the summary row. Supplied by the
|
||||
// chat view as the keyed toolview slot's render-site fallback (an
|
||||
// unregistered tool name lands here); registrants may also compose it as a
|
||||
// base, feeding the same owner payload through.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
think: <IconThinkOutline14 size={14} />,
|
||||
search: <IconSearchOutline16 size={14} />,
|
||||
read: <IconBrowseOutline16 size={14} />,
|
||||
bash: <IconApiOutline14 size={14} />,
|
||||
write: <IconEditOutline16 size={14} />,
|
||||
edit: <IconEditOutline16 size={14} />,
|
||||
code: <IconCodeOutline16 size={14} />,
|
||||
others: <IconSparkle16 size={14} />,
|
||||
}
|
||||
|
||||
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
|
||||
export interface GenericToolCardProps extends ToolRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const search = searchCardModel(block)
|
||||
const web = webCardModel(block)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow;
|
||||
// a search result view's replacement title outranks it the same way.
|
||||
summary={terminal?.description ?? search?.title ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only
|
||||
// args interaction. A card is not an args body: a read/write/edit row is
|
||||
// single-file AND carries a card, so the card expands under the path link.
|
||||
body={singleFile ? null : model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
diff={diff}
|
||||
read={read}
|
||||
search={search}
|
||||
web={web}
|
||||
state={state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -33,9 +33,9 @@
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Compaction marker: one dim 24px row with a chevron disclosure for the
|
||||
summary body. Dimmed title (not label-primary) — the row is a boundary
|
||||
notice, not conversation content. */
|
||||
/* Compaction marker: one dim 24px row with a context icon at rest and a
|
||||
hover/focus disclosure for the summary body. Dimmed title (not
|
||||
label-primary) — the row is a boundary notice, not conversation content. */
|
||||
.compactionRow {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -65,15 +65,36 @@
|
||||
|
||||
.compactionLeading {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.compactionContextIcon,
|
||||
.compactionDisclosureIcon {
|
||||
display: inline-flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compactionDisclosureIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionContextIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionDisclosureIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compactionTitle {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -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[] = []
|
||||
@@ -137,29 +122,27 @@ 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. Two
|
||||
* shapes decorate: legacy `<skill>name</skill>` spans (pre-decision-21
|
||||
* history) and plain-text `/name` / `@name` word-boundary tokens (decision
|
||||
* 21: 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).
|
||||
* logged model text remains the single truth; this is presentation only.
|
||||
* 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).
|
||||
*/
|
||||
function projectUserText(text: string): ReactNode {
|
||||
const re = /<skill>([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g
|
||||
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const legacy = m[1] !== undefined
|
||||
const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0)
|
||||
const label = legacy ? `/${m[1]}` : m[3] ?? ''
|
||||
const tokenStart = m.index + (m[1]?.length ?? 0)
|
||||
const label = m[2] ?? ''
|
||||
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
|
||||
parts.push(
|
||||
<span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
|
||||
{label}
|
||||
</span>,
|
||||
)
|
||||
cursor = legacy ? m.index + m[0].length : tokenStart + label.length
|
||||
cursor = tokenStart + label.length
|
||||
}
|
||||
if (parts.length === 0) return <MessageText text={text} />
|
||||
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)
|
||||
@@ -221,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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-reasoning-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-reasoning-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary[data-follow-end] {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.thinkBody {
|
||||
padding: 4px 0 4px 22px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root[data-state='running'] .row::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { DisclosureRow, IconThinkOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './ReasoningRow.module.css'
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const newline = text.indexOf('\n')
|
||||
return newline === -1 ? text : text.slice(0, newline)
|
||||
}
|
||||
|
||||
function latestLine(text: string): string {
|
||||
const visible = text.trimEnd()
|
||||
const newline = visible.lastIndexOf('\n')
|
||||
return newline === -1 ? visible : visible.slice(newline + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one assistant reasoning block as the Think disclosure row.
|
||||
* @param props.text - complete or streaming reasoning text.
|
||||
* @param props.running - whether this block is the streaming tail.
|
||||
* @param props.t - conversation locale seat for the running status.
|
||||
* @returns the reasoning disclosure.
|
||||
*/
|
||||
export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const summaryRef = useRef<HTMLSpanElement>(null)
|
||||
const summary = running ? latestLine(text) : firstLine(text)
|
||||
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
|
||||
const element = summaryRef.current
|
||||
if (element === null) return
|
||||
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
|
||||
})
|
||||
useEffect(() => {
|
||||
scheduleSummaryScroll()
|
||||
}, [running, scheduleSummaryScroll, summary])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
||||
{running && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
open={expanded}
|
||||
expandable
|
||||
expandOnRowClick
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span ref={summaryRef} className={css.summary} data-follow-end={running || undefined}>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.thinkBody}>{text}</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -157,9 +157,9 @@ export interface StatsLineProps {
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const settledNodes = useSession(s => s.chat.legacy.nodes)
|
||||
const stats = useMemo(() => deriveStats(settledNodes), [settledNodes])
|
||||
const usage = useProjection('tokenUsage')
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = []
|
||||
if (stats.steps > 0) {
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
/* Tool summary row (figma 122:9479): 24px single line —
|
||||
[16 leading] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
|
||||
theme background at 60% — glides over the row content from off-left to
|
||||
off-right, washing glyphs and icon toward the background as it passes.
|
||||
ease-out with a 10% end hold gives each pass a beat before the next. */
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-tool-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
|
||||
shared product accent and tool-owned action title. */
|
||||
.root[data-tool^='cordis_'] .leading,
|
||||
.root[data-tool^='cordis_'] .title {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.root[data-tool^='cordis_'] .title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.root[data-tool^='cordis_'] .sep {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Live reasoning follows its one-line summary to the inline end. */
|
||||
.summary[data-follow-end] {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
|
||||
whole value is that it survives a narrow row (the todo row's parallel-active
|
||||
`+n`). Repeats .summary's type because it sits beside that text, and its
|
||||
`nowrap` too: `flex: none` stops the box shrinking but not the text wrapping,
|
||||
which would break the one-line row in the narrow case the slot exists for. */
|
||||
.summarySuffix {
|
||||
flex: none;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hover-revealed jump to the trajectory record: a small pill in real flow
|
||||
under the expanded body's bottom-left corner (it reserves its line, so
|
||||
revealing never shifts layout); revealed by hovering anywhere on the tool
|
||||
call — title row included — or by keyboard focus. */
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
/* Base background, not bg-overlay: the overlay token is a raised dark
|
||||
surface and reads too heavy for a quiet in-flow affordance. */
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.root:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Solid hover fill (a translucent token would let content bleed through). */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card
|
||||
and the terminal card scroll INSIDE their own surface instead, so the
|
||||
scrollbar sits within the rounded card. */
|
||||
.bodyScroll {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card
|
||||
(the reasoning is not an input payload), pre-wrapped at the row's indent.
|
||||
Uncapped: reasoning reads as message prose, so it flows with the page
|
||||
instead of scrolling in a box. */
|
||||
.thinkBody {
|
||||
padding: 4px 0 4px 22px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Expanded input/output card (figma 1249:35657): the code-block surface and
|
||||
radius from the TerminalBlock/CodeBlock family. The card itself is a plain
|
||||
column — the padding and the IN/OUT gutter-label grid live on each section
|
||||
so the divider spans the full card width and each section scrolls alone. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
|
||||
independently so a long input never buries a short output (and vice versa). */
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
|
||||
it floats off the rounded card edge instead of hugging it (the terminal
|
||||
card's own output scroller carries the same treatment in TerminalBlock). */
|
||||
.ioSection::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Track end-margins keep the thumb's travel out of the rounded corners. */
|
||||
.ioSection::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Caption (not tertiary): one step dimmer than the payload text so the
|
||||
gutter labels read as labels, not as part of the content. Sticky against
|
||||
the section's own scroll so the label stays readable while its payload
|
||||
scrolls underneath (top 0 = the section's padding edge inside the
|
||||
scrollport; start-aligned because sticky needs a block-start anchor). */
|
||||
.ioLabel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* l2 hairline between the IN and OUT sections, spanning the full card width
|
||||
(it sits between the padded sections, not inside their grid). */
|
||||
.ioDivider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.ioText {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* A failed call's OUT text shares the collapsed summary's error color. */
|
||||
.ioText[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript), a terminal card's command
|
||||
output through TerminalBlock, a diff card through DiffBlock, a read card's
|
||||
line-numbered window through ReadBlock, a search card's grouped matches or
|
||||
path list through SearchBlock, and a web card's citation/source list through
|
||||
WebBlock. All are drawn by the shared primitive, so only the row's
|
||||
indentation is this file's concern — the margin also replaces each
|
||||
primitive's own standalone vertical spacing with the flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody,
|
||||
.diffBody,
|
||||
.readBody,
|
||||
.searchBody,
|
||||
.webBody {
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
/* The recovery footer for a capped search: the result text (its `Full … stored
|
||||
at …` locator) below the card in the muted tone, since the card holds only the
|
||||
retained rows. Same column indent as the card body. */
|
||||
.searchRecovery {
|
||||
margin: 4px 0 4px 4px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* In-row code renders at the smaller code size (12/18) via each primitive's
|
||||
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
|
||||
.codeBody {
|
||||
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
|
||||
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
|
||||
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
|
||||
.terminalBody {
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* Visually hidden run-state label for assistive technology: the StateDot and
|
||||
the running sweep are aria-hidden / colour-only, so the text carries the
|
||||
running/failed/interrupted state to a screen reader. */
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary, drawn through the shared
|
||||
// DisclosureRow chrome with the whole row as the expand toggle (click /
|
||||
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
||||
// one line; every row with body, output, or a card material (terminal, diff,
|
||||
// read, search, web) is expandable; the summary stays inline while open,
|
||||
// except Think, where the running collapsed row follows the latest line at its
|
||||
// scroll end and the summary yields while open to avoid repeating the body.
|
||||
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
||||
// text input/output, the run_code program through CodeBlock, or a card
|
||||
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
|
||||
// call that declared that render intent — lives in a max-height scroll
|
||||
// container so a long payload scrolls internally instead of taking over the
|
||||
// message flow; Think's prose is the exception and flows uncapped like message
|
||||
// text. Every card kind starts collapsed, so a run of tool calls stays
|
||||
// scannable; the details panel is the single-call full-height reading surface.
|
||||
// Expand state is component-local view state. File-tool summaries are path
|
||||
// links that open through the host (stopPropagation keeps the two gestures
|
||||
// independent); an error row's collapsed summary is the failure's first line in
|
||||
// the error color.
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
export interface ToolRowProps {
|
||||
/** The render site's conversation locale seat (terminal/code body copy). */
|
||||
t: TranslateNS<'conversation'>
|
||||
variant: ToolRowVariant
|
||||
/** Wire tool name for tool-owned styling layered over the generic variant. */
|
||||
toolName?: string | undefined
|
||||
/** Leading 16px tool icon, shown while collapsed and not running/failed. */
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Trailing summary fragment rendered outside the ellipsized summary text, so
|
||||
* a narrow row clips the summary before this. For a fragment whose whole
|
||||
* value is surviving that clip — the todo row's parallel-active count.
|
||||
* null/absent = the summary is the whole collapsed content. Dropped on an
|
||||
* error row, whose collapsed summary is the failure line instead.
|
||||
*/
|
||||
summarySuffix?: string | null | undefined
|
||||
/** Expanded-body input text; null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text for the expanded Output section; null/absent = no output section. */
|
||||
output?: string | null | undefined
|
||||
/** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
|
||||
errorSummary?: string | null | undefined
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text sections when
|
||||
* present. A call carries at most one card kind, so the card props below are
|
||||
* mutually exclusive.
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
/**
|
||||
* Diff-card material for a call whose render intent is a diff card (derived by
|
||||
* `diffCardModel`); it replaces the text body when present, the same way
|
||||
* `terminal` does.
|
||||
*/
|
||||
diff?: DiffCardModel | null | undefined
|
||||
/**
|
||||
* Read-card material for a call whose render intent is a read card (derived by
|
||||
* `readCardModel`); it replaces the text body with the file's line-numbered,
|
||||
* syntax-highlighted window when present.
|
||||
*/
|
||||
read?: ReadCardModel | null | undefined
|
||||
/**
|
||||
* Search-card material for a call whose render intent is a search card
|
||||
* (derived by `searchCardModel`); it replaces the text body with grouped
|
||||
* matches or a path list when present.
|
||||
*/
|
||||
search?: SearchCardModel | null | undefined
|
||||
/**
|
||||
* Web-card material for a call whose render intent is a web card (derived by
|
||||
* `webCardModel`); it replaces the text body with the retrieval's citation
|
||||
* list or fetched-source card when present.
|
||||
*/
|
||||
web?: WebBlockProps | null | undefined
|
||||
state: ToolRowState
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
* renders as a hover-underline link that opens the host default app.
|
||||
*/
|
||||
filePath?: string | undefined
|
||||
/** Open the path with the host OS default application (already cwd-resolved). */
|
||||
onOpenFile?: ((path: string) => void) | undefined
|
||||
/**
|
||||
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
|
||||
* over the expanded body. Absent = no affordance (rows without a call
|
||||
* identity, like Think).
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
|
||||
function IconInspect() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
|
||||
* the row sweep (CSS on data-state) carries the in-flight signal. */
|
||||
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return icon
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden run-state label: the StateDot and the CSS sweep are both
|
||||
* aria-hidden / colour-only, so assistive technology needs this text to know a
|
||||
* row is running, failed, or interrupted. null in the ok state (the icon and
|
||||
* summary already describe a settled row). */
|
||||
function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null {
|
||||
switch (state) {
|
||||
case 'running': return t('row.running')
|
||||
case 'error': return t('row.failed')
|
||||
case 'stopped': return t('row.stopped')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
t,
|
||||
variant,
|
||||
toolName,
|
||||
icon,
|
||||
title,
|
||||
summary,
|
||||
summarySuffix,
|
||||
body,
|
||||
output,
|
||||
errorSummary,
|
||||
terminal,
|
||||
diff,
|
||||
read,
|
||||
search,
|
||||
web,
|
||||
state,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
inspect,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const summaryRef = useRef<HTMLSpanElement>(null)
|
||||
const terminalBody = terminal ?? null
|
||||
const diffBody = diff ?? null
|
||||
const readBody = read ?? null
|
||||
const searchBody = search ?? null
|
||||
const webBody = web ?? null
|
||||
const outputText = output ?? null
|
||||
// A card replaces the text body; a call carries at most one card kind, so the
|
||||
// card props are mutually exclusive. Any of them, or a text body/output,
|
||||
// makes the row expandable.
|
||||
const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
|
||||
const expandable = body !== null || outputText !== null || card !== null
|
||||
const open = expanded && expandable
|
||||
// The run-state label AT needs: the StateDot and the running sweep are both
|
||||
// aria-hidden / colour-only, so a stopped or running row is otherwise silent.
|
||||
const status = stateStatus(state, t)
|
||||
// An error row's collapsed summary IS the failure: the first error line in
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line replaces the summary wholesale, so a suffix derived from
|
||||
// the call args has nothing left to sit beside.
|
||||
const suffix = failureLine === null ? summarySuffix ?? null : null
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const isThink = variant === 'think'
|
||||
const followSummaryEnd = isThink && state === 'running' && !open
|
||||
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
|
||||
const summaryElement = summaryRef.current
|
||||
if (summaryElement === null) return
|
||||
summaryElement.scrollLeft = followSummaryEnd
|
||||
? summaryElement.scrollWidth - summaryElement.clientWidth
|
||||
: 0
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!isThink) return
|
||||
scheduleSummaryScroll()
|
||||
}, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Keep Enter/Space on the focused path link from bubbling to the row's
|
||||
// keydown handler, which would preventDefault() the key and toggle expand
|
||||
// instead of activating the link — the keyboard analogue of openFile's
|
||||
// stopPropagation. The native button still fires its own onClick from the key.
|
||||
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
}
|
||||
// Think reasoning is prose, not an input payload: expanded, it renders as
|
||||
// plain indented text (no IN/OUT card) and the inline summary yields to avoid
|
||||
// repeating the body.
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : body
|
||||
// The state substitution rides the idle icon slot, so an expandable error
|
||||
// row keeps DisclosureRow's icon→chevron hover preview (its default) instead
|
||||
// of losing it with the icon.
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(state, icon)}
|
||||
title={title}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen={!isThink}
|
||||
onToggle={toggleExpand}
|
||||
collapsedContent={summaryText !== '' && (
|
||||
/* An empty summary drops the separator with it (a row that is only
|
||||
its title shows no trailing dot). */
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{fileLink ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
onKeyDown={fileLinkKeyDown}
|
||||
>
|
||||
{summaryText}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
ref={isThink ? summaryRef : undefined}
|
||||
className={clsx(css.summary, failureLine !== null && css.errorSummary)}
|
||||
data-follow-end={followSummaryEnd || undefined}
|
||||
>
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
{suffix !== null && <span className={css.summarySuffix}>{suffix}</span>}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{/* The wrapper (sibling of the header row, so clicks inside never
|
||||
toggle it) carries the expanded body and the Inspect pill below. */}
|
||||
<div className={css.bodyWrap}>
|
||||
{terminalBody !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: diffBody !== null
|
||||
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
||||
: readBody !== null
|
||||
? <ReadBlock {...readBody} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
|
||||
: searchBody !== null
|
||||
? (
|
||||
<>
|
||||
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
||||
{/* A capped search's recovery locator lives only in the result
|
||||
text; show it below the card so the dropped rows survive. */}
|
||||
{searchBody.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{searchBody.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} className={css.webBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.inspectButton}
|
||||
onClick={inspect}
|
||||
>
|
||||
<IconInspect />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: -6px;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,154 +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, 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[] }
|
||||
|
||||
/**
|
||||
* 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[] = []
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(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: `n${node.seq}`, node })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Key projection for the list parent's selector (content-blind identity).
|
||||
* @param items - derived flow items.
|
||||
* @returns joined key string usable with Object.is short-circuiting.
|
||||
*/
|
||||
export function flowKeys(items: readonly ChatFlowItem[]): string {
|
||||
return items.map(i => i.key).join('|')
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { NS } from '../locales.ts'
|
||||
import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
UnknownNodeView, UserMessageNodeView,
|
||||
} from './MessageItem.tsx'
|
||||
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
|
||||
/**
|
||||
* Register this package's business renderers behind the keyed Chat Node seat.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerChatNodeRenderers(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'command',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } },
|
||||
}, CommandNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'manual-compaction', locale: NS }, ManualCompactionNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'compaction', locale: NS }, CompactionNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'turn-tail',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
}, TurnTailNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
ConversationSnapshot, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
|
||||
function toolNode(node: ReturnType<ConversationSnapshot['chat']['nodes']['get']>): ChatNode<'tool-call'> | undefined {
|
||||
return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one root Tool lifecycle through the internal Chat Node index.
|
||||
* @param snapshot - current Conversation snapshot.
|
||||
* @param rootCallId - root call identity and Tool Context identity.
|
||||
* @returns root lifecycle when it is materialized in the current window.
|
||||
*/
|
||||
export function rootToolCall(
|
||||
snapshot: ConversationSnapshot,
|
||||
rootCallId: string,
|
||||
): ToolCallBlock | undefined {
|
||||
return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any root or nested Tool lifecycle through the internal Node store.
|
||||
* @param snapshot - current Conversation snapshot.
|
||||
* @param callId - root or nested call identity.
|
||||
* @returns current Tool lifecycle when materialized in the loaded window.
|
||||
*/
|
||||
export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined {
|
||||
const visit = (block: ToolCallBlock): ToolCallBlock | undefined => {
|
||||
if (block.callId === callId) return block
|
||||
for (const child of block.subCalls) {
|
||||
const found = visit(child)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
for (const node of snapshot.chat.nodes.values()) {
|
||||
const root = toolNode(node)?.data.root
|
||||
if (root === undefined) continue
|
||||
const found = visit(root)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Collect visible prose from one Assistant lifecycle.
|
||||
* @param blocks - Assistant content blocks.
|
||||
* @returns concatenated text blocks.
|
||||
*/
|
||||
export function assistantText(blocks: readonly AssistantBlock[]): string {
|
||||
return blocks.flatMap(block => block.kind === 'text' ? [block.text] : []).join('')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Latency/throughput folds shared by the settled turn footer and StatsLine.
|
||||
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Latency and decode-throughput readings for one turn's footer. */
|
||||
export interface TurnMetrics {
|
||||
@@ -24,7 +24,7 @@ interface UsageLike {
|
||||
outputTokens?: number
|
||||
}
|
||||
|
||||
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
|
||||
type AssistantNode = AssistantMessageNode
|
||||
|
||||
function usageOutputTokens(usage: unknown): number | null {
|
||||
if (typeof usage !== 'object' || usage === null) return null
|
||||
@@ -67,7 +67,7 @@ interface TurnFold {
|
||||
* @param nodes - Snapshot nodes of the loaded window.
|
||||
* @returns Turn number → available metrics; turns with none are absent.
|
||||
*/
|
||||
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> {
|
||||
export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map<number, TurnMetrics> {
|
||||
const folds = new Map<number, TurnFold>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant') continue
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/** Frame-throttled scheduling for non-essential visual alignment. */
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_INTERVAL_FRAMES = 3
|
||||
|
||||
/**
|
||||
* Return a stable scheduler that coalesces visual updates over a frame interval.
|
||||
* Repeated calls retain the latest callback, and unmount cancels pending work.
|
||||
* @param update - DOM alignment to run after the throttle interval.
|
||||
* @param intervalFrames - Frames to wait before applying the latest alignment.
|
||||
* @param intervalFrames - frames to wait before applying the latest alignment.
|
||||
* @returns a stable function that schedules the latest update.
|
||||
*/
|
||||
export function useThrottledVisualUpdate(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the diff-card props from a frozen call slice: the
|
||||
* `card:'diff'` render intent the write/edit tools declare arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link DiffBlock} draws. Both conversation render sites
|
||||
* (the chat tool row's expanded body and the details panel's Output section)
|
||||
* call this, so the hunks they show are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Diff-body lines the chat row shows before collapsing the middle — half the
|
||||
* primitive's own default, which the details panel keeps. A chat row is a
|
||||
* summary surface inside the message flow: the flow must stay scannable across
|
||||
* many calls, while the details panel is the single-call reading surface. The
|
||||
* same split {@link CHAT_TERMINAL_MAX_LINES} draws for a terminal card, so the
|
||||
* two card kinds cap a long body at the same place in the flow. A design
|
||||
* constant of this UI's row geometry, not a deployment choice.
|
||||
*/
|
||||
export const CHAT_DIFF_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link DiffBlock} props this derivation owns. Picked off the primitive's
|
||||
* props so the two stay in step; `maxLines`/`className` belong to each render
|
||||
* site.
|
||||
*/
|
||||
export interface DiffCardModel {
|
||||
/**
|
||||
* The props {@link DiffBlock} draws. Held as a nested object so a render site
|
||||
* spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
*/
|
||||
card: Pick<DiffBlockProps, 'diffs'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event
|
||||
* view crosses the wire and `toolEventViewSchema` validates only the `card`
|
||||
* string, so a version mismatch or an anomalous plugin can deliver a `diff` card
|
||||
* whose `diffs` is absent, not an array, or carries malformed hunks. Returning
|
||||
* null for any of those routes the block to the generic path instead of letting
|
||||
* DiffBlock's `for...of`/`split` throw and crash the row or the details panel.
|
||||
* @param diffs - the view's `diffs` field, unverified.
|
||||
* @returns the validated hunks, or null when the payload is not usable.
|
||||
*/
|
||||
function narrowDiffs(diffs: unknown): DiffHunk[] | null {
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||
const out: DiffHunk[] = []
|
||||
for (const hunk of diffs) {
|
||||
if (typeof hunk !== 'object' || hunk === null) return null
|
||||
const { path, oldText, newText } = hunk as Record<string, unknown>
|
||||
if (typeof path !== 'string') return null
|
||||
if (oldText !== null && typeof oldText !== 'string') return null
|
||||
if (typeof newText !== 'string') return null
|
||||
out.push({ path, oldText, newText })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the diff-card props for a tool call, or null when this call is not a
|
||||
* diff card and belongs on the generic path.
|
||||
*
|
||||
* The result side is authoritative once the call settles: the write/edit tools
|
||||
* return the applied contextual hunks there (an edit's real before/after, a
|
||||
* create's whole-file diff), which replace the call-time diff derived from the
|
||||
* arguments alone. While the call is still running only the call side exists,
|
||||
* so a running write/edit shows its intended change. Null is the documented
|
||||
* generic-card default and covers every non-diff card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and cannot
|
||||
* be trusted to be one of the compiled variants — and a settled call whose
|
||||
* result view is generic (how write/edit keep their execution errors on the
|
||||
* generic path).
|
||||
*
|
||||
* This derivation consumes only `diffs`; the render intent's `title` field is
|
||||
* deliberately dropped. The row supplies its own title (`Edit`/`Write · path`
|
||||
* from the args), which outranks the view's `title`. A tool that names its own
|
||||
* diff header therefore does not surface that text on the Web row.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the diff-card props, or null for the generic path.
|
||||
*/
|
||||
export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view may carry the intended diff; the result is absent.
|
||||
const call = block.callView?.card === 'diff' ? block.callView : null
|
||||
const diffs = call === null ? null : narrowDiffs(call.diffs)
|
||||
return diffs === null ? null : { card: { diffs } }
|
||||
}
|
||||
// Settled: the result view's applied hunks replace the call-time diff. A
|
||||
// window that dropped the call head leaves only the result, which still
|
||||
// renders — the result view carries the whole change.
|
||||
const result = block.resultView?.card === 'diff' ? block.resultView : null
|
||||
const diffs = result === null ? null : narrowDiffs(result.diffs)
|
||||
return diffs === null ? null : { card: { diffs } }
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the read-card props from a frozen call slice: the
|
||||
* `card:'read'` render intent the read tool declares arrives on the snapshot as
|
||||
* the settled result node's `resultView`, and this is the one place that turns
|
||||
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
|
||||
* tool row's resident body and the details panel's Output section) call this, so
|
||||
* the path, lines, total, and language they show are derived once.
|
||||
*
|
||||
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
|
||||
* a call carries no file content until `execute` returns, so the pending call
|
||||
* stays a generic card (`kind: 'read'`). A running read therefore has no read
|
||||
* card, and this returns null for it — the row keeps its args-derived summary
|
||||
* until the result arrives.
|
||||
* @module
|
||||
*/
|
||||
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Content lines the chat row's resident read body shows before collapsing the
|
||||
* middle — half the primitive's own default, which the details panel keeps. A
|
||||
* chat row is a summary surface inside the message flow: the flow must stay
|
||||
* scannable across many calls, while the details panel is the single-call
|
||||
* reading surface. A design constant of this UI's row geometry, not a
|
||||
* deployment choice, so it is fixed here rather than a plugin Config field. The
|
||||
* same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for
|
||||
* terminal output.
|
||||
*/
|
||||
export const CHAT_READ_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link ReadBlock} props this derivation owns. Picked off the primitive's
|
||||
* props so the two stay in step; `maxLines`/`className` belong to each render
|
||||
* site.
|
||||
*/
|
||||
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
|
||||
|
||||
/**
|
||||
* Derive the read-card props for a tool call, or null when this call is not a
|
||||
* read card and belongs on the generic path.
|
||||
*
|
||||
* The read card is result-side only, so only a settled call whose result view
|
||||
* declares `card:'read'` produces one. Every other case is null — the
|
||||
* documented generic-card default:
|
||||
*
|
||||
* - A running call: it has no result view yet, and a read carries no content at
|
||||
* call time.
|
||||
* - A settled call whose result view is not a read card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and cannot
|
||||
* be trusted to be one of the compiled variants, and the read tool's own
|
||||
* generic fallback for an error result or a non-envelope body.
|
||||
*
|
||||
* The label is the read view's `title` when the tool supplied one (the
|
||||
* presentation contract's replacement-title rule), otherwise the file path
|
||||
* relativized to the session workspace so a workspace-rooted absolute path
|
||||
* displays the same short form the row summary shows.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
|
||||
* path label displays relative to it. Absent leaves the path as authored.
|
||||
* @returns the read-card props, or null for the generic path.
|
||||
*/
|
||||
export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
|
||||
// Running has no result view; a read carries no content until execute returns.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'read' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
// Lines arrive frozen off the snapshot; copy into the primitive's own line
|
||||
// shape so the card never holds a reference into the runtime's cache.
|
||||
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
|
||||
return {
|
||||
label: result.title ?? relativizeToCwd(result.path, sessionCwd),
|
||||
lines,
|
||||
totalLines: result.totalLines,
|
||||
lang: result.lang,
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the search-card props from a frozen call slice: the
|
||||
* `card:'search'` render intent the `grep` and `glob` tools declare arrives on
|
||||
* the snapshot as `resultView`, and this is the one place that turns it into
|
||||
* what {@link SearchBlock} draws. Both conversation render sites (the chat tool
|
||||
* row's resident body and the details panel's Output section) call this, so the
|
||||
* grouped matches or the path list they show are derived once.
|
||||
*
|
||||
* The search card is result-time only: a search call has no matches or paths
|
||||
* before `execute`, so its pending state stays a `GenericCallView`
|
||||
* ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation
|
||||
* therefore reads only `resultView` and returns null for a still-running call,
|
||||
* unlike the terminal card whose call view carries the command before
|
||||
* execution.
|
||||
*
|
||||
* A capped result also carries a recovery locator (grep/glob's `Full … stored
|
||||
* at …` footer) in the raw `tool/result` content, not in the structured
|
||||
* matches/paths the view carries. Since both render sites replace that raw
|
||||
* result with the card, this derivation surfaces the block's own result text as
|
||||
* {@link SearchCardModel.recovery} so the one path to the dropped rows is not
|
||||
* lost.
|
||||
* @module
|
||||
*/
|
||||
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Distributive `Omit`: a plain `Omit<A | B, K>` keeps only the keys common to
|
||||
* both members, which would drop the `files`/`paths` discriminated fields.
|
||||
* Distributing over the naked type parameter `T` preserves each shape.
|
||||
*/
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
|
||||
|
||||
/** The {@link SearchBlockProps} union minus each render site's own fields. */
|
||||
type SearchBlockModelProps = DistributiveOmit<SearchBlockProps, 'maxLines' | 'className'>
|
||||
|
||||
/**
|
||||
* Result rows the chat row's resident search body shows before collapsing the
|
||||
* middle — half the primitive's own default, which the details panel keeps. A
|
||||
* chat row is a summary surface inside the message flow: the flow must stay
|
||||
* scannable across many calls, while the details panel is the single-call
|
||||
* reading surface. A design constant of this UI's row geometry, not a
|
||||
* deployment choice, so it is fixed here rather than a plugin Config field.
|
||||
*/
|
||||
export const CHAT_SEARCH_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link SearchBlock} props this derivation owns. Held as a nested object
|
||||
* (`card`) so a render site spreads exactly the primitive's own surface and can
|
||||
* never leak a neighbouring field into it. `maxLines`/`className` belong to each
|
||||
* render site.
|
||||
*/
|
||||
export interface SearchCardModel {
|
||||
/**
|
||||
* The props {@link SearchBlock} draws, minus each render site's own
|
||||
* `maxLines`/`className`.
|
||||
*/
|
||||
card: SearchBlockModelProps
|
||||
/**
|
||||
* The result view's replacement title, which the presentation contract lets a
|
||||
* search tool set at settle time. Absent when the presenter supplied none; a
|
||||
* row then keeps its args-derived summary.
|
||||
*/
|
||||
title: string | undefined
|
||||
/**
|
||||
* The raw `tool/result` text, flattened, surfaced only when the search was
|
||||
* capped. The card renders the retained matches or paths, but the recovery
|
||||
* locator a capped result carries — grep/glob's `Full … stored at: <locator>`
|
||||
* footer, the one way to reach the rows the cap dropped — lives only in the raw
|
||||
* result text, which the card replaces. A UI that shows the card would
|
||||
* otherwise lose it. Absent when the result was not capped (the card holds
|
||||
* every result) or the block carries no text.
|
||||
*/
|
||||
recovery: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every file group in a matches view is structurally valid: the wire
|
||||
* frame carries `shape` and `card` as strings the host schema checks, but not the
|
||||
* grouped shape, so a version mismatch or loose producer could deliver
|
||||
* `shape: 'matches'` with a missing or malformed `files`. Rendering that would
|
||||
* crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the
|
||||
* generic path instead.
|
||||
* @param files - the candidate `files` field off the untrusted result view.
|
||||
* @returns whether `files` is a valid {@link SearchFileGroup} array.
|
||||
*/
|
||||
function isValidFiles(files: unknown): files is SearchFileGroup[] {
|
||||
return Array.isArray(files) && files.every(file =>
|
||||
typeof file === 'object' && file !== null
|
||||
&& typeof (file as { path?: unknown }).path === 'string'
|
||||
&& Array.isArray((file as { matches?: unknown }).matches)
|
||||
&& (file as { matches: unknown[] }).matches.every(match =>
|
||||
typeof match === 'object' && match !== null
|
||||
&& typeof (match as { lineNumber?: unknown }).lineNumber === 'number'
|
||||
&& typeof (match as { line?: unknown }).line === 'string'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a settled tool result's content blocks to their text, joined by
|
||||
* newlines. The search view carries no result text — a UI without a card falls
|
||||
* back to the raw `tool/result` content — so the truncation recovery footer is
|
||||
* read from the block's own content here. Non-text blocks (a search result
|
||||
* carries none) are skipped.
|
||||
* @param content - the result node's content blocks.
|
||||
* @returns the joined text, or undefined when empty.
|
||||
*/
|
||||
function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined {
|
||||
const text = content
|
||||
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
return text === '' ? undefined : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the search-card props for a tool call, or null when this call is not a
|
||||
* search card and belongs on the generic path.
|
||||
*
|
||||
* Only the result side matters: the search card carries no call-time state, so
|
||||
* a still-running call (no result view) is null, as is a settled call whose
|
||||
* result view is not a search card — including a `card` value this UI version
|
||||
* does not know, which arrives over the wire and cannot be trusted to be one of
|
||||
* the compiled variants, a `card: 'search'` view whose `shape` is neither
|
||||
* `matches` nor `paths` (equally untrusted wire data), and a generic result a
|
||||
* `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
|
||||
* the generic path).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the search-card props, or null for the generic path.
|
||||
*/
|
||||
export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
// Running: no result view exists yet, and a search card is result-only.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'search' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
const common = { truncated: result.truncated, total: result.total }
|
||||
// The recovery footer only matters when the tool capped the result: an
|
||||
// uncapped card holds every match/path, so the raw text adds nothing the card
|
||||
// does not already show. When capped, the raw result's `Full … stored at …`
|
||||
// locator is the only path to the dropped rows, so surface it.
|
||||
const recovery = result.truncated ? flattenContent(block.content) : undefined
|
||||
if (result.shape === 'matches') {
|
||||
// `files` rides the untrusted wire frame: the host schema checks `card`/`shape`
|
||||
// strings but not the grouped shape, so validate it before SearchBlock, which
|
||||
// would crash on a missing/malformed `files`. An invalid shape falls to generic.
|
||||
if (!isValidFiles(result.files)) return null
|
||||
return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } }
|
||||
}
|
||||
// `shape` rides the same untrusted wire frame as `card`, so a version mismatch
|
||||
// or a loose protocol producer could deliver a `card: 'search'` subtype this
|
||||
// client does not compile. Guard the paths shape explicitly: an unknown shape
|
||||
// falls to the generic path rather than being rendered as a paths card, which
|
||||
// would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive.
|
||||
if (result.shape !== 'paths') return null
|
||||
// `paths` is likewise unchecked by the wire schema; a known shape with a
|
||||
// missing/malformed array would crash the paths card at `.map`.
|
||||
if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
|
||||
return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }
|
||||
}
|
||||
@@ -1,13 +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, 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' {
|
||||
@@ -29,14 +37,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* conversation snapshot through the standard kit.
|
||||
*/
|
||||
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
|
||||
/**
|
||||
* The chat view's per-tool row hole: keyed dispatch on the wire tool name
|
||||
* (the key space is runtime-open — SlotMap declares slots, never keys).
|
||||
* Declared by the chat view entry (declaring is claiming); the render
|
||||
* site dispatches via `entryKey: toolName` with GenericToolCard as the
|
||||
* `fallback` for unregistered tools.
|
||||
*/
|
||||
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
|
||||
/** 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
|
||||
@@ -46,6 +55,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* registration, and a domain upgrades by registering one row component.
|
||||
*/
|
||||
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
|
||||
/**
|
||||
* 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. */
|
||||
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
|
||||
/**
|
||||
* The composer takeover chain: entries are selector-routed replacements
|
||||
* of the default InputBar. Declared by this package's 'conversation'
|
||||
@@ -61,14 +79,14 @@ 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
|
||||
// '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. */
|
||||
@@ -79,7 +97,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
|
||||
@@ -93,7 +111,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 }
|
||||
@@ -106,7 +124,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 {
|
||||
@@ -127,7 +145,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
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).
|
||||
@@ -151,56 +169,98 @@ export interface ConvViewOwnerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
* view supplies per row. Uniform across views — the trajectory/waterfall
|
||||
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
|
||||
* discipline) land with their own row render sites; today only the chat slot
|
||||
* is declared (RendersCheck rejects a declaration nobody renders).
|
||||
* 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 ToolRowOwnerProps {
|
||||
/** Tool call identity (details linkage; stable across running → settled). */
|
||||
callId: CallId
|
||||
/** Wire tool name (also the keyed dispatch key at the render site). */
|
||||
toolName: string
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Session workspace root; path summaries display relative to it. */
|
||||
cwd?: string | undefined
|
||||
export interface ChatFileMentions {
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application.
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
* 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.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
/**
|
||||
* Jump to this call's record in the trajectory view (the expanded row's
|
||||
* hover Inspect affordance). Undefined when no trajectory jump is wired.
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
|
||||
chatFileMentions: ChatFileMentions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full props of a registered tool-row component: the slot's runtime share
|
||||
* (owner payload + session standard kit + global seat). Registrants type
|
||||
* their component `FC<ToolRowProps & I>` with `I` inferred from their inject
|
||||
* factory. Declared against the chat slot; the three per-view toolview slots
|
||||
* share one declaration shape, so this alias serves them all.
|
||||
* 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 type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
|
||||
export interface TurnTailOwnerProps {
|
||||
/** Engine-owned closing Turn boundary. */
|
||||
turn: TurnLocation
|
||||
/** The closing assistant's seq — the anchor the tail renders under. */
|
||||
seq: number
|
||||
/**
|
||||
* Open a filesystem path through the Host (tool-row semantics; the chat
|
||||
* view resolves relative paths against the session cwd).
|
||||
*/
|
||||
openFile: (path: string) => 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. */
|
||||
block: ToolCallBlock
|
||||
/** Session workspace root for card cwd and relative-path display. */
|
||||
cwd?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (structured name/args, pairing id,
|
||||
* outcome-or-executing), so a
|
||||
* registrant needs no second data channel; domain state arrives through its
|
||||
* own projection cell.
|
||||
* carries the whole lifecycle (structured name/args, pairing id, and
|
||||
* outcome-or-executing). A successful domain command may also carry the
|
||||
* explicitly linked projection node needed to fold two log records into one
|
||||
* presentation row.
|
||||
*/
|
||||
export interface CommandRowOwnerProps {
|
||||
/** Folded command lifecycle node (run + optional done). */
|
||||
node: CommandNode
|
||||
/** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */
|
||||
compaction?: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
|
||||
/** Full props of a registered command-row component. */
|
||||
export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
|
||||
|
||||
/**
|
||||
@@ -223,6 +283,12 @@ export interface ConversationInjected {
|
||||
* When a blank session is already current, carry its draft to the target.
|
||||
*/
|
||||
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Framework-bound sources. `composerBlock` is this session's block when a
|
||||
* plugin raised one; the reason is the blocker's own localized copy, which
|
||||
* the root renders as the inert composer's placeholder.
|
||||
*/
|
||||
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict Session body seat. */
|
||||
@@ -258,6 +324,14 @@ export interface ConversationSessionHeaderInjected {
|
||||
export interface ComposerBarOwnerProps {
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
/**
|
||||
* A block another plugin raised for this session: the bar refuses input and
|
||||
* shows the blocker's reason as the placeholder, but — unlike `disabled` —
|
||||
* keeps the model seat live. Every block this contract has is one the user
|
||||
* clears by choosing a model, so locking that seat too would leave the
|
||||
* composer telling them to do the one thing it prevents.
|
||||
*/
|
||||
blocked?: { readonly reason: string }
|
||||
/**
|
||||
* Inert no-workspace state: the bar renders its normal DOM fully disabled
|
||||
* (textarea, add, send) so the workspace pick transitions in place instead
|
||||
@@ -279,7 +353,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: (
|
||||
@@ -306,7 +380,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>
|
||||
@@ -356,7 +431,7 @@ export type ConversationSlotProps =
|
||||
| 'conversation.input.left' | 'conversation.input.right'
|
||||
| 'conversation.hero.workspace'
|
||||
>
|
||||
& ConversationInjected
|
||||
& InjectFace<ConversationInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
|
||||
@@ -476,11 +551,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 & the declared toolview/commandview holes' render share & store & injected & locale seat. */
|
||||
/** 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.toolview' | 'conversation.chat.commandview'>
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'>
|
||||
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
@@ -492,8 +574,9 @@ export interface DetailsInjected {
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
|
||||
/** Full details-slot props: selection store, Tool output seat, injected close callback, and locale. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsRenderSlots<'conversation.details.tool'>
|
||||
& PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
|
||||
|
||||
/** Owner share common to the hero / New-Session Workspace pickers. */
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the shell tools declare arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Build the TerminalBlock display copy from the conversation locale seat —
|
||||
* the one place the primitive's label surface pairs with this package's
|
||||
* dictionary, shared by every terminal render site (chat row, bash row,
|
||||
* details panel).
|
||||
* @param t - the render site's conversation locale seat.
|
||||
* @returns the full label set for {@link TerminalBlockProps}'s `labels`.
|
||||
*/
|
||||
export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels {
|
||||
return {
|
||||
signal: signal => t('terminal.signal', { signal }),
|
||||
exitCode: code => t('terminal.exitCode', { code }),
|
||||
running: t('terminal.running'),
|
||||
failed: t('terminal.failed'),
|
||||
done: t('terminal.done'),
|
||||
copy: t('copy'),
|
||||
copied: t('copied'),
|
||||
noOutput: t('terminal.noOutput'),
|
||||
collapseAria: t('terminal.collapseAria'),
|
||||
collapse: t('collapse'),
|
||||
expandAria: hidden => t('terminal.expandAria', { n: hidden }),
|
||||
expand: hidden => t('terminal.expandRest', { n: hidden }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
*/
|
||||
export interface TerminalCardModel {
|
||||
/**
|
||||
* The props {@link TerminalBlock} draws. Held as a nested object so a render
|
||||
* site spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
/**
|
||||
* The call view's model-authored description, which the contract defines as
|
||||
* rendering ABOVE the card (the card itself has no description slot). Absent
|
||||
* when the presenter supplied none, or when the window dropped the call side;
|
||||
* a row then keeps its args-derived summary.
|
||||
*/
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a settled terminal card reports a failing exit — a non-zero code
|
||||
* or a terminating signal. The bash tool settles a failing command as a
|
||||
* completed call (`isError` stays false: the exit status is result data), so
|
||||
* this is the collapsed row's only failure signal; without it the red exit
|
||||
* pill would be visible only after expanding the card.
|
||||
* @param model - a derived terminal card.
|
||||
* @returns whether the card's exit status is a failure.
|
||||
*/
|
||||
export function terminalFailed(model: TerminalCardModel): boolean {
|
||||
const { exitCode, signal, running } = model.card
|
||||
return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
* one joins under the session workspace, and an omitted one IS the session
|
||||
* workspace. A pure presenter cannot see the session cwd, which is why this
|
||||
* resolution belongs here rather than in the tool. Without a session cwd there
|
||||
* is nothing to resolve against, so a relative path stays as authored and an
|
||||
* omitted one stays absent (the prompt row then draws a bare `$`).
|
||||
* @param viewCwd - the cwd the terminal call view carries, if any.
|
||||
* @param sessionCwd - the session workspace root, if the caller knows it.
|
||||
* @returns the working directory for the prompt label, or undefined.
|
||||
*/
|
||||
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined || viewCwd === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
|
||||
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `.` and `..` segments so the prompt label names the directory the
|
||||
* command actually ran in. The bash executor resolves the workdir before
|
||||
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
|
||||
* are preserved as authored (a Windows path keeps its backslashes) because this
|
||||
* value is only ever displayed; a `..` that would climb past the root is
|
||||
* dropped, which is what a filesystem does with it. A UNC path's `server` and
|
||||
* `share` are part of its root, not poppable segments: Windows cannot climb
|
||||
* above a share, so `\\\\server\\share` with a `..` stays there.
|
||||
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
|
||||
* @returns the same path with those segments resolved.
|
||||
*/
|
||||
function normalizeSegments(path: string): string {
|
||||
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
|
||||
// A UNC path is `\\\\server\\share\\...`: the server and share form the root,
|
||||
// so they are split off here and neither is a segment `..` may pop. Its
|
||||
// separator is fixed to a backslash, since a joined relative part may have
|
||||
// introduced a forward slash that UNC syntax does not use.
|
||||
const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
|
||||
if (unc !== null) {
|
||||
// Both groups are mandatory in the pattern, so destructuring types them as
|
||||
// strings without an assertion.
|
||||
const [matched, server, share] = unc
|
||||
const root = `\\\\${String(server)}\\${String(share)}`
|
||||
// Rooted: what follows the share hangs off it, so a `..` at the top is
|
||||
// dropped rather than kept — Windows cannot climb above a share.
|
||||
const rest = collapse(path.slice(matched.length), true)
|
||||
return rest === '' ? root : `${root}\\${rest}`
|
||||
}
|
||||
const backslashed = path.includes('\\') && !path.includes('/')
|
||||
const separator = backslashed ? '\\' : '/'
|
||||
const rooted = /^[/\\]/.test(path)
|
||||
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
|
||||
const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
|
||||
const leading = rooted ? separator : ''
|
||||
return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the `.`/`..` segments of a path body against a known root state.
|
||||
* @param body - the path after any drive letter or UNC root.
|
||||
* @param rooted - the body hangs off a root, so a `..` at its top is dropped
|
||||
* the way a filesystem drops one; without a root the `..` is kept, since it
|
||||
* stays meaningful against a cwd this function cannot see.
|
||||
* @param separator - separator to rejoin with (default `/`).
|
||||
* @returns the collapsed body, without leading or trailing separators.
|
||||
*/
|
||||
function collapse(body: string, rooted: boolean, separator = '/'): string {
|
||||
const kept: string[] = []
|
||||
for (const segment of body.split(/[/\\]/)) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
|
||||
else if (!rooted) kept.push(segment)
|
||||
continue
|
||||
}
|
||||
kept.push(segment)
|
||||
}
|
||||
return kept.join(separator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled result (see
|
||||
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
|
||||
* result with no call side. That still renders: the command falls back to the
|
||||
* result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root, which resolves an omitted or
|
||||
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
description: call.description,
|
||||
card: {
|
||||
command: call.title,
|
||||
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
return {
|
||||
description: call?.description,
|
||||
card: {
|
||||
// The result's title REPLACES the pending one when the tool supplies it
|
||||
// (the presentation contract's replacement-title rule); the call title is
|
||||
// what a result without one keeps.
|
||||
command: result.title ?? call?.title ?? '',
|
||||
// Only a PRESENT call view can mean "omitted the cwd, so use the
|
||||
// workspace". When the window dropped the call head there is no cwd
|
||||
// anywhere — the result view carries none — and the original call may
|
||||
// well have used an explicit workdir, so the prompt draws a bare `$`
|
||||
// rather than naming a directory this card cannot know.
|
||||
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
running: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary, expanded-body text, and flattened result output from the
|
||||
* frozen call slice. Input material comes from the call ARGUMENTS; output and
|
||||
* error material from the settled result node. A call whose render intent is
|
||||
* a terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
// that produces the values).
|
||||
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** The frozen slice the chat view hands to toolview components as `block`
|
||||
* (both members are cache-stable references off ConversationSnapshot). */
|
||||
|
||||
/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
|
||||
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
|
||||
|
||||
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
|
||||
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
|
||||
|
||||
/** Figma row titles per variant (design literals, not translatable copy). */
|
||||
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
|
||||
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
|
||||
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
|
||||
}
|
||||
|
||||
/** Known tool name -> variant. */
|
||||
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
|
||||
bash: 'bash',
|
||||
// The PowerShell twin is a shell tool: the bash row family (icon, colors)
|
||||
// with its own title from TOOL_TITLES, not the generic `others` row.
|
||||
pwsh: 'bash',
|
||||
read: 'read',
|
||||
web_fetch: 'read',
|
||||
web_search: 'search',
|
||||
grep: 'search',
|
||||
glob: 'search',
|
||||
write: 'write',
|
||||
edit: 'edit',
|
||||
run_code: 'code',
|
||||
cordis_inspect: 'read',
|
||||
cordis_mount: 'code',
|
||||
cordis_unmount: 'others',
|
||||
}
|
||||
|
||||
/** Tool-owned titles that refine a generic row variant without replacing it. */
|
||||
const TOOL_TITLES: Record<string, string> = {
|
||||
cordis_inspect: 'Inspect',
|
||||
cordis_mount: 'Mount temporary Plugin',
|
||||
cordis_unmount: 'Unmount temporary Plugin',
|
||||
pwsh: 'Pwsh',
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a tool name into its row variant.
|
||||
* @param toolName - wire tool name.
|
||||
* @returns matching variant, others when unknown.
|
||||
*/
|
||||
export function classifyTool(toolName: string): ToolRowVariant {
|
||||
return TOOL_VARIANTS[toolName] ?? 'others'
|
||||
}
|
||||
|
||||
/** Everything ToolRow needs, derived once from the frozen slice. */
|
||||
export interface ToolRowModel {
|
||||
variant: ToolRowVariant
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Filesystem path from args (`path` / `file_path`) when the row is a file
|
||||
* tool; absent for URL reads and non-file tools. The chat view resolves
|
||||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body input text (pretty args); null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
|
||||
output: string | null
|
||||
/** First line of the result text on an error row; null for every other state. */
|
||||
errorSummary: string | null
|
||||
state: ToolRowState
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a settled result's content blocks to display text: text blocks
|
||||
* verbatim, other block shapes as pretty JSON. Empty content on a failed call
|
||||
* falls back to the structured error's `name: code` line.
|
||||
* @param node - the settled result node.
|
||||
* @returns the flattened result text (may be empty).
|
||||
*/
|
||||
export function resultText(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
else parts.push(JSON.stringify(block, null, 2))
|
||||
}
|
||||
if (parts.length === 0 && node.error !== undefined) {
|
||||
parts.push(`${node.error.name}: ${node.error.code}`)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function parseArgs(argsRaw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(argsRaw)
|
||||
} catch {
|
||||
// Non-JSON args (mid-stream truncation): summary/body fall back to the raw string.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const nl = text.indexOf('\n')
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
function pickString(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const v = args[key]
|
||||
if (typeof v === 'string' && v !== '') return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Summary key preference per variant (args-derived; result-derived summaries are a ledger item). */
|
||||
const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
||||
bash: ['description', 'command'],
|
||||
read: ['path', 'file_path', 'url'],
|
||||
search: ['query', 'pattern', 'url'],
|
||||
think: [],
|
||||
write: ['path', 'file_path'],
|
||||
edit: ['path', 'file_path'],
|
||||
code: ['description'],
|
||||
others: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the workspace root from a workspace-rooted absolute path (display only).
|
||||
* @param text - the path to shorten.
|
||||
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
|
||||
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
|
||||
*/
|
||||
export function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return text
|
||||
const root = cwd.replace(/[/\\]+$/, '')
|
||||
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
|
||||
return text
|
||||
}
|
||||
|
||||
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
|
||||
const args = parsed as Record<string, unknown>
|
||||
const picked = pickString(args, SUMMARY_KEYS[variant])
|
||||
if (picked !== undefined) return firstLine(picked)
|
||||
for (const v of Object.values(args)) {
|
||||
if (typeof v === 'string' && v !== '') return firstLine(v)
|
||||
}
|
||||
return firstLine(argsRaw)
|
||||
}
|
||||
|
||||
/** Path keys only — never `url` (web_fetch lands on the read variant). */
|
||||
const FILE_PATH_KEYS = ['path', 'file_path'] as const
|
||||
|
||||
/** File-tool variants whose summary may be an openable workspace path. */
|
||||
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
|
||||
|
||||
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
|
||||
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
|
||||
return picked === undefined ? undefined : firstLine(picked)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool-arg path against the session cwd for host.openPath.
|
||||
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
|
||||
* @param cwd - session working directory (may be absent for ungrouped sessions).
|
||||
* @param path - path as carried in tool args.
|
||||
* @returns a host-facing path string.
|
||||
*/
|
||||
export function resolveToolPath(cwd: string | undefined, path: string): string {
|
||||
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
||||
if (cwd === undefined || cwd === '') return path
|
||||
const base = cwd.replace(/[/\\]+$/, '')
|
||||
const rel = path.replace(/^[/\\]+/, '')
|
||||
return `${base}/${rel}`
|
||||
}
|
||||
|
||||
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
if (argsRaw === '') return null
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (parsed === undefined) return argsRaw
|
||||
// The code row's expanded body IS the program (monospace via the row's
|
||||
// variant styling), not the args JSON envelope around it.
|
||||
if (variant === 'code' && typeof parsed === 'object' && parsed !== null) {
|
||||
const code = (parsed as Record<string, unknown>).code
|
||||
if (typeof code === 'string' && code !== '') return code
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the full row model from a frozen call slice.
|
||||
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
|
||||
* @returns the row model.
|
||||
*/
|
||||
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
|
||||
const variant = classifyTool(toolName)
|
||||
const done = 'kind' in block
|
||||
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const state: ToolRowState = !done ? 'running'
|
||||
: block.error?.code === 'interrupted' ? 'stopped'
|
||||
: block.isError ? 'error' : 'ok'
|
||||
const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
|
||||
const toolTitle = TOOL_TITLES[toolName]
|
||||
// Others keeps the static "Tool call" title (figma literal); the real tool
|
||||
// name rides the mutable summary slot unless the tool owns a specific title.
|
||||
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
|
||||
? `${toolName} · ${base}`
|
||||
: base
|
||||
// The empty string is "no text" for both derived result fields: a settled
|
||||
// call with blank content has nothing to expand, and a blank first line
|
||||
// would erase the collapsed error row's summary slot.
|
||||
const output = done ? (resultText(block) || null) : null
|
||||
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
|
||||
return {
|
||||
variant,
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
output,
|
||||
errorSummary,
|
||||
state,
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the web-card props from a frozen call slice: the
|
||||
* `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
|
||||
* result time arrives on the snapshot as `resultView`, and this is the one
|
||||
* place that turns it into what {@link WebBlock} draws. Both conversation
|
||||
* render sites (the chat tool row's resident/expanded body and the details
|
||||
* panel's Output section) call this, so the sources and fetch summary they
|
||||
* show are derived once.
|
||||
*
|
||||
* The web card is result-only by contract: those tools keep a generic pending
|
||||
* call view, so there is nothing to derive while the call is still running and
|
||||
* a running call always takes the generic path.
|
||||
* @module
|
||||
*/
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Derive the web-card props for a tool call, or null when this call is not a
|
||||
* web card and belongs on the generic path.
|
||||
*
|
||||
* The result side supplies the whole card: the sources and answer for a
|
||||
* `search`, the URL and status for a `fetch`. Cases producing null, all of
|
||||
* them the documented generic-card default:
|
||||
*
|
||||
* - A running call (no `resultView` yet): the web tools keep a generic pending
|
||||
* card, so nothing web-shaped exists until the call settles.
|
||||
* - A settled call whose result view is not a web card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and so
|
||||
* cannot be trusted to be one of the compiled variants, and a generic result
|
||||
* view (a web tool's error path returns the generic card, whose text the
|
||||
* generic path preserves).
|
||||
* - A web card whose `kind` this UI version does not know (a newer host's
|
||||
* value): the wire cannot be trusted to be `search` or `fetch`, so it takes
|
||||
* the generic path rather than rendering as a malformed fetch.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the web-card props, or null for the generic path.
|
||||
*/
|
||||
export function webCardModel(block: ToolCallBlock): WebBlockProps | null {
|
||||
// Running calls have no result view; the web card is result-only.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView
|
||||
if (result?.card !== 'web') return null
|
||||
if (result.kind === 'search') {
|
||||
return {
|
||||
kind: 'search',
|
||||
answer: result.answer,
|
||||
sources: result.sources.map(source => ({
|
||||
url: source.url,
|
||||
title: source.title,
|
||||
snippet: source.snippet,
|
||||
publishedAt: source.publishedAt,
|
||||
})),
|
||||
truncated: result.truncated,
|
||||
}
|
||||
}
|
||||
// Discriminate `fetch` explicitly rather than treating it as the else of
|
||||
// `search`: a `kind` this UI version does not know arrives over the wire from
|
||||
// a newer host, and reading it as a fetch would draw an empty URL and
|
||||
// `HTTP undefined`. It takes the generic path, the same wire-boundary default
|
||||
// an unknown `card` tag takes above. The static union narrows `kind` to
|
||||
// `'fetch'` here, but the runtime value is off the wire, so the guard and its
|
||||
// null fallthrough are load-bearing despite the type.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (result.kind === 'fetch') {
|
||||
return {
|
||||
kind: 'fetch',
|
||||
url: result.url,
|
||||
statusCode: result.statusCode,
|
||||
truncated: result.truncated,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, UnknownSurfaceNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Generic presentation of an unclaimed append-surface event. */
|
||||
unknown: UnknownSurfaceNode
|
||||
}
|
||||
}
|
||||
|
||||
/** Unclaimed append-surface fallback Definition. */
|
||||
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
|
||||
kind: 'unknown-surface',
|
||||
match: event => isAppendSurfaceEvent(event)
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => ({
|
||||
kind: 'unknown',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
type: match.event.type,
|
||||
data: match.event.data,
|
||||
}),
|
||||
update: context => context.state,
|
||||
buildViewNode: (context, target) => target !== 'chat' || context.state === undefined
|
||||
? null
|
||||
: chatNode(context, 'unknown', context.state.seq, context.state),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the unmatched append-surface fallback contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerUnknownConversationFallback(ctx: Context): void {
|
||||
ctx.conversationEvents.registerFallback(unknownFallbackDefinition)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxState } from './inbox.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Ordinary turn-opening user message. */
|
||||
user: UserMessageNode
|
||||
/** User message admitted into an active turn. */
|
||||
steering: SteeringMessageNode
|
||||
/** Non-user context injected into model history. */
|
||||
context: ContextMessageNode
|
||||
}
|
||||
}
|
||||
|
||||
function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
|
||||
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false
|
||||
const source = event.data.source
|
||||
return source.kind === 'plugin' && source.plugin === 'compact'
|
||||
}
|
||||
|
||||
/** User, steering, and injected-context message classification Definition. */
|
||||
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
kind: 'input-message',
|
||||
match: event => event.type === 'user/message'
|
||||
&& isAppendSurfaceEvent(event)
|
||||
&& !isCompactionCheckpoint(event)
|
||||
? { id: String(event.data.id), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
|
||||
const event = match.event
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
|
||||
return claimed
|
||||
? {
|
||||
kind: 'steering',
|
||||
messageId: event.data.id,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
}
|
||||
: {
|
||||
kind: 'user',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined) return null
|
||||
return chatNode(context, context.state.kind, context.state.seq, context.state)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user, steering, and injected-context message contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerMessageConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(messageDefinition)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { registerAssistantConversationNode } from './assistant.ts'
|
||||
import { registerChatConversationView } from './chat-snapshot-builder.ts'
|
||||
import { registerCommandConversationNode } from './command.ts'
|
||||
import { registerCompactionConversationNode } from './compaction.ts'
|
||||
import { registerUnknownConversationFallback } from './fallback.ts'
|
||||
import { registerInboxConversationNodes } from './inbox.ts'
|
||||
import { registerMessageConversationNode } from './message.ts'
|
||||
import { registerRetryConversationNode } from './retry.ts'
|
||||
import { registerToolConversationNode } from './tool.ts'
|
||||
import { registerTurnErrorConversationNode } from './turn-error.ts'
|
||||
import { registerTurnTailConversationNode } from './turn-tail.ts'
|
||||
|
||||
/**
|
||||
* Register the Chat business Definitions and target builder contributed by this package.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerConversationNodes(ctx: Context): void {
|
||||
registerInboxConversationNodes(ctx)
|
||||
registerMessageConversationNode(ctx)
|
||||
registerAssistantConversationNode(ctx)
|
||||
registerToolConversationNode(ctx)
|
||||
registerCommandConversationNode(ctx)
|
||||
registerCompactionConversationNode(ctx)
|
||||
registerRetryConversationNode(ctx)
|
||||
registerTurnErrorConversationNode(ctx)
|
||||
registerTurnTailConversationNode(ctx)
|
||||
registerUnknownConversationFallback(ctx)
|
||||
registerChatConversationView(ctx)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -10,14 +20,18 @@ export type { IConversation } from './service.ts'
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.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,
|
||||
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
||||
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
|
||||
TurnTailOwnerProps, UseChatNodeTurnData,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
|
||||
77
packages/client/ui-conversation/src/client/input/blocks.ts
Normal file
77
packages/client/ui-conversation/src/client/input/blocks.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Composer blocks: the one way another plugin stops a session's input.
|
||||
*
|
||||
* The composer cannot read the plugins that would know — the dependency runs
|
||||
* ui-model → ui-conversation, never back — so a blocker pushes here and the
|
||||
* bar reads its own session's store. A block carries the localized reason it
|
||||
* exists, because the plugin that raised it owns that copy; the composer only
|
||||
* knows how to render an inert textarea with a placeholder, exactly as it
|
||||
* already does for a session with no workspace.
|
||||
*
|
||||
* This is an affordance, not enforcement: the Host refuses a prompt it cannot
|
||||
* route regardless of what any client disables.
|
||||
*/
|
||||
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Why one session's composer is inert. */
|
||||
export interface ComposerBlock {
|
||||
/**
|
||||
* Localized placeholder replacing the composer's own, owned by the plugin
|
||||
* that raised the block.
|
||||
*/
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** The registry face other plugins reach through `ctx.conversation.blocks`. */
|
||||
export interface ComposerBlocks {
|
||||
/**
|
||||
* Raise or clear this session's block. Idempotent: setting a block equal to
|
||||
* the current one, or clearing an absent one, notifies nobody.
|
||||
* @param sessionId - the session whose composer is affected.
|
||||
* @param block - the block to raise, or undefined to clear it.
|
||||
*/
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void
|
||||
/**
|
||||
* The store the composer subscribes to for one session. Created on first
|
||||
* read from either side, so a blocker may raise a block before the session's
|
||||
* composer mounts and the composer still sees it.
|
||||
* @param sessionId - the session to observe.
|
||||
* @returns that session's block store (undefined value = not blocked).
|
||||
*/
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined>
|
||||
/**
|
||||
* Drop one session's store. The session scope's disposer calls this; a
|
||||
* blocker never needs to.
|
||||
* @param sessionId - the session being torn down.
|
||||
*/
|
||||
forget(sessionId: SessionId): void
|
||||
}
|
||||
|
||||
/** The per-session composer-block registry (one instance per plugin fiber). */
|
||||
export class ComposerBlockRegistry implements ComposerBlocks {
|
||||
private readonly stores = new Map<SessionId, SnapshotStore<ComposerBlock | undefined>>()
|
||||
|
||||
/** @inheritdoc */
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void {
|
||||
const store = this.storeFor(sessionId)
|
||||
const current = store.getSnapshot()
|
||||
if (current?.reason === block?.reason) return
|
||||
store.set(block)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> {
|
||||
const existing = this.stores.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const created = createSnapshotStore<ComposerBlock | undefined>(undefined)
|
||||
this.stores.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
forget(sessionId: SessionId): void {
|
||||
this.stores.delete(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -134,7 +134,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).
|
||||
@@ -163,7 +163,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).
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -71,7 +71,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') },
|
||||
@@ -213,7 +213,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.
|
||||
*/
|
||||
@@ -268,7 +270,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.
|
||||
@@ -354,7 +357,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
|
||||
|
||||
@@ -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
|
||||
@@ -69,7 +69,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 =>
|
||||
@@ -105,7 +105,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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': '命令',
|
||||
@@ -45,7 +46,7 @@ export const zh = {
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '开始构建吧',
|
||||
'hero.headline': '探索未知之境',
|
||||
'hero.preview': '预览版',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
@@ -81,6 +82,8 @@ export const zh = {
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.running': '正在压缩…',
|
||||
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
@@ -158,6 +161,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',
|
||||
@@ -186,7 +190,7 @@ export const en = {
|
||||
'access.confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'access.confirm.cancel': 'Cancel',
|
||||
'access.confirm.enable': 'Enable Full access',
|
||||
'hero.headline': 'Let\'s start building',
|
||||
'hero.headline': 'Into the Unknown',
|
||||
'hero.preview': 'Preview',
|
||||
'hero.chooseWorkspace': 'Choose workspace',
|
||||
'session.hierarchy': 'Session hierarchy',
|
||||
@@ -222,6 +226,8 @@ export const en = {
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.running': 'Compacting context…',
|
||||
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
|
||||
// method) instead of the standalone helper.
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
import type { ComposerBlocks } from './input/blocks.ts'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,11 @@ import type { InputService } from './input/contract.ts'
|
||||
export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* The per-session composer-block registry: how a plugin the composer
|
||||
* cannot import makes a session's input inert with its own reason.
|
||||
*/
|
||||
readonly blocks: ComposerBlocks
|
||||
/**
|
||||
* Send a prompt into the caller scope's session (queued turn).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
@@ -51,18 +57,22 @@ 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
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
* @param config - carries the InputService instance constructed by the
|
||||
* plugin apply (the same InputHub the slot inject factories close over).
|
||||
* @param config - carries the InputService and composer-block registry
|
||||
* constructed by the plugin apply (the same instances the slot inject
|
||||
* factories close over).
|
||||
*/
|
||||
constructor(ctx: Context, config: { input: InputService }) {
|
||||
constructor(ctx: Context, config: { input: InputService; blocks: ComposerBlocks }) {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config.input
|
||||
this.blocks = config.blocks
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -13,7 +13,7 @@ import css from './ConversationRoot.module.css'
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput,
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock,
|
||||
renderSlot, renderSlotChain, selectWorkspace, t,
|
||||
}: ConversationRootProps) {
|
||||
const openState = useSession(s => s.openState)
|
||||
@@ -24,6 +24,9 @@ export function ConversationRoot({
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
// A plugin this package cannot import (ui-model) says this session cannot
|
||||
// send; its reason is already localized by whoever raised it.
|
||||
const composerBlock = useComposerBlock(block => block)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
@@ -78,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");
|
||||
@@ -126,11 +128,20 @@ export function ConversationRoot({
|
||||
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
|
||||
// not a different tree, so the textarea DOM survives the transition.
|
||||
const inert = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
// A raised block is the same inert posture with the blocker's own reason:
|
||||
// one disabled textarea, never a second tree. The no-workspace state wins
|
||||
// when both hold — picking a workspace is the earlier prerequisite.
|
||||
const blocked = !inert && composerBlock !== undefined
|
||||
const inputBar = renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(inert
|
||||
? { disabled: true, placeholder: t('placeholder.workspace') }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
: blocked
|
||||
// `blocked`, not `disabled`: the bar refuses input either way, but a
|
||||
// block keeps the model seat live because choosing a model is how the
|
||||
// user clears it.
|
||||
? { blocked: composerBlock, placeholder: composerBlock.reason }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -92,36 +92,3 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Above the card, which is where the render-intent contract puts a terminal
|
||||
call's description; the panel has no summary row to carry it. */
|
||||
.terminalDescription {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* A card body (terminal, diff, or search) sits directly under its section
|
||||
label, so it drops the primitive's standalone vertical margin; the section
|
||||
owns the spacing. Card-neutral: no card-kind-specific value. */
|
||||
.cardBody {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The recovery footer for a capped search: the result text (its `Full … stored
|
||||
at …` locator) below the card in the muted tone, since the card holds only the
|
||||
retained rows. */
|
||||
.searchRecovery {
|
||||
margin: 6px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The read and web cards sit directly under their section label, same as the
|
||||
terminal card: drop the primitive's standalone vertical margin. */
|
||||
.read,
|
||||
.web {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
// 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
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Fragment } from 'react'
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } 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 { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.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). */
|
||||
@@ -46,21 +41,9 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -72,7 +55,15 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
|
||||
/** Flatten a settled result for the no-ui-tool fallback. */
|
||||
function rawResultText(block: ToolCallBlock): string {
|
||||
if (!('kind' in block)) return ''
|
||||
const parts = block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
|
||||
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
@@ -118,7 +109,17 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
|
||||
<Fragment key={callId}>
|
||||
{renderSlot('conversation.details.tool', { block: material.block, cwd: sessionCwd }, {
|
||||
fallback: 'kind' in material.block
|
||||
? (
|
||||
<pre className={css.code} data-error={material.block.isError || undefined}>
|
||||
{rawResultText(material.block)}
|
||||
</pre>
|
||||
)
|
||||
: <div className={css.empty}>{t('details.running')}</div>,
|
||||
})}
|
||||
</Fragment>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -126,83 +127,3 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. A read-card call
|
||||
* renders through the shared ReadBlock at that same full height, so the whole
|
||||
* returned window is line-numbered and highlighted. A diff-card call — a
|
||||
* write/edit's applied change — renders through the shared DiffBlock at the same
|
||||
* full height. A search-card call — a `grep`/`glob` result view — renders
|
||||
* through the shared SearchBlock at the same full height allowance, with a
|
||||
* capped search's recovery footer below it. A web-card call — a
|
||||
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
|
||||
* source-list allowance. Every other call, and a running call with no card yet,
|
||||
* keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
// panel has no summary row to carry it, so it is drawn here.
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.cardBody} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
const read = readCardModel(material.block, cwd)
|
||||
// The panel takes the primitive's own default cap, not the row's tighter one:
|
||||
// it is the single-call reading surface, so the whole window is available.
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
const search = searchCardModel(material.block)
|
||||
if (search !== null) {
|
||||
return (
|
||||
<>
|
||||
<SearchBlock {...search.card} className={css.cardBody} />
|
||||
{/* A capped search's recovery locator lives only in the result text;
|
||||
show it below the card so the dropped rows stay reachable. */}
|
||||
{search.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{search.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const web = webCardModel(material.block)
|
||||
// The card shows every source the tool returned (the same list the model saw),
|
||||
// scrolling within its own capped height. Below the card the panel also renders
|
||||
// the flattened result content — the model-visible text the card does not carry
|
||||
// verbatim (a web_fetch card shows only the URL and status, so its fetched body
|
||||
// lives only here; a search card's answer and sources are structured, so the
|
||||
// flattened form repeats them as the raw text the model saw).
|
||||
if (web !== null) {
|
||||
const settled = 'kind' in material.block ? material.block : null
|
||||
const body = settled === null ? '' : resultText(settled)
|
||||
return (
|
||||
<>
|
||||
<WebBlock {...web} className={css.web} />
|
||||
{body !== '' && <pre className={css.code}>{body}</pre>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{resultText(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
|
||||
badge is a product addition outside that source and aligns to the title. */
|
||||
badge is a product addition outside that source: a mono superscript pill
|
||||
riding the title's top-right. */
|
||||
.headline {
|
||||
display: grid;
|
||||
grid-template-columns: 34px auto;
|
||||
grid-template-columns: 34px auto auto;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
@@ -44,13 +44,17 @@
|
||||
}
|
||||
|
||||
.previewBadge {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
align-self: start;
|
||||
margin-top: 2px;
|
||||
margin-left: -3px;
|
||||
padding: 1px 7px 0;
|
||||
border: 1px solid var(--dsw-alias-interactive-bg-hover);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-bluish);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
|
||||
renderSlot, useNotices, useLexicon, useMenuLauncher,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
|
||||
accessory, overlay, leftItems, rightItems, footer,
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
@@ -82,12 +83,21 @@ 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
|
||||
// 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
|
||||
// the composer asking for the only thing it prevents. The other reasons to
|
||||
// be disabled do lock it — there is no session to choose a model for.
|
||||
const modelSeatLocked = removed || inert || !live
|
||||
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
|
||||
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
|
||||
&& input.queue.some(row => row.placement === 'queued')
|
||||
@@ -356,11 +366,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
|
||||
}
|
||||
@@ -384,7 +397,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
|
||||
@@ -429,7 +442,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">
|
||||
@@ -484,14 +497,16 @@ export function InputBar({
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
// The steer hint deliberately outranks the plan placeholder:
|
||||
// while it shows, the whole-queue gesture is genuinely available
|
||||
// (the gate never consults plan mode), so the actionable hint wins.
|
||||
: canSteerQueue
|
||||
? t('placeholder.steerQueue')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
placeholder={placeholder ?? (parentOffline
|
||||
? t('placeholder.parentOffline')
|
||||
: disabled
|
||||
? t('placeholder.unavailable')
|
||||
// The steer hint deliberately outranks the plan placeholder:
|
||||
// while it shows, the whole-queue gesture is genuinely available
|
||||
// (the gate never consults plan mode), so the actionable hint wins.
|
||||
: canSteerQueue
|
||||
? t('placeholder.steerQueue')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -529,19 +544,34 @@ export function InputBar({
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
{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>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
// ask_user_question toolview: question-flavored summary row replacing the
|
||||
// generic "Tool call" card, registered into the keyed
|
||||
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
|
||||
// (chrome, running sweep, whole-row expand) and swaps in the interaction
|
||||
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
|
||||
// when the user dismissed the whole set — because the questions themselves
|
||||
// render in the composer takeover.
|
||||
|
||||
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
|
||||
interface AnswerEntry { selected?: unknown; custom?: unknown }
|
||||
|
||||
function isAnswer(value: unknown): value is AnswerEntry {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** Answered-count summary off the result JSON (a skipped question has
|
||||
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
|
||||
function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const answers = (parsed as { answers?: unknown }).answers
|
||||
if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
|
||||
const answered = answers.filter(a =>
|
||||
(Array.isArray(a.selected) && a.selected.length > 0)
|
||||
|| (typeof a.custom === 'string' && a.custom !== '')).length
|
||||
return t('ask.answered', { answered, total: answers.length })
|
||||
}
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One-line question-interaction row (the whole row toggles the call's
|
||||
* Input/Output sections, ToolRow's unified expand). */
|
||||
export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Composer verdicts settle the call as specific UserInteractionErrors
|
||||
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
|
||||
// question was pending. Both name their verdict instead of the generic
|
||||
// failed shape, and the abort keeps the shared stopped (amber) semantics of
|
||||
// any other interrupted tool call.
|
||||
const code = 'kind' in block ? block.error?.code : undefined
|
||||
let summary = model.summary
|
||||
let state = model.state
|
||||
if (code === 'ASK_CANCELLED') {
|
||||
summary = t('ask.cancelled')
|
||||
} else if (code === 'ASK_ABORTED') {
|
||||
summary = t('ask.interrupted')
|
||||
state = 'stopped'
|
||||
} else if (model.state === 'running') {
|
||||
summary = t('ask.waiting')
|
||||
} else if ('kind' in block && model.state === 'ok') {
|
||||
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
summary = answeredSummary(text, t) ?? model.summary
|
||||
}
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconQuestionOutline14 />}
|
||||
title={t('ask.rowTitle')}
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
state={state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask-question row as a plain registrant plugin following the chat
|
||||
* toolview declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const askQuestionToolview = {
|
||||
name: 'ask-question-toolview',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the ask-question row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({
|
||||
name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS,
|
||||
}, AskQuestionRow))
|
||||
},
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the expand-gated terminal card under the summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1
|
||||
hairline, and the max-height scroll on the card's own OUTPUT (banner stays
|
||||
pinned; 224px = the 260px card cap minus the ~36px banner); the margin
|
||||
replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* A bash execution error can settle without terminal-card material (for
|
||||
example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback
|
||||
so the original command and full error remain available from this keyed row. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ioSection::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ioSection::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.ioLabel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.ioDivider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.ioText {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.ioText[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ToolRow's unified expand interaction, replicated per the registrant
|
||||
posture: pointer on the expandable row (the icon→chevron hover preview is
|
||||
the affordance, no row fill). */
|
||||
.root[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-bash-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative; /* .chevronHover overlay anchor */
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on the expandable row: the idle icon crossfades (100ms) into
|
||||
a down chevron before the row is opened — same overlay as ToolRow. */
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.root:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.root:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Hover-revealed Inspect pill under the expanded terminal's bottom-left —
|
||||
ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant
|
||||
posture: real flow (it reserves its line), revealed by hovering anywhere on
|
||||
the tool call — title row included — or by keyboard focus. */
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
/* Base background, not bg-overlay: the overlay token reads too heavy. */
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.card:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Solid hover fill: the pill floats over terminal output, so a translucent
|
||||
hover token would let the text underneath bleed through. */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// Bash toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
//
|
||||
// A bash call normally declares the terminal render intent, so this row renders
|
||||
// the command's own output through TerminalBlock. Execution failures that
|
||||
// settle without terminal material use the bounded generic IN/OUT fallback —
|
||||
// both are expand-gated exactly like
|
||||
// ToolRow's unified interaction: collapsed by default, the whole summary row
|
||||
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
|
||||
// summary stays inline while open),
|
||||
// and the expanded card max-height-scrolls inside its own surface with the
|
||||
// full output (maxLines Infinity — no middle collapse). An error row's
|
||||
// collapsed summary is the failure's first line in the error color.
|
||||
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
/** Bash row props: the toolview runtime share plus the standard locale seat. */
|
||||
type BashRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return <IconApiOutline14 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
switch (state) {
|
||||
case 'running': return t('bash.running')
|
||||
case 'error': return t('bash.failed')
|
||||
case 'stopped': return t('bash.stopped')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
|
||||
* whole row toggling the command's terminal or generic error card (ToolRow's unified
|
||||
* expand interaction, replicated locally per the registrant posture).
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const status = stateStatus(state, t)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// Execution failures (for example cancellation before the process reports a
|
||||
// terminal result) use the generic presenter. Keep their recorded args and
|
||||
// full error reachable instead of collapsing the row to the first line.
|
||||
const genericError = terminal === null
|
||||
&& model.state === 'error'
|
||||
&& (model.body !== null || model.output !== null)
|
||||
const expandable = terminal !== null || genericError
|
||||
const open = expanded && expandable
|
||||
const failureLine = model.state === 'error' ? model.errorSummary : null
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{leadingFor(state)}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: leadingFor(state)
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample="bash"
|
||||
data-variant="bash"
|
||||
data-state={state}
|
||||
data-expandable={expandable || undefined}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={expandable ? toggleExpand : undefined}
|
||||
onKeyDown={expandable ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
<span className={css.leading}>{leading}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
above-card summary; a failure's first line outranks both. */}
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
{failureLine ?? terminal?.description ?? model.summary}
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
|
||||
locally per the registrant posture. */
|
||||
<div className={css.bodyWrap}>
|
||||
{terminal !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={css.ioCard}>
|
||||
{model.body !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{model.body}</span>
|
||||
</div>
|
||||
)}
|
||||
{model.body !== null && model.output !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{model.output !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error>
|
||||
{model.output}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
|
||||
</svg>
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample as a plain registrant plugin. Slot injection follows the chat
|
||||
* toolview declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const bashToolviewSample = {
|
||||
name: 'bash-toolview-sample',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the bash row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', () =>
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow))
|
||||
},
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// File-mutation toolview registrant: the keyed toolview hole for the `edit`
|
||||
// and `write` tools. The row composes the shared ToolRow (chrome, running
|
||||
// sweep, whole-row expand) and feeds it the applied diff as ToolRow's `diff`
|
||||
// card material, so the change renders through DiffBlock in the collapsed-by-
|
||||
// default expanded body — the same unified interaction every other card row
|
||||
// has. The summary stays a path link (the file-tool interaction) that opens
|
||||
// through the host; an errored mutation (write/edit return no diff on
|
||||
// `result.isError`) keeps the model-facing error text on ToolRow's Output
|
||||
// section, its first line in the collapsed summary.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type FileMutationRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
|
||||
* with the applied diff as the row's collapsed-by-default card body. The
|
||||
* summary is a path link (a file tool's interaction); the host's `openFile`
|
||||
* resolves it against the session cwd, so this passes the tool's own path
|
||||
* verbatim. An errored mutation has no diff card, so ToolRow surfaces the
|
||||
* model-facing error text through its Output section and its first line in the
|
||||
* collapsed summary instead.
|
||||
*/
|
||||
export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconEditOutline16 size={14} />}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
diff={diff}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={openFile}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The file-mutation rows as a plain registrant plugin following the chat
|
||||
* toolview declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const fileMutationToolview = {
|
||||
name: 'file-mutation-toolview',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the file-mutation row into the chat view's keyed toolview hole
|
||||
* under both mutation tool names.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Pure plan derivation for the todo_write row's one-line summary. Several items
|
||||
* may be `in_progress` at once — parallel work runs concurrent tasks, so a
|
||||
* summary built from one active item would silently drop the rest. The plan
|
||||
* strip header derives its own counts inline and shares nothing with this, so
|
||||
* this stays inside the toolviews domain rather than in `contract/` (the
|
||||
* inter-domain face).
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* One list item as the row sees it: unvalidated model JSON parsed from a call's
|
||||
* args, so any field may be missing or mistyped.
|
||||
*/
|
||||
export interface PlanItemLike {
|
||||
content?: unknown
|
||||
status?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts plus the two halves of the summary, deliberately NOT pre-joined: the
|
||||
* row ellipsizes its summary text, and a count concatenated onto the end of the
|
||||
* task name is the first thing a narrow row clips — exactly when it carries
|
||||
* information. The row renders `activeExtra` in its own non-shrinking span
|
||||
* beside the truncatable text.
|
||||
*/
|
||||
export interface PlanSummary {
|
||||
done: number
|
||||
total: number
|
||||
/** First `in_progress` content, or null when that first item is unusable. */
|
||||
activeContent: string | null
|
||||
/** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */
|
||||
activeExtra: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the counts and the active summary from a whole-list snapshot. It names
|
||||
* the first `in_progress` item and counts the remaining active ones, so a
|
||||
* parallel plan reports how many tasks are running rather than naming one and
|
||||
* hiding the others. `activeContent` is null when nothing is in progress, or
|
||||
* when the first active item's content is missing, mistyped, or blank once
|
||||
* trimmed — the tool's own rule for usable content, applied here because a
|
||||
* rejected call keeps its args verbatim. The row then renders the counts alone
|
||||
* rather than falling back to the generic tool summary: the counts are already
|
||||
* known to be good, and the active-item clause is the only part an unusable
|
||||
* name costs.
|
||||
* @param todos - the whole list, in model order.
|
||||
* @returns the done/total counts and the two summary halves.
|
||||
*/
|
||||
export function planSummary(todos: readonly PlanItemLike[]): PlanSummary {
|
||||
const active = todos.filter(t => t.status === 'in_progress')
|
||||
const first = active[0]?.content
|
||||
const named = typeof first === 'string' && first.trim() !== ''
|
||||
return {
|
||||
done: todos.filter(t => t.status === 'completed').length,
|
||||
total: todos.length,
|
||||
activeContent: named ? first : null,
|
||||
activeExtra: named ? active.length - 1 : 0,
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Read toolview registrant: the keyed toolview hole for the read tool. The row
|
||||
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
|
||||
// feeds it the file's line-numbered, syntax-highlighted content as ToolRow's
|
||||
// `read` card material, so it renders through ReadBlock in the collapsed-by-
|
||||
// default expanded body — the same unified interaction every other card row
|
||||
// has. The summary path is an openable host link. A running read (no result
|
||||
// yet) and a non-read result render the summary row alone: the read intent is
|
||||
// result-side only, so there is no running-state read card to draw.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type ReadRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
|
||||
* read card as the row's collapsed-by-default card body. The summary path is an
|
||||
* openable host link when the row names a single file.
|
||||
*/
|
||||
export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
read={read}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={openFile}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The read row as a plain registrant plugin following the chat toolview
|
||||
* declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const readToolview = {
|
||||
name: 'read-toolview',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the read row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', () =>
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow))
|
||||
},
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Search toolview registrant: the keyed toolview hole for the `grep` and `glob`
|
||||
// tools. One SearchRow component registered under both, since both declare the
|
||||
// same `card: 'search'` render intent and render as one visual object; the
|
||||
// derived model's `kind` decides the card shape (grouped matches or a path
|
||||
// list). The row composes the shared ToolRow (chrome, running sweep, whole-row
|
||||
// expand) and feeds it the completed search as ToolRow's `search` card
|
||||
// material, so it renders through SearchBlock in the collapsed-by-default
|
||||
// expanded body — with a capped search's recovery footer below the card. A
|
||||
// search declares its render intent result-time only, so a running row is the
|
||||
// summary line alone; a settled call with no search card (an errored search, a
|
||||
// nested run_code sub-dispatch, a legacy generic result) surfaces its
|
||||
// model-facing text through ToolRow's Output section instead.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type SearchRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
|
||||
* completed search's card as the row's collapsed-by-default card body (a capped
|
||||
* search's recovery footer rides below it, inside ToolRow). Registered under
|
||||
* both `grep` and `glob`; the derived model's `kind` decides the card shape. A
|
||||
* settled call with no search card surfaces its model-facing text through
|
||||
* ToolRow's Output section, since the keyed SearchRow owns this render slot.
|
||||
*/
|
||||
export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const search = searchCardModel(block)
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconSearchOutline16 size={14} />}
|
||||
title={model.title}
|
||||
// The result view's replacement title outranks the args-derived summary,
|
||||
// matching the terminal card's description precedence.
|
||||
summary={search?.title ?? model.summary}
|
||||
body={null}
|
||||
// A settled call with no search card (errored search, nested run_code
|
||||
// sub-dispatch, legacy generic result) has its text nowhere else to go;
|
||||
// ToolRow's Output section carries it, and errorSummary its first line.
|
||||
// When a card is present ToolRow renders it instead of the output, so
|
||||
// passing model.output unconditionally is safe and keeps the four card
|
||||
// rows symmetric.
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
search={search}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search toolview follows the chat toolview declaration across activation
|
||||
* and reload. One component registers under both keys because `grep` and
|
||||
* `glob` are the same visual object discriminated by the result view's `kind`.
|
||||
*/
|
||||
export const searchToolview = {
|
||||
name: 'search-toolview',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the search row into the chat view's keyed toolview hole under both
|
||||
* the `grep` and `glob` tool names.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// todo_write toolview: plan-flavored summary row replacing the generic
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
|
||||
// summary of the written list (counts + active items) from the call args, with
|
||||
// the parallel-active count riding ToolRow's non-shrinking summary suffix so a
|
||||
// narrow row never clips it; the durable list itself renders in the TodoPanel
|
||||
// above the composer, so the row stays one line until expanded.
|
||||
|
||||
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
import { planSummary, type PlanItemLike } from './plan-summary.ts'
|
||||
|
||||
/** Todo row props: the toolview runtime share plus the standard locale seat. */
|
||||
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
function isItem(value: unknown): value is PlanItemLike {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's summary split at the ellipsis boundary: `text` truncates, `extra`
|
||||
* is the parallel-active count that must not, so a narrow row never clips the
|
||||
* one part that says several tasks are running.
|
||||
*/
|
||||
interface RowSummary {
|
||||
text: string
|
||||
extra: number
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(argsRaw)
|
||||
} catch {
|
||||
// Mid-stream truncation or malformed model JSON: fall back to the generic summary.
|
||||
return null
|
||||
}
|
||||
// Valid JSON with an invalid shape (null root, non-array todos, null items —
|
||||
// a rejected tool/call retains such args verbatim): same generic fallback.
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const todos = (parsed as { todos?: unknown }).todos
|
||||
if (!Array.isArray(todos) || !todos.every(isItem)) return null
|
||||
const { done, total, activeContent, activeExtra } = planSummary(todos)
|
||||
const head = t('todo.completed', { done, total })
|
||||
return {
|
||||
text: activeContent === null ? head : `${head} · ${activeContent}`,
|
||||
extra: activeExtra,
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (the whole row toggles the call's Input/Output
|
||||
* sections, ToolRow's unified expand). Non-ok execution states keep the
|
||||
* shared row's dot semantics — a cancelled call wrote no todo/write, so it
|
||||
* must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 }
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconChecklistOutline14 />}
|
||||
title={t('todo.rowTitle')}
|
||||
summary={summary.text}
|
||||
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The todo row as a plain registrant plugin following the chat toolview
|
||||
* declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const todoToolview = {
|
||||
name: 'todo-toolview',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the todo row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', () =>
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow))
|
||||
},
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Web toolview registrant: the keyed toolview hole for the `web_search` and
|
||||
// `web_fetch` tools. Registered under BOTH, since both declare the one `web`
|
||||
// render intent and render through the one WebBlock family; the row
|
||||
// discriminates on the toolName only to pick its icon and title. The row
|
||||
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
|
||||
// feeds it the completed retrieval as ToolRow's `web` card material, so it
|
||||
// renders through WebBlock in the collapsed-by-default expanded body — the same
|
||||
// unified interaction every other card row has. Until the call settles there is
|
||||
// no web card (the tools keep a generic pending view), so a running row is the
|
||||
// summary line alone.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type WebRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
|
||||
const WEB_TITLES: Record<string, string> = {
|
||||
web_search: 'Search',
|
||||
web_fetch: 'Fetch',
|
||||
}
|
||||
|
||||
/**
|
||||
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
|
||||
* the completed retrieval's web card as the row's collapsed-by-default card
|
||||
* body. The row discriminates on `toolName` only to pick its icon and title.
|
||||
*/
|
||||
export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const web = webCardModel(block)
|
||||
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={icon}
|
||||
title={WEB_TITLES[toolName] ?? model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
web={web}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The web rows follow the chat toolview declaration across activation and
|
||||
* reload. One WebRow component registers under both web tool names.
|
||||
*/
|
||||
export const webToolview = {
|
||||
name: 'web-toolview',
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the web row under both web tool names' keyed toolview holes.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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.toolview' row hole) 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 = () => {}
|
||||
|
||||
Reference in New Issue
Block a user