174 lines
7.7 KiB
TypeScript
174 lines
7.7 KiB
TypeScript
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
|
// result — args as JSON, the result raw except for a terminal-card call, whose
|
|
// Output section is the command's terminal card. The three-段 Switch /
|
|
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
|
// selection from the shared chat
|
|
// store (conversation writes, this panel reads — the cross-registration
|
|
// share the store seat exists for) and derives the call material from the
|
|
// session snapshot — no data of its own.
|
|
|
|
import { CodeBlock, TerminalBlock } 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 { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
|
import 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). */
|
|
export type DetailsPanelProps = DetailsSlotProps
|
|
|
|
/**
|
|
* Selected call material: the call's display name and args plus the frozen
|
|
* block slice it came from. `block` is a snapshot-cached reference, so the
|
|
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
|
|
* running split is read off it with the `'kind' in block` discrimination
|
|
* instead of duplicated as flags.
|
|
*/
|
|
interface CallMaterial {
|
|
name: string
|
|
argsRaw: string | null
|
|
block: ToolCallBlock
|
|
}
|
|
|
|
/** Material of a settled result node (native call or run_code sub-dispatch). */
|
|
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
|
|
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
|
|
}
|
|
|
|
/** Material of an in-flight call (native call or run_code sub-dispatch). */
|
|
function runningMaterial(call: RunningToolCall): CallMaterial {
|
|
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
|
}
|
|
|
|
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
|
for (const node of s.nodes) {
|
|
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
|
}
|
|
const open = s.runningCalls.find(c => c.callId === callId)
|
|
if (open !== undefined) return runningMaterial(open)
|
|
// run_code sub-dispatches: the native call-block shapes, so a selected
|
|
// sub-row resolves through the same material as a native call — the
|
|
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
|
for (const subs of s.codeDispatches.values()) {
|
|
for (const sub of subs) {
|
|
if (sub.callId !== callId) continue
|
|
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function pretty(raw: string): string {
|
|
try {
|
|
return JSON.stringify(JSON.parse(raw), null, 2)
|
|
} catch {
|
|
// Not JSON (streaming fragment or plain text): show verbatim.
|
|
return raw
|
|
}
|
|
}
|
|
|
|
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
|
|
const selection = useStore(s => s.selection)
|
|
// Session workspace root: an omitted or relative terminal cwd resolves
|
|
// against it, which the pure presenter cannot see.
|
|
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
|
|
const callId = selection?.callId
|
|
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
|
// stable members (result node reference rides the snapshot's structural sharing).
|
|
const material = useSession(
|
|
s => (callId === undefined ? null : materialFor(s, callId)),
|
|
(a, b) => shallowEqual(a, b))
|
|
|
|
return (
|
|
<div className={css.root}>
|
|
<div className={css.header}>
|
|
<div className={css.title}>
|
|
{selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')}
|
|
</div>
|
|
<button
|
|
type="button" className={css.close} aria-label={t('details.close')}
|
|
onClick={() => { closeDetails() }}
|
|
>
|
|
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
|
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className={css.body}>
|
|
{selection === null || callId === undefined
|
|
? <div className={css.empty}>{t('details.empty')}</div>
|
|
: material === null
|
|
? <div className={css.empty}>{t('details.notInWindow')}</div>
|
|
: (
|
|
<>
|
|
{material.argsRaw !== null && (
|
|
<section className={css.section}>
|
|
<div className={css.sectionLabel}>{t('details.input')}</div>
|
|
<CodeBlock code={pretty(material.argsRaw)} lang="json" copyLabel={t('copy')} copiedLabel={t('copied')} />
|
|
</section>
|
|
)}
|
|
<section className={css.section}>
|
|
<div className={css.sectionLabel}>{t('details.output')}</div>
|
|
{/* Keyed by the selected call: the body owns per-call view
|
|
state (the terminal card's expand and copy), which React
|
|
would otherwise carry into the next selection because the
|
|
panel does not unmount between calls. */}
|
|
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The Output section's body for the selected call. A terminal-card call — a
|
|
* shell command's call/result views — renders through the shared TerminalBlock
|
|
* at the primitive's own full height allowance, so column-aligned output keeps
|
|
* its alignment and scrolls sideways instead of folding. Every other call, and
|
|
* a running call with no terminal card yet, keeps the flattened text form.
|
|
* @param props.material - the selected call's material from {@link materialFor}.
|
|
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
|
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
|
* @returns the Output section's body element.
|
|
*/
|
|
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
|
|
const terminal = terminalCardModel(material.block, cwd)
|
|
if (terminal !== null) {
|
|
// The contract renders the presenter's description above the card, and the
|
|
// panel has no summary row to carry it, so it is drawn here.
|
|
return (
|
|
<>
|
|
{terminal.description !== undefined && (
|
|
<div className={css.terminalDescription}>{terminal.description}</div>
|
|
)}
|
|
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
|
|
</>
|
|
)
|
|
}
|
|
// A settled call always carries the result node the flattened form needs;
|
|
// the running shape has no result to flatten.
|
|
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
|
|
const result = material.block
|
|
return (
|
|
<pre className={css.code} data-error={result.isError || undefined}>
|
|
{renderResult(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')
|
|
}
|