Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/compaction.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/sessions/request-inspection.ts
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/README.i18n.yaml
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/fs/tool-str-replace-editor/tests/tools.spec.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.md
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
#	packages/pty/tool-bash-persistent/tests/tools.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/tests/tui.spec.ts
#	packages/ui/user-approval/src/index.ts
#	packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
_Kerman
2026-07-31 22:16:40 +08:00
1010 changed files with 105572 additions and 5504 deletions

View File

@@ -20,6 +20,10 @@ import { InputBar } from './skeleton/InputBar.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'
@@ -52,6 +56,10 @@ const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
const ABSENT_MENU_LAUNCHER = {
getSnapshot: (): string | null => null,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
@@ -86,6 +94,11 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
// load should keep the open-jump-to-bottom default.
const chatScrollTops = new Map<SessionId, number>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
@@ -152,7 +165,6 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
locale: NS,
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
@@ -162,7 +174,6 @@ export function apply(ctx: Context): void {
version: () => slots.getVersion('conversation.view'),
},
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)
@@ -187,14 +198,28 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
}
}
const shell = inputHub.shell(sessionId)
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
toggleCommandMenu: slash === undefined
? undefined
: (selection) => {
shell.dismissPopup()
const snapshot = shell.snapshot
slash.toggleSource('command', {
trigger: '/',
query: '',
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
span: { ...selection, draftRev: snapshot.draftRev },
})
},
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
@@ -206,7 +231,11 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
hooks: {
notices: shell.notices,
lexicon: shell.lexicon,
menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER,
},
}
},
}, InputBar)
@@ -252,6 +281,19 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
// Unregistered 'trajectory' id is safe: the tab ring falls back to
// the first view, and the untouched inspect target stays inert.
inspectCall: (callId) => {
actions.setInspect({ callId })
actions.setView('trajectory')
},
chatScroll: {
save: (top) => {
if (top === null) chatScrollTops.delete(sessionId)
else chatScrollTops.set(sessionId, top)
},
read: () => chatScrollTops.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
@@ -278,6 +320,24 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
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)

View File

@@ -64,7 +64,6 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}

View File

@@ -66,33 +66,45 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
right with a stepped trail — flat keyframe holds, no tweening. Phase
offsets come from per-rect animation-delay (index * -250ms) set inline
by the component. */
.turnDots {
/* 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 {
align-self: flex-start;
flex: none;
display: flex;
display: inline-flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450);
font: var(--dsw-font-s-strong-14);
white-space: nowrap;
background: linear-gradient(
90deg,
var(--dsw-static-deepseek-500) 0%,
var(--dsw-static-deepseek-500) 40%,
var(--dsw-static-deepseek-200) 50%,
var(--dsw-static-deepseek-500) 60%,
var(--dsw-static-deepseek-500) 100%
);
background-position: 100% 0;
background-size: 250% 100%;
background-clip: text;
color: transparent;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: dsh-turn-status-shimmer 1.8s linear infinite;
}
.turnDotCell {
fill: currentColor;
opacity: 0.15;
animation: dsh-turn-dots-chase 1s infinite;
@keyframes dsh-turn-status-shimmer {
to {
background-position: 0 0;
}
}
@keyframes dsh-turn-dots-chase {
0%, 24.9% { opacity: 1; }
25%, 49.9% { opacity: 0.6; }
50%, 74.9% { opacity: 0.35; }
75%, 100% { opacity: 0.15; }
@media (prefers-reduced-motion: reduce) {
.turnStatus {
background-position: 0 0;
background-size: 100% 100%;
animation: none;
}
}
.hint {

View File

@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
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']
@@ -53,23 +55,36 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index]
if (node === undefined) continue
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
if (node.kind === 'assistant' || node.kind === 'user') return null
}
return null
}
/** 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, t }: {
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,
}), [node, toolName, openFile, cwd])
inspect: () => { inspectCall(node.callId) },
}), [node, toolName, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -86,7 +101,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
* 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, t,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
}: {
renderSlot: RenderToolRow
callId: string
@@ -101,11 +116,13 @@ const CallRow = memo(function CallRow({
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,
}), [callId, toolName, block, openFile, cwd])
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -122,6 +139,7 @@ const CallRow = memo(function CallRow({
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
@@ -132,7 +150,7 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
openFile: OpenFile
@@ -142,6 +160,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
return (
@@ -158,6 +177,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
@@ -184,36 +204,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
* per-rect animation-delay. */
const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() {
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus() {
return (
/* The wrapper is a 26px line box (message line height) so the loader
occupies one text line and centers the dots inside it. */
<div className={css.turnDots} aria-hidden="true">
<svg
width="17.5"
height="2.5"
viewBox="0 0 17.5 2.5"
shapeRendering="crispEdges"
>
{LOADER_CELLS.map((x, index) => (
<rect
key={x}
className={css.turnDotCell}
x={x}
y="0"
width="2.5"
height="2.5"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
/>
))}
</svg>
<div className={css.turnStatus} role="status" aria-live="polite">
Deep diving...
</div>
)
}
@@ -237,7 +232,9 @@ function StreamingTail({ useSession, onGrow, t }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt, t }: ChatViewSlotProps) {
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -251,6 +248,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
@@ -284,10 +282,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
const el = scrollerOf(local)
// Open completed: jump to the bottom once.
// Open completed: jump to the bottom once — unless a scroll position
// survives from a previous mount (view-tab switch away and back), which
// is restored instead of snapping the reader back to the floor.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
const saved = chatScroll.read()
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
followSigRef.current = followSig
@@ -325,6 +333,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
chatScroll.save(isAtBottom ? null : el.scrollTop)
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
@@ -375,6 +386,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
)
@@ -399,7 +411,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
t={t}
/>
)
}
return (
@@ -435,6 +455,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
@@ -445,7 +466,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
{running && <TurnStatus />}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>

View File

@@ -0,0 +1,56 @@
// CompactionItem: the one row a landed compaction contributes to the flow.
// The conversation it shadowed on the model surface stays above it, so this
// marker reports where the model stopped seeing that history — it never
// replaces it. The framed checkpoint payload is written for the model and is
// not rendered; the disclosure shows the summary from the checkpoint's own
// provenance, and a window cut that left that provenance outside makes the row
// non-expandable rather than empty.
import { memo, useState } from 'react'
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconChevronDownOutline14,
IconChevronRightOutline14,
MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './MessageItem.module.css'
interface CompactionItemProps {
node: CompactionSummaryNode
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}
/**
* The collapsed-by-default compaction marker.
* @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) {
const [expanded, setExpanded] = useState(false)
const expandable = node.summary !== null
const open = expandable && expanded
return (
<div className={css.compactionRow}>
<button
type="button"
className={css.compactionButton}
disabled={!expandable}
aria-expanded={expandable ? open : undefined}
onClick={() => { setExpanded(value => !value) }}
>
<span className={css.compactionLeading}>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</span>
<span className={css.compactionTitle}>{t('message.compaction')}</span>
<span className={css.compactionSep} aria-hidden />
<span className={css.compactionSummary}>
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
</span>
</button>
{open && node.summary !== null
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
</div>
)
})

View File

@@ -1,4 +1,4 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */
.root {
min-width: 0;
@@ -15,7 +15,7 @@
.body {
box-sizing: border-box;
width: calc(100% - 22px);
height: 141px;
max-height: 141px;
margin: 4px 0 0 22px;
overflow: auto;
padding: 10px 16px 12px 12px;

View File

@@ -14,6 +14,8 @@ export interface DisclosureRowProps {
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
@@ -36,6 +38,7 @@ export function DisclosureRow({
onToggle,
expandOnRowClick = false,
previewChevron = expandable,
keepContentWhenOpen = false,
collapsedContent,
children,
className,
@@ -93,7 +96,7 @@ export function DisclosureRow({
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
{!open && collapsedContent}
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
</div>

View File

@@ -34,7 +34,7 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
<ToolRow
t={t}
variant="others"
icon={<IconApiOutline14 size={16} />}
icon={<IconApiOutline14 size={14} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.

View File

@@ -10,7 +10,11 @@ import {
IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.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'
@@ -31,9 +35,18 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericToolCardProps) {
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
@@ -43,14 +56,24 @@ export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericTo
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.
summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
// 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}
state={model.state}
diff={diff}
read={read}
search={search}
web={web}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
inspect={inspect}
/>
)
}

View File

@@ -1,10 +1,9 @@
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock,
// optional edit stub.
// branch wired through onBranch, date-aware clock.
import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
IconBranchOutline16, IconCopyOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
@@ -18,8 +17,6 @@ export interface MessageIconActionsProps {
time: number
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Fork the session at this message. */
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
@@ -30,11 +27,11 @@ export interface MessageIconActionsProps {
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
* @param props - Copy text, event time, clock side, branch callback, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, onBranch, className, t,
text, time, clock, onBranch, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -58,13 +55,6 @@ export function MessageIconActions({
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label={t('edit')} side="bottom">
<button type="button" className={css.action} aria-label={t('edit')}>
<IconEditOutline16 />
</button>
</Tooltip>
)}
{clock === 'end' ? clockEl : null}
</div>
)

View File

@@ -20,20 +20,219 @@
color: var(--dsw-alias-label-primary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
padding: 1px 6px;
border-radius: 6px;
background: var(--dsw-alias-state-warn-primary);
color: var(--dsw-alias-label-primary-foreground);
font-size: 11px;
}
.contextRow {
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. */
.compactionRow {
padding: 2px 0;
}
.compactionButton {
display: flex;
align-items: center;
width: 100%;
height: 24px;
min-width: 0;
padding: 0;
border: none;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
text-align: left;
}
.compactionButton:not(:disabled) {
cursor: pointer;
}
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.compactionLeading {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 6px;
color: var(--dsw-alias-label-secondary);
}
.compactionTitle {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.compactionSep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.compactionSummary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactionBody {
padding: 4px 0 4px 22px;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.retrySummary {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 2px 0;
gap: 7px;
border-radius: 3px;
color: inherit;
cursor: pointer;
list-style: none;
user-select: none;
}
.retrySummary::-webkit-details-marker {
display: none;
}
.retrySummary::after {
width: 6px;
height: 6px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
content: '';
opacity: 0.8;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
.retrySummary:hover {
color: var(--dsw-alias-label-secondary);
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 2px;
}
.retryText {
color: inherit;
}
.retryRow[data-active] .retryText {
background:
linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-tertiary) 40%,
var(--dsw-alias-label-secondary) 50%,
var(--dsw-alias-label-tertiary) 60%,
var(--dsw-alias-label-tertiary) 100%
);
background-position: 100% 50%;
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: retry-shimmer 1.6s ease-in-out infinite;
}
.retryRow[open] .retrySummary::after {
transform: rotate(45deg);
}
.retryDetails {
display: grid;
gap: 2px;
margin-top: 3px;
padding-left: 14px;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 18px;
}
.retryDetailLabel {
color: var(--dsw-alias-label-secondary);
}
.turnErrorRow {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 2px 0;
font-size: 13px;
line-height: 20px;
}
.turnErrorDot {
margin-top: 5px;
}
.turnErrorCopy {
min-width: 0;
overflow-wrap: anywhere;
}
.turnErrorTitle {
margin-right: 6px;
color: var(--dsw-alias-state-error-primary);
font-weight: 600;
}
.turnErrorMessage {
color: var(--dsw-alias-label-secondary);
}
.turnErrorCode {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-markdown-code-block-small);
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;
}
to {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.retryRow[data-active] .retryText {
background: none;
color: inherit;
animation: none;
}
}
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
spans render as chips; free geometry — no textarea pairing here). */
.refChip {

View File

@@ -1,22 +1,31 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// clock + copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (same bubble, no actions),
// context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
import { memo } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
/** The owning view's locale seat, passed down as a plain prop. */
@@ -34,6 +43,98 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
function retrySeconds(milliseconds: number): number {
return Math.max(1, Math.ceil(milliseconds / 1_000))
}
interface RetryCountdown {
deadline: number
seconds: number
}
function ModelRetryItem({ node, active, t }: {
node: ModelRetryNode
active: boolean
t: ChatViewSlotProps['t']
}) {
// Anchor the host-scheduled delay to this browser's first render of the
// retry node. Host event time and Date.now() may belong to different clocks.
const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq])
const scheduledSeconds = retrySeconds(node.delayMs)
const maximum = node.mode === 'normal' ? node.maxRetries : '∞'
const [countdown, setCountdown] = useState<RetryCountdown>(() => ({
deadline,
seconds: retrySeconds(deadline - Date.now()),
}))
const remainingSeconds = countdown.deadline === deadline
? countdown.seconds
: retrySeconds(deadline - Date.now())
useEffect(() => {
if (!active) return
const updateCountdown = (): number => {
const next = retrySeconds(deadline - Date.now())
setCountdown(current => (
current.deadline === deadline && current.seconds === next
? current
: { deadline, seconds: next }
))
return next
}
if (updateCountdown() === 1) return
const timer = window.setInterval(() => {
if (updateCountdown() === 1) window.clearInterval(timer)
}, 250)
return () => { window.clearInterval(timer) }
}, [active, deadline])
const label = active
? t('message.retry.active')
: node.retryState === 'cancelled'
? t('message.retry.cancelled')
: node.retryState === 'started'
? t('message.retry.started')
: t('message.retry.scheduled')
const seconds = active ? remainingSeconds : scheduledSeconds
return (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span className={css.retryText} role="status">
{t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
</span>
</summary>
<div className={css.retryDetails}>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
{Math.round(node.delayMs)}ms
</div>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
{node.failure.message}
</div>
</div>
</details>
)
}
/** Persistent, turn-positioned feedback for a terminal failure. */
function TurnErrorItem({ node, t }: {
node: TurnErrorNode
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.turnErrorRow} role="status">
<StateDot state="error" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.turnErrorTitle}>{t('message.turnError')}</span>
<span className={css.turnErrorMessage}>{node.message}</span>
</div>
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
</div>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -66,45 +167,62 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
/** Right-aligned bubble shared by user and steering rows (steering has no actions). */
function UserStyleBubble({
content, actions, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
{actions?.(text)}
</div>
)
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
case 'user':
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
<MessageIconActions
text={text}
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
<UserStyleBubble
content={node.content}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
)}
/>
)
}
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
</div>
)
}
case 'steering':
return <UserStyleBubble content={node.content} t={t} />
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
case 'turn-error':
return <TurnErrorItem node={node} t={t} />
default:
return (
<div className={css.contextRow}>

View File

@@ -3,43 +3,35 @@
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import css from './StatsLine.module.css'
interface UsageTotals {
interface WindowStats {
turns: number
steps: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
inputTokens: number
outputTokens: number
cacheHitPct: number | null
}
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
interface UsageLike {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
}
/**
* Fold assistant and tool-result nodes into display totals.
* Fold assistant and tool-result nodes into the window-scoped display totals.
*
* Counts and wall times describe the loaded window on purpose — they answer
* "what is on screen". Token accounting deliberately does NOT come from here:
* the window is paged and compaction rewrites it, so billing rides the durable
* `tokenUsage` projection instead.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
* @returns visible counts and summed wall times.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
const turns = new Set<number>()
let steps = 0
let llmMs = 0
let toolMs = 0
let input = 0
let output = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
@@ -51,22 +43,8 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
output += usage.outputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
llmMs,
toolMs,
inputTokens: input + cacheRead,
outputTokens: output,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
return { turns: turns.size, steps, llmMs, toolMs }
}
/**
@@ -94,21 +72,82 @@ export function formatDuration(ms: number): string {
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
/**
* Cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns rounded integer percent, or null when no input was billed.
*/
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
const denominator = billedInputTokens(usage)
return denominator === 0
? null
: Math.round(usage.cacheReadTokens / denominator * 100)
}
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
/** Sum the three disjoint prompt-side billing buckets. */
function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
interface ContextOccupancy {
percent: number
contextWindow: number
}
/**
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact measurement of one
* request (see the token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy and its denominator, or null until both values are known.
*/
export function contextOccupancy(
pressure: ContextPressureProjection | undefined,
): ContextOccupancy | null {
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
return {
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
contextWindow: pressure.contextWindow,
}
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
}
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
const groups: string[] = []
if (stats.steps > 0) {
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
}
const context = contextOccupancy(pressure)
if (context !== null) {
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
}
// Billing rides the durable projection, so these survive paging and
// compaction. Suppress the empty projection on a brand-new session.
if (usage !== undefined
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
groups.push(
`Input ${formatTokens(billedInputTokens(usage))} tok`
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
)
}
if (groups.length === 0) return null
return (
<div className={css.root}>
{groups.map((group, i) => (

View File

@@ -56,6 +56,10 @@
background: var(--dsw-alias-state-business-primary);
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
}
@@ -103,8 +107,65 @@
text-decoration: underline;
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
/* 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;
@@ -113,23 +174,130 @@
color: var(--dsw-alias-label-tertiary);
}
/* The two block-shaped expanded bodies: the code variant's run_code program
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
command output through TerminalBlock. Both 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 {
margin: 4px 0 4px 22px;
/* 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);
}
/* Indented to the body's own column so the description reads as the card's
heading rather than as another summary row, and sits tight against the card
below it. Its own rule: grouping it with a body would put description
typography on a `CodeBlock` wrapper and change that body's spacing. */
.terminalDescription {
margin: 4px 0 0 22px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
/* 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;
}

View File

@@ -1,17 +1,36 @@
// 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. The collapsed row is always one
// line; the expanded body is indented gray text, the run_code program through
// CodeBlock, or — for a call whose render intent is a terminal card — the
// command's own output through TerminalBlock, capped at
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
// 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, whose body opens with the same first line and would repeat it.
// 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 { useState, type MouseEvent, type ReactNode } from 'react'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
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 { CHAT_WEB_MAX_SOURCES } from '../contract/web-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 css from './ToolRow.module.css'
@@ -26,18 +45,44 @@ export interface ToolRowProps {
icon: ReactNode
title: string
summary: string
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
/** 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 body when present.
* Null or absent leaves the text body, and a row with neither is not
* expandable (its leading slot never toggles).
* (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
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
@@ -45,6 +90,21 @@ export interface ToolRowProps {
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
/**
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
* over the expanded body. Absent = no affordance (rows without a call
* identity, like Think).
*/
inspect?: (() => void) | undefined
}
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
function IconInspect() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
@@ -58,6 +118,19 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
/** 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,
@@ -66,26 +139,40 @@ export function ToolRow({
title,
summary,
body,
output,
errorSummary,
terminal,
diff,
read,
search,
web,
state,
expandOnRowClick = false,
filePath,
onOpenFile,
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet. Terminal
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = (body !== null && !singleFile) || terminalBody !== null
// The text arms take the empty string for a null body: a row expandable
// only through its terminal material renders the terminal body instead, so
// this substitution never shows.
const text = body ?? ''
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 is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -93,20 +180,41 @@ export function ToolRow({
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 — the body's
// own first line — yields to avoid repeating itself.
const isThink = variant === 'think'
// 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={expandOnRowClick}
previewChevron={expandable && state !== 'error' && state !== 'stopped'}
expandOnRowClick
keepContentWhenOpen={!isThink}
onToggle={toggleExpand}
collapsedContent={(
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 ? (
@@ -114,32 +222,90 @@ export function ToolRow({
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
{summary}
{summaryText}
</button>
) : (
<span className={css.summary}>{summary}</span>
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{summaryText}
</span>
)}
</>
)}
>
{/* The terminal presenter's description belongs above the card per
the render-intent contract. */}
{terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={CHAT_TERMINAL_MAX_LINES}
labels={terminalBlockLabels(t)}
className={css.terminalBody}
/>
)
: variant === 'code'
? <CodeBlock code={text} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
: <div className={css.body}>{text}</div>}
{/* 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} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
Inspect
</button>
)}
</div>
</DisclosureRow>
</div>
)

View File

@@ -1,7 +1,8 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* VERTICAL gap10) alternating with narration. Consecutive retry notices
* reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
@@ -48,8 +49,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
* @returns flow items; consecutive tool results group and retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
@@ -63,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
} else {
group.push(node)
}
} else if (node.kind === 'model-retry') {
group = null
const previous = items[items.length - 1]
if (
previous?.kind === 'node'
&& previous.node.kind === 'model-retry'
) {
items[items.length - 1] = { ...previous, node }
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })

View File

@@ -0,0 +1,100 @@
/**
* 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) and that outranks the view's `title`, matching the TUI diff
* branch, which likewise draws no view title. A tool that names its own diff
* header therefore does not surface that text on the Web row — an accepted
* product choice, recorded here as the one asymmetry with the terminal card,
* whose derivation does consume the view's title.
* @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 } }
}

View File

@@ -0,0 +1,76 @@
/**
* 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,
}
}

View File

@@ -0,0 +1,159 @@
/**
* 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 } }
}

View File

@@ -3,9 +3,9 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -147,13 +147,17 @@ export interface InputZone {
}
/**
* View-slot owner share: deliberately empty — ConversationRoot supplies
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
* View-slot owner share: the cross-view inspect handoff (otherwise views need
* nothing from the render site — sessionId and the snapshot hook arrive as
* framework-standard props; tool rows go through each view's own declared
* toolview hole). Kept as the named owner seat so a future cross-view
* payload has a home.
* toolview hole).
*/
export interface ConvViewOwnerProps {}
export interface ConvViewOwnerProps {
/** One-shot inspect request from another view (chat's Inspect button); null when idle. */
inspect?: { callId: CallId } | null
/** Acknowledge the inspect request once applied (clears the store field). */
onInspectDone?: () => void
}
/**
* Owner share of a per-view toolview slot: the call material the rendering
@@ -176,6 +180,11 @@ export interface ToolRowOwnerProps {
* The chat view 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.
*/
inspect?: (() => void) | undefined
}
/**
@@ -235,8 +244,6 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
/**
@@ -265,14 +272,14 @@ export interface ComposerBarOwnerProps {
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
@@ -292,6 +299,8 @@ export interface ComposerBarInjected {
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
/** Source name opened by the programmatic menu launcher, or null. */
menuLauncher: ObservableSnapshot<string | null>
}
}
@@ -344,7 +353,6 @@ export type ConversationSessionSlotProps =
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>
@@ -423,6 +431,19 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
inspectCall: (callId: CallId) => void
/**
* Per-session scroll memory surviving view switches (in-memory, never
* persisted): the view saves on every scroll and restores on remount; a
* fresh page load starts empty and keeps the open-jump-to-bottom default.
*/
chatScroll: {
/** Record the scroll offset; null clears it (pinned to bottom). */
save: (top: number | null) => void
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
}
/** Fork the session through the turn containing the message at `seq`, then open the child. */
forkAt: (seq: number) => void
}

View File

@@ -37,17 +37,6 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo
}
}
/**
* Output lines the chat row's expanded terminal 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_TERMINAL_MAX_LINES = 8
/**
* 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
@@ -70,6 +59,20 @@ export interface TerminalCardModel {
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

View File

@@ -1,14 +1,15 @@
/**
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary and expanded-body text from the frozen call slice. This
* derivation reads the call ARGUMENTS only; a call whose render intent is a
* terminal card gets its expanded body from the views instead, through
* 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 } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -70,11 +71,34 @@ export interface ToolRowModel {
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body text (pretty args); null = row not expandable. */
/** 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)
@@ -109,8 +133,13 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [],
}
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
/**
* 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)
@@ -192,12 +221,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
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,
}
}

View File

@@ -23,4 +23,10 @@ export interface ChatStoreState {
draft: string
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
view: string | null
/**
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
* view consumes it and acknowledges by clearing. Read with `?? null` —
* persisted snapshots from before this field rehydrate without it.
*/
inspect: { callId: CallId } | null
}

View File

@@ -0,0 +1,84 @@
/**
* 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'
/**
* Sources the chat row's web 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_WEB_MAX_SOURCES = 8
/**
* 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
}

View File

@@ -29,7 +29,7 @@ export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(mode?: 'queue' | 'steer'): void
submit(): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -60,7 +60,7 @@ export interface InputActions {
/** Single public draft write path (full next draft; occurrence math via diff scan). */
setDraft(text: string): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(mode?: 'queue' | 'steer'): void
submit(): void
}
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
@@ -239,7 +239,7 @@ export type InputEvent =
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
| { readonly type: 'enter' }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
@@ -258,5 +258,5 @@ export type InputEvent =
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
| { readonly type: 'default-sink'; readonly draft: string }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -39,7 +39,7 @@ export interface SessionInputDeps {
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer'): void
defaultSink(text: string): void
}
/** Guard tier from the machine phase. */
@@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput {
/** The public provide-channel action face (one stable identity per session — decision 20). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
submit: (mode) => { this.submit(mode) },
submit: () => { this.submit() },
}
// Real wall clock: the typing-run merge window must actually expire in
@@ -151,10 +151,9 @@ export class SessionInputShell implements SessionInput {
* from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
* @param mode - default-sink mode (queue appends; steer interrupts).
*/
submit(mode: 'queue' | 'steer' = 'queue'): void {
this.run(this.core.dispatch({ type: 'enter', mode }))
submit(): void {
this.run(this.core.dispatch({ type: 'enter' }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -339,7 +338,7 @@ export class SessionInputShell implements SessionInput {
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
this.sinkSerialized(fx.draft)
return
}
default:
@@ -354,10 +353,10 @@ export class SessionInputShell implements SessionInput {
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
private sinkSerialized(draft: string): void {
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode)
this.deps.defaultSink(draft.trim())
return
}
const slash = this.deps.slash?.()
@@ -377,7 +376,7 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode)
this.deps.defaultSink(out.trim())
},
(error: unknown) => {
controller.abort()

View File

@@ -56,7 +56,7 @@ export class InputHub implements InputService {
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
defaultSink: (text) => { this.sink(session, text) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -106,18 +106,29 @@ export class InputHub implements InputService {
return this.shell(id)
}
/**
* Resolve the optional slash controller for composer chrome that launches
* the shared candidate menu without typing a trigger.
* @param id - session id.
* @returns the resident controller, or undefined when ui-slash is absent.
*/
slash(id: SessionId): SlashController | undefined {
const actx = this.sessions().scope(id)
return actx === undefined ? undefined : this.controller(actx)
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
private sink(session: SessionFace, text: string): void {
if (text === '') return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend()
void session.prompt([{ type: 'text', text }], mode).then(
void session.prompt([{ type: 'text', text }], 'queue').then(
(result) => {
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
},

View File

@@ -112,7 +112,6 @@ export class InputMachine {
private inflight: {
readonly attempt: SubmitAttempt
readonly controller: AbortController
readonly mode: 'queue' | 'steer'
} | undefined
private log: Transaction[] = []
private redoStack: Transaction[] = []
@@ -163,7 +162,7 @@ export class InputMachine {
this.paste = undefined
return []
}
case 'enter': return this.onEnter(ev.mode)
case 'enter': return this.onEnter()
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
@@ -462,18 +461,18 @@ export class InputMachine {
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
private beginAttempt(): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
this.inflight = { attempt, controller, mode }
this.inflight = { attempt, controller }
return attempt
}
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
private onEnter(): InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'submitting'
this.paste = undefined
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
@@ -482,11 +481,11 @@ export class InputMachine {
if (trimmed === '') return []
this.paste = undefined
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft: this.draft }]
}
return [{ type: 'default-sink', draft: this.draft, mode }]
return [{ type: 'default-sink', draft: this.draft }]
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -507,7 +506,7 @@ export class InputMachine {
this.inflight = undefined
this.phase = 'plain'
return outcome === undefined
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
: []
}

View File

@@ -19,13 +19,17 @@ export const zh = {
'placeholder.unavailable': '会话不可用',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.addAttachment': '添加附件',
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'access.confirm.title': '确认启用 Full access?',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
@@ -42,12 +46,22 @@ export const zh = {
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
'message.unknownSurface': '未知 surface 事件:{type}',
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'message.retry.active': '正在重试模型请求',
'message.retry.cancelled': '模型请求重试已取消',
'message.retry.started': '已重试模型请求',
'message.retry.scheduled': '等待重试模型请求',
'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -65,6 +79,9 @@ export const zh = {
'bash.running': '运行中',
'bash.failed': '失败',
'bash.stopped': '已停止',
'row.running': '运行中',
'row.failed': '失败',
'row.stopped': '已停止',
'queue.count': '{n} 条排队消息',
'queue.edit': '编辑排队消息',
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
@@ -101,13 +118,17 @@ export const en = {
'placeholder.unavailable': 'Session unavailable',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.addAttachment': 'Add attachment',
'input.commands': 'Commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',
@@ -124,12 +145,22 @@ export const en = {
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',
'message.unknownSurface': 'Unknown surface event: {type}',
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'message.retry.active': 'Retrying model request',
'message.retry.cancelled': 'Model request retry cancelled',
'message.retry.started': 'Retried model request',
'message.retry.scheduled': 'Waiting to retry model request',
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',
@@ -147,6 +178,9 @@ export const en = {
'bash.running': 'Running',
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'row.running': 'Running',
'row.failed': 'Failed',
'row.stopped': 'Stopped',
'queue.count': '{n} queued messages',
'queue.edit': 'Edit queued message',
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',

View File

@@ -1,10 +1,21 @@
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
/* Figma .FileContainerText 1:791: the wrapper uses the shared dock inset
inside the composer card around the inset panel. */
.dock {
box-sizing: border-box;
flex: none;
width: 100%;
max-width: 776px;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
/* Flex gap still applies after this item; subtract it together with the
design's overlap so the later composer paints over the queue edge. */
margin: 0 auto calc(
@@ -96,6 +107,10 @@
border-radius: 8px;
}
.row + .row {
box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
}
.preview,
.editor {
flex: 1 1 auto;

View File

@@ -73,7 +73,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
}
return (
<div className={css.dock}>
<div className={css.dock} data-queue-dock="">
<div className={css.panel}>
{queue.length > 1 && (
<button

View File

@@ -25,12 +25,11 @@ export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* Send a prompt into the caller scope's session.
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
@@ -39,7 +38,7 @@ export interface IConversation {
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* Cancel the scoped session's in-flight turn while preserving its pending Queue.
* @returns completion; failures reject as in send.
*/
cancel(): Promise<void>
@@ -71,11 +70,10 @@ export class ConversationService extends Service implements IConversation {
* session snapshot's promptError (object-layer surface); the rejection here
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
*/
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
async send(text: string): Promise<void> {
const session = this.scopedSession('send')
const result = await session.prompt([{ type: 'text', text }], mode)
const result = await session.prompt([{ type: 'text', text }], 'queue')
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
@@ -88,7 +86,7 @@ export class ConversationService extends Service implements IConversation {
}
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
/** Cancel the scoped session's in-flight turn while preserving Queue (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')
const result = await session.cancel()

View File

@@ -1,4 +1,4 @@
/* Conversation column skeleton: header (breadcrumb row + tabs) over the view
/* Conversation column skeleton: header (session title + tabs) over the view
area, composer InputBar at the bottom. Column width/squeeze is layout's;
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
a 3px active bar. */
@@ -23,59 +23,24 @@
display: none;
}
.crumbRow {
.titleRow {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
.sessionTitle {
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.crumb {
max-width: 220px;
overflow: hidden;
margin: 0;
padding: 4px 8px;
border: none;
border-radius: 12px;
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumb:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
text-overflow: ellipsis;
white-space: nowrap;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
@@ -133,6 +98,12 @@
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
/* InputBar and dock registrants derive their horizontal geometry from the
same card width, outer clearance, and dock inset. */
--dsh-composer-card-max-width: 800px;
--dsh-composer-side-clearance: 32px;
--dsh-composer-dock-inset: 12px;
display: flex;
flex-direction: column;
gap: var(--dsh-composer-stack-gap);

View File

@@ -22,6 +22,7 @@ export function ConversationRoot({
const session = useSession(s => s)
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
const workspaces = useWorkspaces(s => s)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -65,8 +66,16 @@ export function ConversationRoot({
// While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
// Exemption: a session the list summary already proves blank can only
// land on the hero, so hiding would blank the column for the whole
// history round-trip (the startup auto-selection flash) for nothing.
// The exemption is deliberately open-state-wide, not loading-only: a
// summary-blank session is the hero before its open starts (`cold`) and
// after one fails (`error`) for the same reason — there is no history.
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
&& summaryBlank !== true
const hero = sessionId === undefined
|| (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }

View File

@@ -2,39 +2,27 @@
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
renderSlot, views, bindDraftMirror, wrapActiveBody,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const title = useSessions(s => s.byId[sessionId]?.displayTitle ?? sessionId)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
const inspect = useStore(s => s.inspect ?? null)
useEffect(() => {
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
@@ -52,7 +40,10 @@ export function ConversationSession({
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
{active !== undefined && renderSlot('conversation.view', {
inspect,
onInspectDone: () => { actions.setInspect(null) },
}, { only: active.id })}
</div>
)
@@ -64,26 +55,8 @@ export function ConversationSession({
>
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.titleRow}>
<h1 className={css.sessionTitle}>{title}</h1>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">

View File

@@ -101,8 +101,27 @@
font: var(--dsw-font-xs-13);
}
/* The terminal card sits directly under its section label, so it drops the
primitive's standalone vertical margin; the section owns the spacing. */
.terminal {
/* 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;
}

View File

@@ -7,12 +7,16 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } 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 { 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 type { ToolCallBlock } from '../contract/tool-call-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). */
@@ -127,8 +131,16 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* 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. Every other call, and
* a running call with no terminal card yet, keeps the flattened text form.
* 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.
@@ -144,7 +156,44 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
<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)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. 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>}
</>
)
}
@@ -154,20 +203,7 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
const result = material.block
return (
<pre className={css.code} data-error={result.isError || undefined}>
{renderResult(result)}
{resultText(result)}
</pre>
)
}
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
function renderResult(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')
}

View File

@@ -11,7 +11,7 @@
padding: 0 24px;
}
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
/* Cap matches the InputBar card. Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
@@ -19,7 +19,7 @@
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
overflow: visible;
}

View File

@@ -23,7 +23,7 @@
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
the chat scroller. No top pad: the composer stack's gap owns the space
above; error/status strips still carry their own margin. */
padding: 0 32px 8px;
padding: 0 var(--dsh-composer-side-clearance) 8px;
}
.hero {
@@ -33,7 +33,7 @@
.error,
.status {
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
@@ -48,7 +48,7 @@
.notice {
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
@@ -69,6 +69,7 @@
}
.card {
box-sizing: border-box;
position: relative; /* overlay anchor positioning context */
display: flex;
flex-direction: column;
@@ -76,7 +77,7 @@
top pad on the card before .InputText. */
gap: 12px;
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
padding-top: 10px;
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
the input border is one notch weaker than buttons) — exactly the
@@ -192,6 +193,18 @@
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
/* These three MUST wrap at one width, because InputBar mirrors a single
scroll offset between .input and .backdrop and a layer that wraps onto
more lines is taller, has a larger scroll maximum, and clamps the mirrored
offset below the caret. Only .input scrolls, so only .input can lose
content width to a scrollbar that consumes layout space.
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
removed after measuring: WebKit applies it to overflow-y:auto but not to
the overflow:hidden layers, so it left .input at 768 against 776 — the
same gap it was meant to close — while costing chromium 8px of text width
unconditionally. The gap it would have closed is measured and recorded in
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
closing it needs one geometry every engine agrees on, not this property. */
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */

View File

@@ -34,13 +34,14 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, t, renderSlot, useNotices, useLexicon,
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const commandMenuOpen = useMenuLauncher(source => source === 'command')
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
@@ -61,6 +62,7 @@ export function InputBar({
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const backdropRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -90,11 +92,22 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
// the inert state renders the same element disabled).
//
// wheel — active conversation scrollport: chain the gesture. While the
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
// direction, keep the native scroll; only at its own edge forward delta to
// the host so a short draft never traps the gesture and a long draft stays
// scrollable. Hero mounts have no host and keep native wheel scrolling.
//
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place. Every way the box moves ends
// in a `scroll` event, edits included (the caret is scrolled into view), and
// the layers share an extent, so a draft that shrinks past the offset clamps
// both to the same maximum — one listener covers the coupling.
useEffect(() => {
const el = inputRef.current
if (el === null) return
@@ -107,8 +120,16 @@ export function InputBar({
e.preventDefault()
host.scrollTop += e.deltaY
}
const onScroll = (): void => {
const backdropEl = backdropRef.current
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -172,7 +193,7 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
inputActions.submit('queue')
inputActions.submit()
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
@@ -257,7 +278,11 @@ export function InputBar({
inputRef.current?.focus()
}
const addText = addLabel ?? t('input.addAttachment')
const onToggleCommandMenu = (): void => {
const el = inputRef.current
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const primaryLabel = running ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
@@ -266,7 +291,7 @@ export function InputBar({
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
if (!empty && !disabled && !machineBusy) inputActions.submit()
}
// The Access seat: the projection-fed permission chip (renders nothing
@@ -274,7 +299,7 @@ export function InputBar({
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
@@ -350,6 +375,22 @@ export function InputBar({
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
// Trailing-line sentinel, the same one the mirror div carries and for the
// same reason: a textarea reserves a line box for the caret after a final
// newline, while `white-space: pre-wrap` collapses a text node's trailing
// newline and generates none. Without it a draft ending in a newline makes
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
// offset at the very bottom clamps and the glyphs sit a line behind the
// caret. The extra newline is absorbed by that same collapse when the draft
// does not end in one, so it costs no height in the ordinary case.
//
// The mirror only fails one way — a backdrop SHORTER than the textarea
// clamps the assignment, while a taller one takes every offset exactly and
// hides the surplus below the clip. That is why the ghost hint needs no
// handling of its own: it can only add content after the draft and before
// this sentinel, never remove a line box, so it moves the pair to equal or
// to the safe side.
backdrop.push('\n')
}
return (
@@ -371,7 +412,7 @@ export function InputBar({
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
@@ -399,11 +440,13 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label={addText}
title={addText}
disabled={locked}
aria-label={t('input.commands')}
title={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onAdd}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>

View File

@@ -1,21 +1,28 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
const FULL_ACCESS = 'danger-full-access'
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
* pass through. Twin of the /permission popup's (client ui-permission) — the
* two permission surfaces must show the same text.
* pass through. Full access intentionally overrides the machine-name
* transform so both permission surfaces use the product label `Full access`;
* the warning body remains locale-aware.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function optionLabel(option: PermissionSelectValue['options'][number]): string {
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
}
export interface PermissionSelectProps {
value: PermissionSelectValue | undefined
locked: boolean
@@ -27,49 +34,94 @@ export interface PermissionSelectProps {
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [confirmation, setConfirmation] = useState<string | null>(null)
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
if (!locked && value !== undefined) return
setOpen(false)
setAcknowledged(false)
setConfirmation(null)
}, [locked, value])
if (value === undefined) return null
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const busy = pick !== null
const busy = pick !== null || confirmation !== null
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map(option => ({ id: option.value, label: displayName(option.name) }))
.map(option => ({ id: option.value, label: optionLabel(option) }))
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
const submit = (id: string): void => {
setPick(id)
void command(`/permission ${id}`)
.catch(() => false)
.then(() => { setPick(null) })
}
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
if (id === FULL_ACCESS) {
setAcknowledged(false)
setConfirmation(id)
return
}
submit(id)
}
const closeConfirmation = (): void => {
setAcknowledged(false)
setConfirmation(null)
}
const confirmFullAccess = (): void => {
if (locked || !acknowledged || confirmation === null) return
const id = confirmation
closeConfirmation()
submit(id)
}
return (
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
<>
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
<RiskConfirmation
open={confirmation !== null}
title={t('access.confirm.title')}
description={t('access.confirm.description')}
acknowledgeLabel={t('access.confirm.acknowledge')}
cancelLabel={t('access.confirm.cancel')}
confirmLabel={t('access.confirm.enable')}
acknowledged={acknowledged}
disabled={locked}
onAcknowledgedChange={setAcknowledged}
onCancel={closeConfirmation}
onConfirm={confirmFullAccess}
/>
</>
)
}

View File

@@ -1,13 +1,24 @@
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
14px radius, status icons + secondary item labels. */
14px radius, status icons + secondary item labels. It shares the composer
card geometry and adds the dock inset on both sides. */
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(100% - 88px);
max-width: 752px;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);

View File

@@ -3,7 +3,7 @@
* The plugin creates its handle at apply time so identity follows the fiber.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts'
/** Declared action shape used to give the exported factory a stable return type. */
type ChatActions = {
@@ -12,6 +12,7 @@ type ChatActions = {
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: string) => void
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
}
/**
@@ -20,7 +21,7 @@ type ChatActions = {
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
@@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: string) => { d.view = view },
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
},
})
}

View File

@@ -1,7 +1,7 @@
// 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, leading expansion) and swaps in the interaction
// (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.
@@ -42,8 +42,9 @@ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | nu
/** Full row props: the toolview runtime share plus the standard locale seat. */
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
/** One-line question-interaction row (leading toggle expands the raw args). */
export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) {
/** 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
@@ -74,7 +75,9 @@ export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) {
title={t('ask.rowTitle')}
summary={summary}
body={model.body}
output={model.output}
state={state}
inspect={inspect}
/>
)
}

View File

@@ -1,5 +1,5 @@
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
plus the terminal card the row stacks under its summary line. */
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. */
@@ -8,10 +8,23 @@
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
/* 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 {
margin: 4px 0 4px 22px;
--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);
}
/* 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 {
@@ -47,6 +60,7 @@
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
@@ -57,6 +71,34 @@
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;
}
.scopeBadge {
flex: none;
margin-right: 8px;
@@ -95,6 +137,52 @@
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;

View File

@@ -4,20 +4,24 @@
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
//
// A bash call declares the terminal render intent, so this row also renders
// the command's own output through TerminalBlock. This row has no expand
// control and is not a details-panel target either (tool rows stopped being
// one), so its terminal body is resident rather than expand-gated as in
// ToolRow, and the card's own copy and expand controls are the row's only
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
// flow's tighter cap over the block's own default of 16 — and the block's
// internal expander keeps a long output from taking over the message flow.
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — 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 { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.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'
@@ -45,43 +49,89 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
}
/**
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
* command's terminal card resident below it. The summary row is not a
* details-panel control (tool rows stopped being one), so the card's copy and
* expand controls are the row's only interactions.
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
* whole row toggling the command's terminal card (ToolRow's unified
* expand interaction, replicated locally per the registrant posture).
*/
export function BashRow({ toolName, block, sessionId, useSessions, t }: BashRowProps) {
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 isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state, t)
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
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={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
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}>{leadingFor(model.state)}</span>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</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; it outranks the args-derived one. */}
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
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>
{terminal !== null && (
<TerminalBlock
{...terminal.card}
maxLines={CHAT_TERMINAL_MAX_LINES}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
{terminal !== null && open && (
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
locally per the registrant posture. */
<div className={css.bodyWrap}>
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
Inspect
</button>
)}
</div>
)}
</div>
)

View File

@@ -0,0 +1,73 @@
// 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. `inject` carries the
* load-order seam: requiring the conversation service guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export const fileMutationToolview = {
name: 'file-mutation-toolview',
inject: ['slots', 'conversation'],
/**
* 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.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
},
}

View File

@@ -0,0 +1,66 @@
// 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. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and with
* it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export const readToolview = {
name: 'read-toolview',
inject: ['slots', 'conversation'],
/**
* 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.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow)
},
}

View File

@@ -0,0 +1,82 @@
// 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 as a plain registrant plugin. `inject` carries the
* load-order seam: requiring the conversation service guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is registered.
* The one component registers under both keys, since `grep` and `glob` are the
* same visual object discriminated only by the result view's `kind`.
*/
export const searchToolview = {
name: 'search-toolview',
inject: ['slots', 'conversation'],
/**
* 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.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
},
}

View File

@@ -1,10 +1,10 @@
// 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, leading expansion) and swaps in a
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
// row stays one line until expanded.
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
@@ -45,10 +45,11 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
: head
}
/** One-line plan update row (leading toggle expands the raw args). 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, t }: TodoRowProps) {
/** 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) ?? model.summary
@@ -61,7 +62,10 @@ export function TodoRow({ toolName, block, t }: TodoRowProps) {
title={t('todo.rowTitle')}
summary={summary}
body={model.body}
output={model.output}
errorSummary={model.errorSummary}
state={model.state}
inspect={inspect}
/>
)
}

View File

@@ -0,0 +1,74 @@
// 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 as a plain registrant plugin, riding the same load-order seam as
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
* WebRow component registers under both web tool names.
*/
export const webToolview = {
name: 'web-toolview',
inject: ['slots', 'conversation'],
/**
* 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.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
},
}