Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/module-graph.md
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/compact/compact-basic/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 17:56:53 +08:00
841 files changed
+13085 -5978

No files matched your search

@@ -1,7 +1,7 @@
/** 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'
@@ -11,7 +11,6 @@ import type {
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, UnsupportedImageMediaTypeError } from './service.ts'
import type { IConversation } from './service.ts'
@@ -24,14 +23,7 @@ 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'
@@ -41,7 +33,7 @@ import { en, NS, zh, type ConversationKey } from './locales.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
}
}
@@ -347,10 +339,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 ordered Tool placement but delegates each whole root call
// to ui-tool, which owns root/subcall composition and atomic dispatch.
slots.register({
name: 'conversation.view',
id: 'chat',
@@ -358,7 +348,7 @@ export function apply(ctx: Context): void {
label: () => t('view.chat'),
locale: NS,
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.tool': { kind: 'single', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
},
@@ -373,7 +363,7 @@ export function apply(ctx: Context): void {
},
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.
})
@@ -413,34 +403,6 @@ export function apply(ctx: Context): void {
// this service remains only where conversation actions are required.
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
// 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)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
@@ -451,6 +413,9 @@ export function apply(ctx: Context): void {
slots.register({
name: 'details',
locale: NS,
children: {
'conversation.details.tool': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
@@ -12,14 +12,12 @@
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
@@ -52,18 +50,6 @@ export interface AssistantMarkdownProps {
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[] = []
@@ -74,20 +60,6 @@ function copyText(blocks: readonly AssistantBlock[]): string {
}
/** 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, loadImage, time, runMs, ttftMs, tokensPerSecond,
seq, onFork, forkUnavailable, turnTail, t,
@@ -114,7 +86,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
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} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
@@ -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 {
@@ -2,10 +2,9 @@
// 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).
// registered directly; its registration declares the whole-Tool
// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and
// keyed per-tool dispatch behind that boundary.
//
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
// column), that host is the scrollport and this view is flow content; when
@@ -25,15 +24,15 @@ import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import type { ImageLoader } from './MessageImage.tsx'
import { formatRunDuration } from './message-chrome.ts'
@@ -104,8 +103,8 @@ 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']
/** Declared child-slot render share (stable framework binding). */
type RenderChatSlot = ChatViewSlotProps['renderSlot']
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
@@ -136,129 +135,49 @@ 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,
/** One ordered root Tool call handed intact to the Tool presentation plugin. */
const ToolSeat = memo(function ToolSeat({
renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall,
}: {
renderSlot: RenderToolRow
renderSlot: RenderChatSlot
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>
)
callId, toolName, block, selectedCallId, cwd, openFile, inspectCall,
}), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall])
return renderSlot('conversation.chat.tool', owner)
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: {
renderSlot: RenderChatSlot
results: readonly ToolResultNode[]
openFile: OpenFile
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
/** Tool ownership resolves whether the selection is this root or one of its children. */
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
<ToolSeat
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>
@@ -268,17 +187,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
/** 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
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
renderSlot: RenderChatSlot
node: CommandNode
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
const fallback = node.name === 'compact'
? <CompactionCommandCard {...owner} t={t} />
: <GenericCommandCard {...owner} t={t} />
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} t={t} />,
fallback,
})}
</div>
)
@@ -333,8 +256,8 @@ function StreamingTail({ useSession, loadImage, 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 root Tool call crosses the declared whole-Tool render seat.
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder,
@@ -348,7 +271,6 @@ export function ChatView({
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)
@@ -567,18 +489,23 @@ export function ChatView({
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}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
/>
)
}
if (item.kind === 'command-compaction') {
return (
<CommandRow
renderSlot={renderSlot}
node={item.command}
compaction={item.compaction}
t={t}
/>
)
@@ -647,9 +574,17 @@ export function ChatView({
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
data-chat-anchor-key={item.kind === 'node'
? `node:${String(item.node.seq)}`
: item.kind === 'command-compaction'
? `node:${String(item.compaction.seq)}`
: undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
data-chat-flow-kind={item.kind === 'node'
? item.node.kind
: item.kind === 'command-compaction'
? item.kind
: 'tool-group'}
>
{renderItem(item)}
</div>
@@ -658,19 +593,16 @@ export function ChatView({
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
<CallRow
<ToolSeat
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>
@@ -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')} />
}
@@ -9,6 +9,7 @@
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>}
@@ -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'
@@ -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}
/>
)
}
@@ -42,9 +42,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;
}
@@ -74,15 +74,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;
@@ -149,29 +149,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 (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).
*/
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)} />)
@@ -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>
)
}
@@ -1,326 +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, with a persistent link affordance. */
.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-secondary);
text-decoration: underline;
text-decoration-color: var(--dsw-alias-label-quaternary);
text-underline-offset: 3px;
cursor: pointer;
}
.fileLink:hover {
color: var(--dsw-alias-label-primary);
text-decoration-color: currentColor;
}
/* 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,334 +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, IconInspectOutline12, 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
}
/** 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}
>
<IconInspectOutline12 />
Inspect
</button>
)}
</div>
</DisclosureRow>
</div>
)
}
@@ -0,0 +1,8 @@
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@@ -9,13 +9,48 @@
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
| {
kind: 'command-compaction'
key: string
command: CommandNode
compaction: CompactionSummaryNode
}
/** Match explicit command outcome references to exactly one compaction checkpoint. */
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
} {
const commandsBySource = new Map<number, CommandNode | null>()
for (const node of nodes) {
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
const source = node.outcome.sourceEventSeq
if (source === undefined) continue
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
}
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
for (const node of nodes) {
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
const summary = node.summaryEventSeq
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
}
const byCommandId = new Map<string, CompactionSummaryNode>()
const byCompactionSeq = new Map<number, CommandNode>()
for (const [source, command] of commandsBySource) {
const compaction = compactionsBySummary.get(source)
if (command === null || compaction === undefined || compaction === null) continue
byCommandId.set(command.commandId, compaction)
byCompactionSeq.set(compaction.seq, command)
}
return { byCommandId, byCompactionSeq }
}
/**
* True when the node has model-visible text content worth IconActions chrome.
@@ -115,9 +150,28 @@ export function assistantBranchSeqs(
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
const pairs = commandCompactionPairs(nodes)
let group: ToolResultNode[] | null = null
for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
continue
}
if (node.kind === 'compaction') {
group = null
const command = pairs.byCompactionSeq.get(node.seq)
if (command !== undefined) {
items.push({
kind: 'command-compaction',
key: `c${command.commandId}`,
command,
compaction: node,
})
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
continue
}
if (node.kind === 'tool-result') {
if (group === null) {
group = [node]
@@ -138,7 +192,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })
items.push({
kind: 'node',
key: node.kind === 'command' && node.name === 'compact'
? `c${node.commandId}`
: `n${node.seq}`,
node,
})
}
}
return items
@@ -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(
@@ -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 } }
}
@@ -4,7 +4,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type {
@@ -42,13 +42,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'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.
* One root Tool call at its ordered ChatFlow position. The chat view owns
* placement; ui-tool owns root/subcall composition and keyed dispatch.
* The filler preserves the call-anchor DOM contract documented by
* {@link ToolTreeOwnerProps} for every root and child wrapper.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
/**
* 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
@@ -66,6 +65,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* 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'
@@ -189,56 +190,57 @@ export interface TurnTailOwnerProps {
}
/**
* 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).
* Owner currency of the chat view's whole-Tool rendering seat. The filler
* wraps every rendered root and child with `data-chat-anchor-key="call:<id>"`
* and `data-chat-call-id="<id>"`, plus `data-selected="true"` for the selected
* call. ChatView consumes those anchors to restore prepend/paging position.
*/
export interface ToolRowOwnerProps {
/** Tool call identity (details linkage; stable across running → settled). */
export interface ToolTreeOwnerProps {
/** Root Tool call identity, stable across running → settled. */
callId: CallId
/** Wire tool name (also the keyed dispatch key at the render site). */
/** Root wire Tool name. */
toolName: string
/** Frozen call slice: the running call or the settled result node. */
/** Frozen root call slice: running call or settled result node. */
block: ToolCallBlock
/** Selected call id; the Tool owner resolves whether it is root or child. */
selectedCallId?: CallId | undefined
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/**
* Open a tool-arg filesystem path with the host OS default application.
* The chat view resolves relative paths against the session cwd.
* The conversation owner resolves relative paths against the session cwd.
*/
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.
* Jump to any call in this tree in the trajectory view.
*/
inspect?: (() => void) | undefined
inspectCall: (callId: CallId) => void
}
/**
* 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.
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/** 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'>
/**
@@ -540,9 +542,9 @@ export interface ChatViewInjected {
forkAt: (seq: number) => void
}
/** 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' | 'conversation.chat.turnTail'>
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**
@@ -554,8 +556,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
}
@@ -4,20 +4,20 @@
* owns their slot assembly.
*/
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export type { IConversation } from './service.ts'
export type { DraftAttachmentId } from './input/contract.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 {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps,
ComposerAttachment, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
ToolTreeOwnerProps, TurnTailOwnerProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
@@ -94,6 +94,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}',
@@ -248,6 +250,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}',
@@ -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;
}
@@ -7,16 +7,11 @@
// 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 css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (automatic shares & injected share). */
@@ -72,7 +67,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 +121,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 +139,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>
)
}
@@ -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,181 +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, IconInspectOutline12, 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}>
<IconInspectOutline12 />
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.tool' whole-call seat) ride the slot system, whose ledger
* invariants live with the runtime slots package.
*/
const install: InvariantInstaller = () => {}