feat(web): unify tool-row expand interaction with IN/OUT card and trajectory Inspect
Every expandable tool row shares one interaction (whole-row toggle, icon-to-chevron hover preview) and one expanded body: an IN/OUT gutter-labeled card with per-section 150px scroll caps and sticky labels. toolRowModel derives result output and the error first line, terminalFailed surfaces a failing exit as the collapsed row's red dot, a hover Inspect pill jumps to the call's trajectory record through a one-shot store handoff, and the chat view keeps its scroll offset across view switches.
This commit is contained in:
@@ -98,6 +98,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')) {
|
||||
@@ -262,6 +267,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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
@@ -54,7 +54,6 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
summary={firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
expandOnRowClick
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -57,18 +59,20 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
* 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 }: {
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
}) {
|
||||
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, {
|
||||
@@ -85,7 +89,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,
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
@@ -100,10 +104,12 @@ const CallRow = memo(function CallRow({
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
}) {
|
||||
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, {
|
||||
@@ -120,6 +126,7 @@ const CallRow = memo(function CallRow({
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -129,7 +136,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 }: {
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
@@ -139,6 +146,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
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
@@ -154,6 +162,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -230,7 +239,9 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* 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 }: ChatViewSlotProps) {
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll,
|
||||
}: 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)
|
||||
@@ -274,10 +285,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
|
||||
@@ -315,6 +336,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.
|
||||
@@ -365,6 +389,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -417,6 +442,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
|
||||
return (
|
||||
<ToolRow
|
||||
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,7 @@ import {
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
@@ -26,9 +26,14 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
others: <IconSparkle16 size={14} />,
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, inspect }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, 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 singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
@@ -39,12 +44,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
// 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.
|
||||
body={singleFile ? null : model.body}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
state={state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
// 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.
|
||||
// injection (a ToolRow-chromed collapsible row: the injection reads as "the
|
||||
// harness read something into context", so it borrows the read variant's icon
|
||||
// and the IN-card expanded body) and unknown-surface JSON rows. Props are
|
||||
// frozen node slices off the snapshot cache; memo holds across streaming
|
||||
// because unchanged nodes keep their references.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBrowseOutline16, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
@@ -92,12 +95,29 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'context':
|
||||
case 'context': {
|
||||
// Pure-text injections show their text; anything with non-text blocks
|
||||
// (or nothing at all) keeps the full JSON payload so no material is lost.
|
||||
// Title-only collapsed row (no summary), label-less expanded card: the
|
||||
// injection is ambient context, not a call's input.
|
||||
const { text, rest } = contentText(node.content)
|
||||
const body = rest.length === 0 && text !== ''
|
||||
? text
|
||||
: JSON.stringify({ content: node.content, source: node.source }, null, 2)
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
|
||||
<ToolRow
|
||||
variant="read"
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
title="上下文注入"
|
||||
summary=""
|
||||
body={body}
|
||||
plainBody
|
||||
state="ok"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
|
||||
/* Every expandable row is the expand control: pointer only — the icon→chevron
|
||||
hover preview is the affordance, no row fill. */
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -55,9 +56,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -76,8 +74,8 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
button.leading {
|
||||
cursor: pointer;
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
|
||||
@@ -155,8 +153,65 @@ button.leading {
|
||||
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;
|
||||
@@ -165,6 +220,89 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Expanded input/output card (figma 1249:35657): the code-block surface and
|
||||
radius from the TerminalBlock/CodeBlock family. The card itself is a plain
|
||||
column — the padding and the IN/OUT gutter-label grid live on each section
|
||||
so the divider spans the full card width and each section scrolls alone. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
|
||||
independently so a long input never buries a short output (and vice versa). */
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
|
||||
it floats off the rounded card edge instead of hugging it (the terminal
|
||||
card's own output scroller carries the same treatment in TerminalBlock). */
|
||||
.ioSection::-webkit-scrollbar-thumb,
|
||||
.ioCardPlain::-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,
|
||||
.ioCardPlain::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Label-less variant of the card (plainBody): the text is not an IN/OUT pair,
|
||||
so it renders as one plain padded block scrolling as a whole. */
|
||||
.ioCardPlain {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 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 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
|
||||
@@ -173,15 +311,21 @@ button.leading {
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
// 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.
|
||||
// line; every row with body, output, or terminal material is a whole-row
|
||||
// expand toggle (click / Enter / Space, icon→chevron hover preview); 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 terminal
|
||||
// card's command output through TerminalBlock — 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. 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 KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
@@ -23,18 +31,25 @@ 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 row with no body, no output, and no terminal material is not
|
||||
* expandable.
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
/**
|
||||
* Render the expanded body in the card without the IN gutter label — for
|
||||
* material that is not a call's input payload (context injection).
|
||||
*/
|
||||
plainBody?: boolean | 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.
|
||||
@@ -42,6 +57,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 and context injection).
|
||||
*/
|
||||
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
|
||||
@@ -62,36 +92,31 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
output,
|
||||
errorSummary,
|
||||
terminal,
|
||||
plainBody,
|
||||
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 outputText = output ?? null
|
||||
const expandable = body !== null || outputText !== null || terminalBody !== null
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
// 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)
|
||||
}
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
toggleExpand()
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
@@ -99,46 +124,47 @@ export function ToolRow({
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Expandable rows preview the toggle on hover: the tool icon yields to a
|
||||
// down chevron (CSS swap on .row:hover); state dots still take precedence.
|
||||
// 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
|
||||
// Expandable rows preview the toggle on hover: the idle leading — the tool
|
||||
// icon OR the state dot — yields to a down chevron (CSS swap on .row:hover).
|
||||
// The state substitution happens inside the idle slot so an error row keeps
|
||||
// the hover preview instead of losing it with the icon.
|
||||
const idleLeading = leadingFor(state, icon)
|
||||
const collapsedIcon = expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={css.chevronHover} />
|
||||
<span className={css.iconIdle}>{idleLeading}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
: idleLeading
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 />
|
||||
: leadingFor(state, collapsedIcon)
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: collapsedIcon
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? toggleExpand : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
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}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.leading}
|
||||
aria-expanded={open}
|
||||
onClick={toggleFromLeading}
|
||||
>
|
||||
{leading}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.leading}>
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.leading}>
|
||||
{leading}
|
||||
</span>
|
||||
<span className={css.title}>{title}</span>
|
||||
{!open && (
|
||||
{/* An empty summary drops the separator with it (a row that is only
|
||||
its title, like the context-injection row, shows no trailing dot). */}
|
||||
{!(open && isThink) && summaryText !== '' && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{fileLink ? (
|
||||
@@ -147,25 +173,71 @@ export function ToolRow({
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
>
|
||||
{summary}
|
||||
{summaryText}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{summary}</span>
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* The terminal presenter's description belongs ABOVE the card per the
|
||||
render-intent contract, so an expanded terminal row keeps showing it
|
||||
even though the collapsed summary is hidden while open. */}
|
||||
{open && terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
{open && (
|
||||
/* The wrapper (sibling of .row, so clicks inside never toggle the
|
||||
row) carries the expanded body and the Inspect pill below it. */
|
||||
<div className={css.bodyWrap}>
|
||||
{terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={Infinity} className={css.terminalBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{plainBody === true && cardBody !== null && (
|
||||
<div className={clsx(css.ioCard, css.ioCardPlain)}>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{plainBody !== true && (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>
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,6 +428,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
|
||||
}
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||
|
||||
@@ -11,17 +11,6 @@
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -44,6 +33,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)
|
||||
@@ -192,12 +216,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
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export function ConversationSession({
|
||||
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 +54,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>
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-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). */
|
||||
@@ -153,20 +153,7 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -37,8 +37,9 @@ function answeredSummary(text: string): string | null {
|
||||
return `${answered}/${answers.length} answered`
|
||||
}
|
||||
|
||||
/** One-line question-interaction row (leading toggle expands the raw args). */
|
||||
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
|
||||
/** 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 }: ToolRowProps) {
|
||||
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
|
||||
@@ -68,7 +69,9 @@ export function AskQuestionRow({ toolName, block }: ToolRowProps) {
|
||||
title="Ask question"
|
||||
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,19 +4,23 @@
|
||||
// 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 { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
@@ -40,38 +44,84 @@ function stateStatus(state: ToolRowState): 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 }: ToolRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect }: ToolRowProps) {
|
||||
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)
|
||||
const status = stateStatus(state)
|
||||
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} 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} 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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
@@ -40,10 +40,11 @@ function summarize(argsRaw: string): 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 }: ToolRowProps) {
|
||||
/** 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 }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? model.summary
|
||||
@@ -55,7 +56,10 @@ export function TodoRow({ toolName, block }: ToolRowProps) {
|
||||
title="更新任务清单"
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user