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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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 }]
|
||||
: []
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user