7357070fb9
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/ Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml, their npm scripts, and every dangling reference across README, ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics endpoint itself is untouched — it's the dashboard's own route, not part of the removed monitoring stack.
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
/**
|
|
* @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.
|
|
*
|
|
* ## 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-fg-secondary hover:text-fg-primary hover:bg-surface-2">
|
|
<span className="font-semibold uppercase tracking-wide">original file</span>
|
|
<span className="text-fg-muted 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;
|
|
}
|
|
}
|