feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* @file primitives.tsx
|
||||
* @description Presentational building blocks used by the per-tool input and
|
||||
* response renderers. Each primitive is a pure component with a narrow,
|
||||
* typed contract so they can be composed freely (Terminal + TerminalOutput for
|
||||
* Bash; Terminal + UnifiedDiff for Edit; LineNumberedCode for Read/Write;
|
||||
* FileList/MatchList for Grep/Glob; KeyValueCard for MCP tools).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TerminalOutput` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `LineNumberedCode` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `DiffHunk` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `UnifiedDiff` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `KeyValueCard` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `FileList` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `GrepMatch` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `MatchList` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CopyButton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **Terminal**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TerminalOutput**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **LineNumberedCode**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **DiffHunk**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **UnifiedDiff**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **KeyValueCard**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **FileList**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **GrepMatch**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **MatchList**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
// ───────────────────────── Copy button ─────────────────────────
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
|
||||
aria-label={t("eventDetail.copy")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
{copied ? t("eventDetail.copied") : t("eventDetail.copy")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Terminal (command) ─────────────────────────
|
||||
|
||||
export function Terminal({ command, description }: { command: string; description?: string }) {
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">terminal</span>
|
||||
<CopyButton text={command} />
|
||||
</div>
|
||||
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words">
|
||||
{description && <div className="text-gray-500 mb-1"># {description}</div>}
|
||||
<div>
|
||||
<span className="text-emerald-400 select-none">$ </span>
|
||||
{command}
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Terminal output (stdout/stderr) ─────────────────────────
|
||||
|
||||
export function TerminalOutput({
|
||||
stdout,
|
||||
stderr,
|
||||
interrupted,
|
||||
exitCode,
|
||||
}: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
interrupted?: boolean;
|
||||
exitCode?: number;
|
||||
}) {
|
||||
const hasStdout = typeof stdout === "string" && stdout.length > 0;
|
||||
const hasStderr = typeof stderr === "string" && stderr.length > 0;
|
||||
const flag =
|
||||
interrupted === true
|
||||
? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" }
|
||||
: typeof exitCode === "number" && exitCode !== 0
|
||||
? {
|
||||
label: `exit ${exitCode}`,
|
||||
color: "text-red-400 border-red-500/40 bg-red-500/10",
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{hasStdout && <OutputBlock label="stdout" text={stdout!} variant="out" />}
|
||||
{hasStderr && <OutputBlock label="stderr" text={stderr!} variant="err" />}
|
||||
{flag && (
|
||||
<span
|
||||
className={`inline-block text-[10px] px-2 py-0.5 rounded border font-mono ${flag.color}`}
|
||||
>
|
||||
{flag.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputBlock({
|
||||
label,
|
||||
text,
|
||||
variant,
|
||||
}: {
|
||||
label: string;
|
||||
text: string;
|
||||
variant: "out" | "err";
|
||||
}) {
|
||||
const color = variant === "err" ? "text-red-300" : "text-gray-200";
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Line-numbered code ─────────────────────────
|
||||
|
||||
export function LineNumberedCode({
|
||||
text,
|
||||
maxHeight = "24rem",
|
||||
startLine = 1,
|
||||
label,
|
||||
}: {
|
||||
text: string;
|
||||
maxHeight?: string;
|
||||
startLine?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto" style={{ maxHeight }}>
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 text-right text-gray-600 select-none bg-black/40 border-r border-border align-top">
|
||||
{i + startLine}
|
||||
</td>
|
||||
<td className="px-3 text-gray-200 whitespace-pre-wrap break-words">{line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Unified diff ─────────────────────────
|
||||
|
||||
export type DiffHunk = {
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
oldLines: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
|
||||
if (hunks.length === 0) {
|
||||
return <p className="text-[11px] text-gray-500 italic">no diff</p>;
|
||||
}
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="overflow-auto max-h-96">
|
||||
{hunks.map((hunk, i) => (
|
||||
<HunkView key={i} hunk={hunk} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
let oldLine = hunk.oldStart;
|
||||
let newLine = hunk.newStart;
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{hunk.lines.map((line, i) => {
|
||||
const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "remove" : "ctx";
|
||||
const body = line.slice(kind === "ctx" ? 0 : 1);
|
||||
const showOld = kind !== "add";
|
||||
const showNew = kind !== "remove";
|
||||
const rowBg =
|
||||
kind === "add"
|
||||
? "bg-green-500/10 text-green-200"
|
||||
: kind === "remove"
|
||||
? "bg-red-500/10 text-red-200"
|
||||
: "text-gray-300";
|
||||
const oldCell = showOld ? oldLine++ : "";
|
||||
const newCell = showNew ? newLine++ : "";
|
||||
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
||||
return (
|
||||
<tr key={i} className={rowBg}>
|
||||
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
|
||||
{oldCell}
|
||||
</td>
|
||||
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
|
||||
{newCell}
|
||||
</td>
|
||||
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
|
||||
<td className="px-2 whitespace-pre-wrap break-words">{body}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Key-value card ─────────────────────────
|
||||
|
||||
export function KeyValueCard({
|
||||
data,
|
||||
priority = [],
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
priority?: string[];
|
||||
}) {
|
||||
const entries = Object.entries(data);
|
||||
const priorityEntries = priority
|
||||
.map((k) => [k, data[k]] as [string, unknown])
|
||||
.filter(([, v]) => v !== undefined);
|
||||
const restEntries = entries.filter(([k]) => !priority.includes(k));
|
||||
const ordered = [...priorityEntries, ...restEntries];
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return <p className="text-[11px] text-gray-500 italic">empty</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-[11px] border border-border rounded overflow-hidden">
|
||||
<tbody>
|
||||
{ordered.map(([k, v], i) => (
|
||||
<tr key={k} className={i > 0 ? "border-t border-border" : ""}>
|
||||
<td className="text-gray-500 align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
|
||||
{k}
|
||||
</td>
|
||||
<td className="text-gray-300 align-top py-1.5 px-2">
|
||||
<ValueCell value={v} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueCell({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-gray-500 italic">null</span>;
|
||||
if (typeof value === "boolean")
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
||||
value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-gray-400 border-gray-500/30 bg-gray-500/10"
|
||||
}`}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
if (typeof value === "number") return <span className="font-mono text-gray-300">{value}</span>;
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 120 || value.includes("\n")) {
|
||||
return (
|
||||
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-gray-300 break-all">{value}</span>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-500 italic">[]</span>;
|
||||
return (
|
||||
<ol className="list-decimal pl-4 space-y-1">
|
||||
{value.map((item, i) => (
|
||||
<li key={i}>
|
||||
<ValueCell value={item} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||
{safeStringify(value)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── File list / match list ─────────────────────────
|
||||
|
||||
export function FileList({ paths }: { paths: string[] }) {
|
||||
if (paths.length === 0) return <p className="text-[11px] text-gray-500 italic">no files</p>;
|
||||
return (
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
|
||||
{paths.map((p, i) => (
|
||||
<li key={i} className="px-3 py-1 font-mono text-gray-300 break-all">
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export type GrepMatch = {
|
||||
file?: string;
|
||||
line?: number;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
||||
if (matches.length === 0) return <p className="text-[11px] text-gray-500 italic">no matches</p>;
|
||||
return (
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
||||
{matches.map((m, i) => (
|
||||
<li key={i} className="px-3 py-1 text-gray-300 break-all">
|
||||
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
||||
{m.line != null && <span className="text-gray-500">:{m.line}</span>}
|
||||
{m.text && <span className="text-gray-400">: {m.text}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* @file tool-views.tsx
|
||||
* @description Per-tool renderers for `tool_input` and `tool_response` fields.
|
||||
* Dispatched from EventDetail when the event's `tool_name` is recognised.
|
||||
* Unknown tools fall back to the caller's generic JSON code view.
|
||||
*
|
||||
* Handled tools:
|
||||
* - Bash / PowerShell → Terminal + TerminalOutput
|
||||
* - Edit / NotebookEdit → Terminal + UnifiedDiff (from old/new + structuredPatch)
|
||||
* - Read → Terminal + LineNumberedCode
|
||||
* - Write → Terminal + LineNumberedCode
|
||||
* - Grep → Terminal + MatchList
|
||||
* - Glob → Terminal + FileList
|
||||
* - WebFetch → Terminal + LineNumberedCode
|
||||
* - Task / Agent → metadata + prompt
|
||||
* - mcp__* → KeyValueCard with known semantic fields promoted
|
||||
* - AskUserQuestion → formatted Q with options
|
||||
* - Any other → returns null (caller falls back to generic JSON)
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./primitives`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolInputView` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ToolResponseView` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolInputView**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ToolResponseView**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
FileList,
|
||||
KeyValueCard,
|
||||
LineNumberedCode,
|
||||
MatchList,
|
||||
Terminal,
|
||||
TerminalOutput,
|
||||
UnifiedDiff,
|
||||
} from "./primitives";
|
||||
import type { DiffHunk, GrepMatch } from "./primitives";
|
||||
|
||||
// ───────────────────────── Helpers ─────────────────────────
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function obj(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function isMcp(toolName: string): boolean {
|
||||
return toolName.startsWith("mcp__");
|
||||
}
|
||||
|
||||
/** Builds a unified-diff hunk from a bare old_string / new_string pair. One
|
||||
* hunk, minimal context - good enough for the input preview before the
|
||||
* actual structuredPatch comes back in the response. */
|
||||
function diffFromStrings(oldStr: string, newStr: string): DiffHunk[] {
|
||||
if (!oldStr && !newStr) return [];
|
||||
const oldLines = oldStr ? oldStr.split(/\r?\n/) : [];
|
||||
const newLines = newStr ? newStr.split(/\r?\n/) : [];
|
||||
const lines: string[] = [];
|
||||
for (const l of oldLines) lines.push(`-${l}`);
|
||||
for (const l of newLines) lines.push(`+${l}`);
|
||||
return [
|
||||
{
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
oldLines: oldLines.length,
|
||||
newLines: newLines.length,
|
||||
lines,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Normalises the `structuredPatch` array that shows up in Edit/NotebookEdit
|
||||
* tool_response into DiffHunk shape. Tolerates missing fields. */
|
||||
function parseStructuredPatch(value: unknown): DiffHunk[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const hunks: DiffHunk[] = [];
|
||||
for (const raw of value) {
|
||||
const r = obj(raw);
|
||||
if (!r) continue;
|
||||
const lines = Array.isArray(r.lines)
|
||||
? (r.lines.filter((l) => typeof l === "string") as string[])
|
||||
: [];
|
||||
hunks.push({
|
||||
oldStart: typeof r.oldStart === "number" ? r.oldStart : 1,
|
||||
newStart: typeof r.newStart === "number" ? r.newStart : 1,
|
||||
oldLines: typeof r.oldLines === "number" ? r.oldLines : lines.length,
|
||||
newLines: typeof r.newLines === "number" ? r.newLines : lines.length,
|
||||
lines,
|
||||
});
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Best-effort match list from a Grep tool_response. Supports the common
|
||||
* shapes: array of strings, array of {file,line,text}, or an object with
|
||||
* `matches` / `files` keys. */
|
||||
function parseGrepMatches(value: unknown): GrepMatch[] {
|
||||
if (Array.isArray(value)) return value.map(toMatch).filter(Boolean) as GrepMatch[];
|
||||
const o = obj(value);
|
||||
if (!o) return [];
|
||||
if (Array.isArray(o.matches)) return o.matches.map(toMatch).filter(Boolean) as GrepMatch[];
|
||||
if (Array.isArray(o.files))
|
||||
return (o.files as unknown[])
|
||||
.map((f) => (typeof f === "string" ? ({ file: f } as GrepMatch) : null))
|
||||
.filter(Boolean) as GrepMatch[];
|
||||
return [];
|
||||
}
|
||||
|
||||
function toMatch(raw: unknown): GrepMatch | null {
|
||||
if (typeof raw === "string") {
|
||||
const m = raw.match(/^(.+?):(\d+):(.*)$/);
|
||||
if (m) return { file: m[1], line: Number(m[2]), text: m[3] };
|
||||
return { text: raw };
|
||||
}
|
||||
const o = obj(raw);
|
||||
if (!o) return null;
|
||||
const match: GrepMatch = {};
|
||||
if (typeof o.file === "string") match.file = o.file;
|
||||
if (typeof o.path === "string" && !match.file) match.file = o.path;
|
||||
if (typeof o.line === "number") match.line = o.line;
|
||||
if (typeof o.line_number === "number" && match.line == null) match.line = o.line_number;
|
||||
if (typeof o.text === "string") match.text = o.text;
|
||||
if (typeof o.match === "string" && !match.text) match.text = o.match;
|
||||
if (typeof o.content === "string" && !match.text) match.text = o.content;
|
||||
return match;
|
||||
}
|
||||
|
||||
function parseFileList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
|
||||
const o = obj(value);
|
||||
if (!o) return [];
|
||||
if (Array.isArray(o.files))
|
||||
return (o.files as unknown[]).filter((v): v is string => typeof v === "string");
|
||||
if (Array.isArray(o.paths))
|
||||
return (o.paths as unknown[]).filter((v): v is string => typeof v === "string");
|
||||
return [];
|
||||
}
|
||||
|
||||
// ───────────────────────── Top-level dispatchers ─────────────────────────
|
||||
|
||||
/** Returns a rendered view for the tool's input, or null when the tool isn't
|
||||
* specifically handled (caller renders JSON fallback). */
|
||||
export function ToolInputView({
|
||||
toolName,
|
||||
input,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
input: unknown;
|
||||
}): React.ReactNode | null {
|
||||
if (!toolName) return null;
|
||||
const i = obj(input);
|
||||
if (!i) return null;
|
||||
|
||||
// MCP tools - show the input as a key-value card with URL/query/id promoted.
|
||||
if (isMcp(toolName)) {
|
||||
return (
|
||||
<KeyValueCard
|
||||
data={i}
|
||||
priority={[
|
||||
"url",
|
||||
"query",
|
||||
"q",
|
||||
"title",
|
||||
"name",
|
||||
"id",
|
||||
"page_id",
|
||||
"task_id",
|
||||
"merge_request_iid",
|
||||
"issue_iid",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
const cmd = str(i.command);
|
||||
const desc = str(i.description);
|
||||
if (!cmd) return null;
|
||||
return <Terminal command={cmd} description={desc || undefined} />;
|
||||
}
|
||||
case "Read": {
|
||||
const path = str(i.file_path);
|
||||
if (!path) return null;
|
||||
const flags: string[] = [];
|
||||
if (i.offset != null) flags.push(`--offset=${i.offset}`);
|
||||
if (i.limit != null) flags.push(`--limit=${i.limit}`);
|
||||
return <Terminal command={`read ${path}${flags.length ? " " + flags.join(" ") : ""}`} />;
|
||||
}
|
||||
case "Write": {
|
||||
const path = str(i.file_path);
|
||||
const content = str(i.content);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{path && <Terminal command={`write ${path}`} />}
|
||||
{content && <LineNumberedCode text={content} label="content" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Edit":
|
||||
case "NotebookEdit": {
|
||||
const path = str(i.file_path);
|
||||
const oldStr = str(i.old_string);
|
||||
const newStr = str(i.new_string);
|
||||
const hunks = diffFromStrings(oldStr, newStr);
|
||||
const replaceAll = i.replace_all === true ? " --replace-all" : "";
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{path && <Terminal command={`edit ${path}${replaceAll}`} />}
|
||||
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Grep": {
|
||||
const pattern = str(i.pattern);
|
||||
const path = str(i.path);
|
||||
const flags = [
|
||||
i.glob ? `--glob=${str(i.glob)}` : null,
|
||||
i.type ? `--type=${str(i.type)}` : null,
|
||||
i.output_mode ? `--mode=${str(i.output_mode)}` : null,
|
||||
i["-i"] ? "-i" : null,
|
||||
i["-n"] ? "-n" : null,
|
||||
].filter(Boolean) as string[];
|
||||
const cmd = `grep "${pattern}"${path ? " " + path : ""}${flags.length ? " " + flags.join(" ") : ""}`;
|
||||
return <Terminal command={cmd} />;
|
||||
}
|
||||
case "Glob": {
|
||||
const pattern = str(i.pattern);
|
||||
const path = str(i.path);
|
||||
return <Terminal command={`glob "${pattern}"${path ? " " + path : ""}`} />;
|
||||
}
|
||||
case "WebFetch": {
|
||||
const url = str(i.url);
|
||||
const prompt = str(i.prompt);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{url && <Terminal command={`fetch ${url}`} description={prompt || undefined} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Task":
|
||||
case "Agent": {
|
||||
const desc = str(i.description);
|
||||
const subtype = str(i.subagent_type);
|
||||
const prompt = str(i.prompt);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<KeyValueCard
|
||||
data={{
|
||||
...(desc ? { description: desc } : {}),
|
||||
...(subtype ? { subagent_type: subtype } : {}),
|
||||
}}
|
||||
/>
|
||||
{prompt && <LineNumberedCode text={prompt} label="prompt" maxHeight="16rem" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "TaskCreate":
|
||||
case "TaskUpdate":
|
||||
case "TaskGet":
|
||||
case "TaskStop":
|
||||
case "TaskOutput":
|
||||
case "TaskList":
|
||||
return <KeyValueCard data={i} priority={["description", "id", "status", "activeForm"]} />;
|
||||
case "AskUserQuestion": {
|
||||
const questions = Array.isArray(i.questions) ? i.questions : null;
|
||||
if (!questions) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{questions.map((q, idx) => {
|
||||
const qo = obj(q);
|
||||
if (!qo) return null;
|
||||
return (
|
||||
<KeyValueCard
|
||||
key={idx}
|
||||
data={qo}
|
||||
priority={["question", "options", "multiSelect", "header"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a rendered view for the tool's response, or null when the tool
|
||||
* isn't specifically handled (caller renders JSON fallback). */
|
||||
export function ToolResponseView({
|
||||
toolName,
|
||||
response,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
response: unknown;
|
||||
}): React.ReactNode | null {
|
||||
if (!toolName) return null;
|
||||
|
||||
if (isMcp(toolName)) {
|
||||
const r = obj(response);
|
||||
if (r)
|
||||
return (
|
||||
<KeyValueCard
|
||||
data={r}
|
||||
priority={[
|
||||
"title",
|
||||
"name",
|
||||
"state",
|
||||
"status",
|
||||
"url",
|
||||
"id",
|
||||
"iid",
|
||||
"author",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
// Non-object responses (string, array) fall through to generic.
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
const r = obj(response);
|
||||
if (!r) return null;
|
||||
return (
|
||||
<TerminalOutput
|
||||
stdout={typeof r.stdout === "string" ? r.stdout : undefined}
|
||||
stderr={typeof r.stderr === "string" ? r.stderr : undefined}
|
||||
interrupted={r.interrupted === true}
|
||||
exitCode={typeof r.exitCode === "number" ? r.exitCode : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "Edit":
|
||||
case "NotebookEdit": {
|
||||
const r = obj(response);
|
||||
if (!r) return null;
|
||||
const hunks = parseStructuredPatch(r.structuredPatch);
|
||||
const originalFile = typeof r.originalFile === "string" ? r.originalFile : "";
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
|
||||
{originalFile && (
|
||||
<details className="bg-surface-2/40 border border-border rounded overflow-hidden">
|
||||
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2">
|
||||
<span className="font-semibold uppercase tracking-wide">original file</span>
|
||||
<span className="text-gray-500 font-normal ml-2">
|
||||
({originalFile.split(/\r?\n/).length} lines)
|
||||
</span>
|
||||
</summary>
|
||||
<div className="p-2">
|
||||
<LineNumberedCode text={originalFile} maxHeight="24rem" />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Read": {
|
||||
if (typeof response === "string") return <LineNumberedCode text={response} />;
|
||||
const r = obj(response);
|
||||
if (r && typeof r.content === "string") return <LineNumberedCode text={r.content} />;
|
||||
return null;
|
||||
}
|
||||
case "Write": {
|
||||
const r = obj(response);
|
||||
if (!r) return null;
|
||||
return <KeyValueCard data={r} priority={["filePath", "type", "bytes"]} />;
|
||||
}
|
||||
case "Grep": {
|
||||
const matches = parseGrepMatches(response);
|
||||
if (matches.length === 0) return null;
|
||||
return <MatchList matches={matches} />;
|
||||
}
|
||||
case "Glob": {
|
||||
const files = parseFileList(response);
|
||||
if (files.length === 0) return null;
|
||||
return <FileList paths={files} />;
|
||||
}
|
||||
case "WebFetch": {
|
||||
if (typeof response === "string") return <LineNumberedCode text={response} />;
|
||||
const r = obj(response);
|
||||
if (r && typeof r.content === "string") return <LineNumberedCode text={r.content} />;
|
||||
if (r) return <KeyValueCard data={r} priority={["url", "title", "status", "content"]} />;
|
||||
return null;
|
||||
}
|
||||
case "Task":
|
||||
case "Agent": {
|
||||
if (typeof response === "string") return <LineNumberedCode text={response} label="output" />;
|
||||
const r = obj(response);
|
||||
if (r) return <KeyValueCard data={r} />;
|
||||
return null;
|
||||
}
|
||||
case "TaskCreate":
|
||||
case "TaskUpdate":
|
||||
case "TaskGet":
|
||||
case "TaskStop":
|
||||
case "TaskOutput":
|
||||
case "TaskList": {
|
||||
const r = obj(response);
|
||||
if (r)
|
||||
return <KeyValueCard data={r} priority={["description", "status", "id", "activeForm"]} />;
|
||||
return null;
|
||||
}
|
||||
case "AskUserQuestion": {
|
||||
const r = obj(response);
|
||||
if (r) return <KeyValueCard data={r} priority={["answer", "option", "question"]} />;
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user