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,851 @@
|
||||
/**
|
||||
* @file event-grouping.ts
|
||||
* @description Client-side helpers for rendering a flat stream of
|
||||
* `DashboardEvent` rows: a per-event status tag (`statusFromEventType`), a
|
||||
* smart human-readable title (`buildEventTitle`), and agent/origin labels for
|
||||
* the muted "{project} › {session} › {agent}" prefix. (The historical
|
||||
* tool-call grouping view was removed; the timeline now renders flat only.)
|
||||
*
|
||||
* ## Event shape
|
||||
* Every helper here operates on a {@link DashboardEvent}. The fields that
|
||||
* matter for titling and attribution are:
|
||||
* - `event_type` — the hook lifecycle name ("PreToolUse", "PostToolUse",
|
||||
* "Stop", "SubagentStop", "Compaction", "Notification", "SessionStart",
|
||||
* "SessionEnd", "TurnDuration", "APIError", …). Drives
|
||||
* {@link statusFromEventType}.
|
||||
* - `tool_name` — set only on tool events (e.g. "Bash", "Edit", "Read", or an
|
||||
* MCP name like "mcp__github__create_issue"). Absent for lifecycle events, in
|
||||
* which case {@link buildEventTitle} falls back to `summary`.
|
||||
* - `summary` — an optional server-provided one-liner used as a fallback.
|
||||
* - `data` — a JSON *string* holding the raw hook payload. When parsed it
|
||||
* typically exposes `tool_input` (the arguments passed to the tool) and `cwd`
|
||||
* (the working directory, used to derive the project label).
|
||||
* - `agent_id` — identifies which agent (main or subagent) emitted the event;
|
||||
* drives the {@link shortAgentLabel} / {@link agentOriginLabel} labels.
|
||||
*
|
||||
* ## Design philosophy
|
||||
* Titles are produced *algorithmically* — there is deliberately no per-tool or
|
||||
* per-MCP-server lookup table to maintain. New tools and MCP servers therefore
|
||||
* render sensibly on day one: MCP names are decoded from their namespaced
|
||||
* `mcp__<server>__<tool>` structure, and unknown native tools fall back to the
|
||||
* first short string found in their payload. Every parser is defensive — bad
|
||||
* JSON, missing fields, and unexpected types degrade to a plain label instead of
|
||||
* throwing, because this code runs on live hook data of varying vintage.
|
||||
*
|
||||
* @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
|
||||
* - `./types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `statusFromEventType` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `buildEventTitle` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `shortAgentLabel` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AgentInfo` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `agentPillLabel` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `agentOriginLabel` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `buildOriginLabel` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `projectFromCwd` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `projectFromEvent` — 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **statusFromEventType**
|
||||
* 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.
|
||||
*
|
||||
* **buildEventTitle**
|
||||
* 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.
|
||||
*
|
||||
* **shortAgentLabel**
|
||||
* 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.
|
||||
*
|
||||
* **AgentInfo**
|
||||
* 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.
|
||||
*
|
||||
* **agentPillLabel**
|
||||
* 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.
|
||||
*
|
||||
* **agentOriginLabel**
|
||||
* 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.
|
||||
*
|
||||
* **buildOriginLabel**
|
||||
* 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.
|
||||
*
|
||||
* **projectFromCwd**
|
||||
* 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.
|
||||
*
|
||||
* **projectFromEvent**
|
||||
* 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 type { DashboardEvent } from "./types";
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Status mapping
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Best-effort status tag per event_type - drives the status badge shown on
|
||||
* each row in the ActivityFeed / SessionDetail event streams.
|
||||
* @param type A `DashboardEvent.event_type` value (e.g. "PreToolUse", "Stop").
|
||||
* @returns The badge status; unrecognized types default to "waiting" rather
|
||||
* than throwing, since new hook event types should degrade gracefully. */
|
||||
export function statusFromEventType(type: string): "working" | "waiting" | "completed" | "error" {
|
||||
switch (type) {
|
||||
// A tool is about to run: the agent is actively doing work.
|
||||
case "PreToolUse":
|
||||
return "working";
|
||||
// The tool finished, or the turn stopped: the agent is idle / awaiting the
|
||||
// next step. "waiting" (not "completed") because more activity usually
|
||||
// follows a PostToolUse within the same turn.
|
||||
case "PostToolUse":
|
||||
case "Stop":
|
||||
return "waiting";
|
||||
// Terminal-ish milestones: a subagent handed control back, or the transcript
|
||||
// was compacted. Both read as a finished unit of work.
|
||||
case "SubagentStop":
|
||||
case "Compaction":
|
||||
return "completed";
|
||||
// Explicit failure signals surface as the red "error" badge.
|
||||
case "error":
|
||||
case "APIError":
|
||||
return "error";
|
||||
// Unknown / newer event types shouldn't blow up the badge — treat them as a
|
||||
// neutral "waiting" so future hook additions render gracefully.
|
||||
default:
|
||||
return "waiting";
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Dynamic humanizers (no per-tool static tables)
|
||||
//
|
||||
// These small string helpers turn machine-shaped identifiers — MCP server slugs,
|
||||
// snake_case tool names, shell commands, file paths, URLs — into short
|
||||
// human-readable fragments. None of them carry a hard-coded catalogue of known
|
||||
// tools; they rely purely on the *structure* of the input, so a brand-new MCP
|
||||
// server or CLI renders acceptably without a code change.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Purely algorithmic: split on _/-, dedupe consecutive tokens, take last,
|
||||
* capitalize-first if all lowercase. Handles any MCP server slug.
|
||||
*
|
||||
* The goal is a compact, recognizable server name. The *last* meaningful token
|
||||
* is usually the brand (e.g. "claude_ai_Slack" → "Slack"), and consecutive
|
||||
* duplicate tokens (from slugs like "github_github") are collapsed so they
|
||||
* don't read twice. Capitalization is only forced when the token is entirely
|
||||
* lowercase, preserving already-cased brands like "GitLab" or "PagerDuty".
|
||||
* @param raw The raw server slug (the `<server>` piece of an MCP tool name).
|
||||
* @returns A short, display-ready server label.
|
||||
* @example humanizeMcpServer("claude_ai_Slack") // "Slack"
|
||||
* @example humanizeMcpServer("github") // "Github"
|
||||
* @example humanizeMcpServer("atlassian") // "Atlassian"
|
||||
*/
|
||||
function humanizeMcpServer(raw: string): string {
|
||||
// Split on underscores/hyphens into candidate tokens, dropping empties.
|
||||
const tokens = raw.split(/[_-]+/).filter(Boolean);
|
||||
// Collapse *consecutive* duplicate tokens ("github_github" → ["github"]).
|
||||
const dedup: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (dedup[dedup.length - 1] !== t) dedup.push(t);
|
||||
}
|
||||
// The trailing token is the most brand-identifying part; fall back to the
|
||||
// untouched input if the slug somehow had no usable tokens.
|
||||
const last = dedup[dedup.length - 1] ?? raw;
|
||||
// Only capitalize purely-lowercase tokens so existing mixed-case brands
|
||||
// (GitLab, PagerDuty) are left untouched.
|
||||
return last.toLowerCase() === last ? last.charAt(0).toUpperCase() + last.slice(1) : last;
|
||||
}
|
||||
|
||||
/** snake_case → lowercase words with spaces (e.g. "get_merge_request" → "get merge request").
|
||||
*
|
||||
* Runs of underscores collapse to a single space, surrounding whitespace is
|
||||
* trimmed, and the result is lowercased so tool actions read as a short verb
|
||||
* phrase in the title (e.g. "Github · create pull request").
|
||||
* @param raw The `<tool>` portion of an MCP tool name (may contain several
|
||||
* `_`-joined words).
|
||||
* @returns The spaced, lowercased action phrase.
|
||||
*/
|
||||
function humanizeMcpTool(raw: string): string {
|
||||
return raw.replace(/_+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Splits an `mcp__<server>__<tool...>` tool name into its humanized server
|
||||
* and tool parts. Returns null for anything that isn't a well-formed MCP
|
||||
* tool name (no `mcp__` prefix, or fewer than 3 `__`-separated segments).
|
||||
*
|
||||
* MCP tool names follow the convention `mcp__<server>__<tool>` where `<tool>`
|
||||
* may itself contain `__` if the underlying action name had underscores. The
|
||||
* first segment ("mcp") is the marker, the second is the server slug, and
|
||||
* everything after is re-joined as the action before being humanized.
|
||||
* @param tool A raw `tool_name`, e.g. "mcp__github__create_pull_request".
|
||||
* @returns `{ server, tool }` with both pieces humanized, or null when `tool` is
|
||||
* not a namespaced MCP name.
|
||||
* @example
|
||||
* parseMcpToolName("mcp__github__create_pull_request")
|
||||
* // → { server: "Github", tool: "create pull request" }
|
||||
* parseMcpToolName("Bash") // → null (native tool, no mcp__ prefix)
|
||||
*/
|
||||
function parseMcpToolName(tool: string): { server: string; tool: string } | null {
|
||||
if (!tool.startsWith("mcp__")) return null;
|
||||
// `filter(Boolean)` drops the empty strings produced by the doubled
|
||||
// underscores, leaving ["mcp", <server>, ...<toolWords>].
|
||||
const parts = tool.split("__").filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const rawServer = parts[1];
|
||||
const rest = parts.slice(2);
|
||||
// Guard against malformed names like "mcp____foo" that survive the length
|
||||
// check but leave no server or no tool segment.
|
||||
if (!rawServer || rest.length === 0) return null;
|
||||
return {
|
||||
server: humanizeMcpServer(rawServer),
|
||||
// Re-join with "_" so humanizeMcpTool can re-split uniformly, restoring the
|
||||
// original multi-word action (parts were split on "__", not "_").
|
||||
tool: humanizeMcpTool(rest.join("_")),
|
||||
};
|
||||
}
|
||||
|
||||
/** First short string found in tool_input using a generic priority list, then
|
||||
* falling back to any other short string. Applies to both MCP and native
|
||||
* tools - no tool-specific knowledge baked in.
|
||||
*
|
||||
* Order matters: fields are tried top-to-bottom and the first non-empty string
|
||||
* wins, so the list is sorted from *most* human-meaningful ("description",
|
||||
* "title") down to more incidental identifiers ("id", "command"). This lets a
|
||||
* single lookup produce a good headline across wildly different tool payloads
|
||||
* without knowing which tool produced them. */
|
||||
const CONTEXT_FIELDS = [
|
||||
"description", // human-authored summary — best possible headline
|
||||
"title", // e.g. an issue / PR title
|
||||
"name", // a resource or entity name
|
||||
"query", // search-style tools
|
||||
"q", // short alias some tools use for a query
|
||||
"pattern", // grep / glob style matchers
|
||||
"url", // web-oriented tools
|
||||
"file_path", // file-oriented tools (absolute or relative)
|
||||
"path", // directory / file path variant
|
||||
"id", // last-resort identifier
|
||||
"command", // shell command text
|
||||
];
|
||||
|
||||
/** Implements the {@link CONTEXT_FIELDS} lookup described above: returns the
|
||||
* first matching field's string value, or (failing that) the first short
|
||||
* (<120 char) string value found anywhere in `input`. Null if none qualify.
|
||||
* @param input A parsed `tool_input` object (arbitrary tool arguments).
|
||||
* @returns The chosen headline string, or null when nothing suitable is found.
|
||||
* @example buildContextHeadline({ query: "auth bug", limit: 20 }) // "auth bug"
|
||||
*/
|
||||
function buildContextHeadline(input: Record<string, unknown>): string | null {
|
||||
// Preferred pass: honor the priority order in CONTEXT_FIELDS. Any non-empty
|
||||
// string wins here regardless of length — a named field is intentional.
|
||||
for (const field of CONTEXT_FIELDS) {
|
||||
const v = input[field];
|
||||
if (typeof v === "string" && v.length > 0) return v;
|
||||
}
|
||||
// Fallback pass: no known field matched, so scan every value and accept the
|
||||
// first *short* string. The <120 guard avoids surfacing a giant blob (e.g. a
|
||||
// file's contents) as the headline.
|
||||
for (const v of Object.values(input)) {
|
||||
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Shell command parsing
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Parses a Bash/PowerShell command string into "<binary> <subcommand>" when
|
||||
* the binary is something with common subcommands (git, npm, docker, etc.).
|
||||
* For curl/wget we surface the host. Falls back to the bare binary name.
|
||||
*
|
||||
* The set below is the allow-list of binaries whose *first argument* is a
|
||||
* meaningful subcommand worth showing ("git commit", "npm install", "docker
|
||||
* build"). For anything not in the set, showing a lone argument would be noise,
|
||||
* so {@link parseShellHeadline} keeps just the binary name. */
|
||||
const SUBCOMMAND_BINARIES = new Set([
|
||||
"git",
|
||||
"npm",
|
||||
"pnpm",
|
||||
"yarn",
|
||||
"bun",
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"just",
|
||||
"make",
|
||||
"cargo",
|
||||
"python",
|
||||
"pip",
|
||||
"poetry",
|
||||
"uv",
|
||||
"node",
|
||||
"npx",
|
||||
"kubectl",
|
||||
"terraform",
|
||||
"helm",
|
||||
"aws",
|
||||
"gcloud",
|
||||
"az",
|
||||
]);
|
||||
|
||||
/** Extracts a compact headline from a raw shell command string.
|
||||
* @param command The full command line (e.g. "git commit -m 'wip' && npm test").
|
||||
* @returns "<binary> <subcommand>" for known multi-command binaries, "<curl|wget>
|
||||
* <host>" for downloads, the bare binary otherwise, or null for an empty
|
||||
* command.
|
||||
* @example parseShellHeadline("git commit -m x") // "git commit"
|
||||
* @example parseShellHeadline("docker compose up -d") // "docker compose up"
|
||||
* @example parseShellHeadline("curl https://api.x/y") // "curl api.x"
|
||||
* @example parseShellHeadline("./run.sh --fast") // "run.sh"
|
||||
*/
|
||||
function parseShellHeadline(command: string): string | null {
|
||||
const cmd = command.trim();
|
||||
if (!cmd) return null;
|
||||
|
||||
// Special case: "docker compose <sub>" (two-word binary)
|
||||
const compose = cmd.match(/^docker\s+compose\s+([A-Za-z0-9_-]+)/);
|
||||
if (compose) return `docker compose ${compose[1]}`;
|
||||
|
||||
// Capture group 1 = the binary (path chars allowed so "./x", "/usr/bin/git"
|
||||
// and "C:\\tool.exe" all match); optional group 2 = the first bare argument.
|
||||
const match = cmd.match(/^([A-Za-z0-9_.\-/\\]+)(?:\s+([A-Za-z0-9_-]+))?/);
|
||||
if (!match) return null;
|
||||
const binPath = match[1] ?? "";
|
||||
// Reduce any path to just the executable name so "/usr/local/bin/git" → "git".
|
||||
const bin = binPath.split(/[/\\]/).pop() || binPath;
|
||||
const sub = match[2];
|
||||
|
||||
// Only show the subcommand for binaries where it's genuinely informative.
|
||||
if (SUBCOMMAND_BINARIES.has(bin) && sub) return `${bin} ${sub}`;
|
||||
|
||||
// Downloads: the destination host is far more useful than a "-fsSL" flag, so
|
||||
// pull the first http(s) URL out of the command and show its host.
|
||||
if (bin === "curl" || bin === "wget") {
|
||||
const urlMatch = cmd.match(/https?:\/\/[^\s"']+/);
|
||||
if (urlMatch) {
|
||||
try {
|
||||
return `${bin} ${new URL(urlMatch[0]).host}`;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
// Everything else: just the binary name (a lone arg would usually be noise).
|
||||
return bin;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Path and URL helpers
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Last path segment (POSIX or Windows separators). Returns `path` unchanged
|
||||
* if it has no separators.
|
||||
* @param path An absolute or relative path using "/" and/or "\" separators.
|
||||
* @returns The final segment (file or directory name).
|
||||
* @example basename("/a/b/c.ts") // "c.ts"
|
||||
* @example basename("solo") // "solo"
|
||||
*/
|
||||
function basename(path: string): string {
|
||||
// Split on either separator and drop empties so trailing slashes don't yield
|
||||
// an empty final element.
|
||||
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||
return parts.length > 0 ? (parts[parts.length - 1] ?? path) : path;
|
||||
}
|
||||
|
||||
/** Compact path label - last 2 segments (e.g. "tasks/base.py" for a long
|
||||
* absolute path ending in tasks/base.py), so the user sees the immediate
|
||||
* parent directory in addition to the filename. Falls back to basename for
|
||||
* single-segment paths.
|
||||
*
|
||||
* Two segments strike a balance: the filename alone can be ambiguous (many
|
||||
* "index.ts"), while the full absolute path is too long for a one-line title.
|
||||
* @param path An absolute or relative path.
|
||||
* @returns The last two segments joined with "/", or the sole segment.
|
||||
* @example shortPath("/repo/client/src/lib/types.ts") // "lib/types.ts"
|
||||
* @example shortPath("README.md") // "README.md"
|
||||
*/
|
||||
function shortPath(path: string): string {
|
||||
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||
// 0 or 1 segments: nothing to shorten — return what we have.
|
||||
if (parts.length <= 1) return parts[0] ?? path;
|
||||
// Always normalize the joiner to "/" even for Windows-style inputs.
|
||||
return parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
/** Extracts the host from a URL string (e.g. WebFetch's target), falling back
|
||||
* to the raw string if it doesn't parse as a URL.
|
||||
* @param url A URL string; may be malformed.
|
||||
* @returns The host (e.g. "api.github.com"), or the original string on parse
|
||||
* failure so the caller still shows *something*.
|
||||
* @example hostFromUrl("https://api.github.com/repos") // "api.github.com"
|
||||
* @example hostFromUrl("not a url") // "not a url"
|
||||
*/
|
||||
function hostFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
// `new URL` throws on relative / garbage input — degrade to the raw string.
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Event title builder
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Parses `event.data` and pulls out its `tool_input` object, if any. Returns
|
||||
* null when there's no data, it isn't valid JSON, or `tool_input` isn't a
|
||||
* plain object (e.g. absent, or an array).
|
||||
*
|
||||
* `event.data` is stored as a JSON string, so it must be parsed at read time.
|
||||
* A representative payload looks like:
|
||||
* `{"tool_input":{"file_path":"/a/b.ts"},"cwd":"/a"}`.
|
||||
* @param event The event whose payload should be inspected.
|
||||
* @returns The `tool_input` record, or null when it's missing / invalid.
|
||||
*/
|
||||
function extractToolInput(event: DashboardEvent): Record<string, unknown> | null {
|
||||
if (!event.data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
// Only read `tool_input` when the payload itself is a truthy object.
|
||||
const maybeInput = parsed && typeof parsed === "object" ? parsed.tool_input : null;
|
||||
// Require a *plain* object — arrays and primitives aren't valid inputs and
|
||||
// would break the field lookups downstream.
|
||||
if (maybeInput && typeof maybeInput === "object" && !Array.isArray(maybeInput)) {
|
||||
return maybeInput as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
/* ignore — malformed JSON simply yields no input */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a short, descriptive title for an event. Parses `tool_input` and
|
||||
* dispatches per-tool to surface what actually happened (e.g. "Bash · git
|
||||
* commit", "GitLab · get merge request · !174", "Edit SessionDetail.tsx"),
|
||||
* instead of the generic "Using tool: X" summary. MCP tools are rendered
|
||||
* dynamically from their namespaced name - no per-server static mapping.
|
||||
* @param event The event to title. Non-tool events fall back to `summary`
|
||||
* (or `event_type` if there's no summary either).
|
||||
* @returns A one-line title, never empty. */
|
||||
export function buildEventTitle(event: DashboardEvent): string {
|
||||
// Lifecycle (non-tool) events have no tool_name — use the server summary, or
|
||||
// the raw event_type as a last resort. Never returns empty.
|
||||
if (!event.tool_name) return event.summary || event.event_type;
|
||||
|
||||
const input = extractToolInput(event);
|
||||
// Local coercion helper: read a field as a string, or "" if it's absent or a
|
||||
// non-string. Keeps the per-tool branches below terse.
|
||||
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
// Local truncator: clamp long values (commands, descriptions) so a single
|
||||
// title never blows out the row. Default cap is 80 chars.
|
||||
const trunc = (text: string, max = 80): string =>
|
||||
text.length > max ? text.slice(0, max) + "..." : text;
|
||||
|
||||
// ── MCP tools - fully dynamic dispatch ─────────────────────────────
|
||||
// Any "mcp__server__tool" name is decoded structurally (no per-server table).
|
||||
// When the payload yields a context headline we append it: "Github · create
|
||||
// pull request · Fix flaky test".
|
||||
const mcp = parseMcpToolName(event.tool_name);
|
||||
if (mcp) {
|
||||
const ctx = input ? buildContextHeadline(input) : null;
|
||||
return ctx ? `${mcp.server} · ${mcp.tool} · ${trunc(ctx)}` : `${mcp.server} · ${mcp.tool}`;
|
||||
}
|
||||
|
||||
// No parseable input (missing / invalid data): fall back to the tool name plus
|
||||
// any server summary. The native per-tool logic below all needs `input`.
|
||||
if (!input) return `${event.tool_name}${event.summary ? `: ${event.summary}` : ""}`;
|
||||
|
||||
// ── Native tools - per-tool smart titles ───────────────────────────
|
||||
// Each case surfaces the single most useful fact from that tool's arguments.
|
||||
// A `break` (rather than return) falls through to the generic tail at the
|
||||
// bottom, used when the expected field was absent.
|
||||
switch (event.tool_name) {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
// Prefer "<tool> · <bin sub> - <description>"; degrade gracefully as
|
||||
// fields drop out (headline only, description only, then raw command).
|
||||
const desc = s(input.description);
|
||||
const cmd = s(input.command);
|
||||
const headline = parseShellHeadline(cmd);
|
||||
if (headline && desc) return `${event.tool_name} · ${headline} - ${trunc(desc, 60)}`;
|
||||
if (headline) return `${event.tool_name} · ${headline}`;
|
||||
if (desc) return `${event.tool_name}: ${desc}`;
|
||||
if (cmd) return `${event.tool_name}: ${trunc(cmd)}`;
|
||||
break;
|
||||
}
|
||||
case "Read": {
|
||||
// Show the compact two-segment path so the file is identifiable.
|
||||
const path = s(input.file_path);
|
||||
if (path) return `Read · ${shortPath(path)}`;
|
||||
break;
|
||||
}
|
||||
case "Write": {
|
||||
// Same treatment as Read — the destination path is the key fact.
|
||||
const path = s(input.file_path);
|
||||
if (path) return `Write · ${shortPath(path)}`;
|
||||
break;
|
||||
}
|
||||
case "Edit":
|
||||
case "NotebookEdit": {
|
||||
const path = s(input.file_path);
|
||||
if (path) {
|
||||
// Flag global replacements so a sweeping edit is visually distinct.
|
||||
const suffix = input.replace_all === true ? " (all)" : "";
|
||||
return `${event.tool_name} · ${shortPath(path)}${suffix}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Grep": {
|
||||
// Lead with the search pattern; append the scope directory when present.
|
||||
const pattern = s(input.pattern);
|
||||
const path = s(input.path);
|
||||
if (pattern) {
|
||||
return path
|
||||
? `Grep · "${trunc(pattern, 40)}" in ${basename(path)}`
|
||||
: `Grep · "${trunc(pattern, 40)}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Glob": {
|
||||
// The glob pattern *is* the action; show it verbatim (already short).
|
||||
const pattern = s(input.pattern);
|
||||
if (pattern) return `Glob · "${pattern}"`;
|
||||
break;
|
||||
}
|
||||
case "WebFetch": {
|
||||
// Only the host is meaningful at a glance; the full URL is often huge.
|
||||
const url = s(input.url);
|
||||
if (url) return `WebFetch · ${hostFromUrl(url)}`;
|
||||
break;
|
||||
}
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
// Subagent spawns: identify the agent kind and/or its task description,
|
||||
// e.g. "Task · frontend-reviewer - audit the new modal".
|
||||
const desc = s(input.description);
|
||||
const subtype = s(input.subagent_type);
|
||||
if (desc && subtype) return `${event.tool_name} · ${subtype} - ${trunc(desc, 60)}`;
|
||||
if (desc) return `${event.tool_name} · ${trunc(desc, 60)}`;
|
||||
if (subtype) return `${event.tool_name} · ${subtype}`;
|
||||
break;
|
||||
}
|
||||
// Task-management tools all share the same shape: prefer a human
|
||||
// description, else the task id.
|
||||
case "TaskCreate":
|
||||
case "TaskUpdate":
|
||||
case "TaskGet":
|
||||
case "TaskStop":
|
||||
case "TaskOutput":
|
||||
case "TaskList": {
|
||||
const desc = s(input.description);
|
||||
const id = s(input.id);
|
||||
if (desc) return `${event.tool_name} · ${trunc(desc, 60)}`;
|
||||
if (id) return `${event.tool_name} · ${id}`;
|
||||
break;
|
||||
}
|
||||
case "ScheduleWakeup": {
|
||||
// Show the delay in seconds and, when given, the reason for the wakeup.
|
||||
const delay = input.delaySeconds;
|
||||
const reason = s(input.reason);
|
||||
if (typeof delay === "number") {
|
||||
return `ScheduleWakeup · ${delay}s${reason ? ` - ${trunc(reason, 50)}` : ""}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "AskUserQuestion": {
|
||||
// `questions` is an array of objects; surface the first question's text.
|
||||
const qs = input.questions;
|
||||
if (Array.isArray(qs) && qs.length > 0) {
|
||||
const first = qs[0];
|
||||
if (first && typeof first === "object") {
|
||||
const q = s((first as Record<string, unknown>).question);
|
||||
if (q) return `AskUserQuestion · "${trunc(q, 60)}"`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Monitor": {
|
||||
// Monitor watches a shell command; show the (truncated) command text.
|
||||
const cmd = s(input.command);
|
||||
if (cmd) return `Monitor · ${trunc(cmd)}`;
|
||||
break;
|
||||
}
|
||||
case "ToolSearch": {
|
||||
// The search query is the action being performed.
|
||||
const q = s(input.query);
|
||||
if (q) return `ToolSearch · ${trunc(q, 60)}`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unknown native tool: reuse the generic CONTEXT_FIELDS headline so even
|
||||
// never-before-seen tools get a meaningful title instead of "Using tool".
|
||||
const ctx = buildContextHeadline(input);
|
||||
if (ctx) return `${event.tool_name} · ${trunc(ctx)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Tail fallback: reached when a matched case `break`s because its expected
|
||||
// field was missing. Show the tool name plus any server summary.
|
||||
return `${event.tool_name}${event.summary ? ` · ${event.summary}` : ""}`;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Agent attribution labels
|
||||
//
|
||||
// These helpers turn an `agent_id` (and optional AgentInfo) into the short
|
||||
// labels shown next to events. A session has one "main" agent plus zero or more
|
||||
// nested subagents; the goal is to identify *which* agent acted without adding
|
||||
// noise for the common main-agent case.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Returns a short agent label for display next to an event, or null when the
|
||||
* event belongs to the session's main agent (no disambiguation needed).
|
||||
* @param agentId The event's `agent_id`, or null.
|
||||
* @returns The last-8 of the id for subagents, the whole id when short, or null
|
||||
* for the main agent / a missing id.
|
||||
*/
|
||||
export function shortAgentLabel(agentId: string | null): string | null {
|
||||
if (!agentId) return null;
|
||||
// Main-agent ids end in "-main"; those need no pill (they're the default).
|
||||
if (agentId.endsWith("-main")) return null;
|
||||
// Last 8 chars of the UUID is enough to distinguish subagents on the same row.
|
||||
return agentId.length > 8 ? agentId.slice(-8) : agentId;
|
||||
}
|
||||
|
||||
/** Minimal subset of an Agent record, enough to render a subagent pill and
|
||||
* walk the parent chain (so events from a nested subagent can render the
|
||||
* full "main › coder › explorer" attribution). */
|
||||
export type AgentInfo = {
|
||||
/** "main" for the session's root agent, "subagent" for any spawned agent. */
|
||||
type: "main" | "subagent";
|
||||
/** The subagent's kind (e.g. "frontend-reviewer"); null for main agents. */
|
||||
subagent_type: string | null;
|
||||
/** A human name / label for the agent; used when `subagent_type` is empty. */
|
||||
name: string;
|
||||
/** Parent agent's id, enabling the chain walk in {@link agentOriginLabel}. */
|
||||
parent_agent_id?: string | null;
|
||||
};
|
||||
|
||||
/** Single-segment label for an agent - the pill text. Returns null when the
|
||||
* agent is the session's main agent (pill is noise in that case).
|
||||
*
|
||||
* Preference order: the descriptive `subagent_type` (most meaningful), then the
|
||||
* agent's `name`, then null when neither is populated.
|
||||
* @param info The agent record to label.
|
||||
* @returns One label segment, or null for main / unlabeled agents.
|
||||
*/
|
||||
function singleAgentSegment(info: AgentInfo): string | null {
|
||||
if (info.type === "main") return null;
|
||||
if (info.subagent_type && info.subagent_type.length > 0) return info.subagent_type;
|
||||
if (info.name && info.name.length > 0) return info.name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolves the pill label for an event's agent. Returns null when the event
|
||||
* comes from the session's main agent (the pill is noise in that case) or
|
||||
* when no info is available. Prefers subagent_type (e.g. "frontend-reviewer"),
|
||||
* then the agent's name, and finally the last-8 short ID fallback.
|
||||
* @param agentId The event's `agent_id`; null yields null.
|
||||
* @param info Optional {@link AgentInfo} for that agent, when known.
|
||||
* @returns The pill text, or null when nothing worth showing exists.
|
||||
*/
|
||||
export function agentPillLabel(agentId: string | null, info: AgentInfo | undefined): string | null {
|
||||
if (!agentId) return null;
|
||||
if (info) {
|
||||
const seg = singleAgentSegment(info);
|
||||
// A concrete subagent label wins outright.
|
||||
if (seg !== null) return seg;
|
||||
// Known main agent with no segment → deliberately no pill.
|
||||
if (info.type === "main") return null;
|
||||
}
|
||||
// No usable info: fall back to the id-derived short label.
|
||||
return shortAgentLabel(agentId);
|
||||
}
|
||||
|
||||
/** Resolves a label that always identifies an event's agent origin - unlike
|
||||
* agentPillLabel, this returns "main" for main agents instead of null. Used
|
||||
* by the inline origin prefix ("{session} › {agent} · {action}").
|
||||
*
|
||||
* When an `agentInfoById` map is provided AND the event's agent has a
|
||||
* parent_agent_id, the chain is walked from the root subagent down to the
|
||||
* current agent and joined with " › " - so an event triggered by a deeply
|
||||
* nested subagent reads "main › coder › explorer" instead of just "explorer".
|
||||
* Cycles and missing parents fall back gracefully to the single-segment label.
|
||||
* @param agentId The event's `agent_id`; null yields null (no origin to show).
|
||||
* @param infoOrMap Either one agent's {@link AgentInfo} (legacy single-segment
|
||||
* behavior) or a `Map<agentId, AgentInfo>` covering the session (enables the
|
||||
* parent-chain walk).
|
||||
* @returns "main", a single subagent segment, a "main › a › b" chain, or the
|
||||
* {@link shortAgentLabel} fallback when no info is available.
|
||||
* @example
|
||||
* agentOriginLabel("sub-42", agentInfoById) // "main › coder › explorer"
|
||||
*/
|
||||
export function agentOriginLabel(
|
||||
agentId: string | null,
|
||||
infoOrMap: AgentInfo | Map<string, AgentInfo> | undefined
|
||||
): string | null {
|
||||
if (!agentId) return null;
|
||||
// Overload detection: a Map enables the parent-chain walk; a bare AgentInfo
|
||||
// (or undefined) keeps the legacy single-segment path.
|
||||
const map = infoOrMap instanceof Map ? infoOrMap : null;
|
||||
const info = map ? map.get(agentId) : (infoOrMap as AgentInfo | undefined);
|
||||
|
||||
// No map - preserve the legacy single-segment behavior for callers that
|
||||
// haven't switched to the chain-aware overload yet.
|
||||
if (!map) {
|
||||
if (info) {
|
||||
if (info.type === "main") return "main";
|
||||
const seg = singleAgentSegment(info);
|
||||
if (seg) return seg;
|
||||
}
|
||||
// Without info, infer "main" from the id suffix, else use the short id.
|
||||
if (agentId.endsWith("-main")) return "main";
|
||||
return shortAgentLabel(agentId);
|
||||
}
|
||||
|
||||
// Map provided - walk parent chain so nested subagents read "main › coder".
|
||||
const segments: string[] = [];
|
||||
// `seen` guards against a corrupt parent cycle causing an infinite loop.
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = agentId;
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const node = map.get(cursor);
|
||||
// Missing node: the chain is broken — stop and use whatever we gathered.
|
||||
if (!node) break;
|
||||
if (node.type === "main") {
|
||||
// Reached the root; prepend "main" and stop climbing.
|
||||
segments.unshift("main");
|
||||
break;
|
||||
}
|
||||
// `unshift` builds the chain root-first so it reads top-down.
|
||||
const seg = singleAgentSegment(node);
|
||||
if (seg) segments.unshift(seg);
|
||||
cursor = node.parent_agent_id ?? null;
|
||||
}
|
||||
|
||||
// Walk produced nothing usable (e.g. id absent from the map): fall back the
|
||||
// same way the map-less branch does.
|
||||
if (segments.length === 0) {
|
||||
if (agentId.endsWith("-main")) return "main";
|
||||
return shortAgentLabel(agentId);
|
||||
}
|
||||
return segments.join(" › ");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Origin prefix and project derivation
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Builds the muted origin prefix shown before a row's action title, e.g.
|
||||
* "datapilot › DataPilot › frontend-reviewer". Returns null when nothing
|
||||
* identifying is available. Any of the three segments may be null - pages
|
||||
* already scoped to a single session pass null for sessionName, etc. When a
|
||||
* segment equals the previous one (e.g. project name == session name), it
|
||||
* is dropped to avoid visual duplication.
|
||||
* @param projectName Leading segment (usually the working-directory name).
|
||||
* @param sessionName Middle segment; dropped when identical to projectName.
|
||||
* @param agentLabel Trailing segment (from {@link agentOriginLabel}).
|
||||
* @returns The " › "-joined prefix, or null when every segment was empty.
|
||||
*/
|
||||
export function buildOriginLabel(
|
||||
projectName: string | null | undefined,
|
||||
sessionName: string | null | undefined,
|
||||
agentLabel: string | null
|
||||
): string | null {
|
||||
const parts: string[] = [];
|
||||
if (projectName) parts.push(projectName);
|
||||
// Skip the session name when it just repeats the project name.
|
||||
if (sessionName && sessionName !== projectName) parts.push(sessionName);
|
||||
if (agentLabel) parts.push(agentLabel);
|
||||
return parts.length > 0 ? parts.join(" › ") : null;
|
||||
}
|
||||
|
||||
/** Last path segment of a working directory - the project/dir name shown as the
|
||||
* leading origin segment. Null for an empty or missing cwd. Use this to derive
|
||||
* a fallback project for events whose own payload carries no `cwd` (e.g.
|
||||
* TurnDuration), by passing the owning session's cwd.
|
||||
* @param cwd An absolute working-directory path, or null/undefined.
|
||||
* @returns The final path segment, or null when there's no usable cwd.
|
||||
* @example projectFromCwd("/Users/me/dev/datapilot") // "datapilot"
|
||||
*/
|
||||
export function projectFromCwd(cwd: string | null | undefined): string | null {
|
||||
if (typeof cwd !== "string" || cwd.length === 0) return null;
|
||||
return basename(cwd);
|
||||
}
|
||||
|
||||
/** Reads `cwd` out of an event's payload and returns the last path segment
|
||||
* (the project/directory name). Null when the payload doesn't include cwd
|
||||
* (e.g. TurnDuration events, or events from a very old client) - callers can
|
||||
* fall back to `projectFromCwd(session.cwd)` in that case.
|
||||
* @param event The event whose JSON `data` payload may carry a `cwd`.
|
||||
* @returns The derived project name, or null when no `cwd` is present / valid.
|
||||
*/
|
||||
export function projectFromEvent(event: DashboardEvent): string | null {
|
||||
if (!event.data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
// Only a plain-object payload can carry a top-level `cwd` string.
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const cwd = (parsed as Record<string, unknown>).cwd;
|
||||
if (typeof cwd === "string" && cwd.length > 0) return projectFromCwd(cwd);
|
||||
}
|
||||
} catch {
|
||||
/* ignore — no parseable cwd means the caller uses its session fallback */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user