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:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,148 @@
/**
* @file Tests for agentOriginLabel - verifies the parent-chain walk that
* renders nested subagent attribution as "main coder explorer", so
* tool events triggered by deeply nested subagents identify their full
* lineage instead of collapsing to just the leaf agent.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { agentOriginLabel, projectFromCwd, type AgentInfo } from "../event-grouping";
function makeMap(entries: Array<[string, AgentInfo]>): Map<string, AgentInfo> {
return new Map(entries);
}
describe("agentOriginLabel - parent chain walk", () => {
it("returns 'main' for the main agent itself", () => {
const map = makeMap([
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
]);
expect(agentOriginLabel("sess-main", map)).toBe("main");
});
it("renders 'main coder' for a direct child of main", () => {
const map = makeMap([
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
[
"sub-coder",
{
type: "subagent",
subagent_type: "coder",
name: "Coder",
parent_agent_id: "sess-main",
},
],
]);
expect(agentOriginLabel("sub-coder", map)).toBe("main coder");
});
it("renders 'main coder explorer' for a 2-deep subagent", () => {
const map = makeMap([
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
[
"sub-coder",
{
type: "subagent",
subagent_type: "coder",
name: "Coder",
parent_agent_id: "sess-main",
},
],
[
"sub-explorer",
{
type: "subagent",
subagent_type: "explorer",
name: "Explorer",
parent_agent_id: "sub-coder",
},
],
]);
expect(agentOriginLabel("sub-explorer", map)).toBe("main coder explorer");
});
it("falls back to single-segment when the map is undefined (legacy callers)", () => {
const info: AgentInfo = {
type: "subagent",
subagent_type: "coder",
name: "Coder",
parent_agent_id: "sess-main",
};
expect(agentOriginLabel("sub-coder", info)).toBe("coder");
});
it("uses subagent name when subagent_type is null", () => {
const map = makeMap([
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
[
"sub-1",
{
type: "subagent",
subagent_type: null,
name: "Helper",
parent_agent_id: "sess-main",
},
],
]);
expect(agentOriginLabel("sub-1", map)).toBe("main Helper");
});
it("breaks cleanly on a parent-chain cycle", () => {
// Pathological: a → b → a. Should not loop forever.
const map = makeMap([
[
"a",
{
type: "subagent",
subagent_type: "a",
name: "A",
parent_agent_id: "b",
},
],
[
"b",
{
type: "subagent",
subagent_type: "b",
name: "B",
parent_agent_id: "a",
},
],
]);
const result = agentOriginLabel("a", map);
expect(result).toBe("b a");
});
it("returns null for null agentId regardless of map shape", () => {
expect(agentOriginLabel(null, makeMap([]))).toBeNull();
expect(agentOriginLabel(null, undefined)).toBeNull();
});
it("falls back to 'main' for IDs ending in -main when no info is available", () => {
expect(agentOriginLabel("session-xyz-main", makeMap([]))).toBe("main");
});
});
describe("projectFromCwd - session-cwd fallback for the dir prefix", () => {
it("returns the last path segment of a POSIX cwd", () => {
expect(projectFromCwd("/Users/dev/WebstormProjects/Claude-Code-Agent-Monitor")).toBe(
"Claude-Code-Agent-Monitor"
);
});
it("ignores a trailing slash", () => {
expect(projectFromCwd("/Users/dev/my-app/")).toBe("my-app");
});
it("handles Windows backslash paths", () => {
expect(projectFromCwd("C:\\Users\\dev\\my-app")).toBe("my-app");
});
it("returns null for null, undefined, or empty cwd", () => {
expect(projectFromCwd(null)).toBeNull();
expect(projectFromCwd(undefined)).toBeNull();
expect(projectFromCwd("")).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* @file dataScope.test.ts
* @description Unit tests for the global data-scope store (which source machines
* the dashboard shows). Covers persistence, the localStorage → query-param
* mapping consumed by the API layer, subscriber notification, and malformed /
* empty input handling. Each case re-imports the module so the singleton starts
* clean.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const STORAGE_KEY = "ccam-data-scope";
// Fresh module instance (resets the module-level `current`) with localStorage
// pre-seeded, so we can test load-from-storage behavior deterministically.
async function freshModule(seed?: unknown) {
localStorage.clear();
if (seed !== undefined) localStorage.setItem(STORAGE_KEY, JSON.stringify(seed));
vi.resetModules();
return import("../dataScope");
}
describe("dataScope store", () => {
beforeEach(() => localStorage.clear());
it("defaults to scope 'all' (no filter) when nothing is stored", async () => {
const m = await freshModule();
expect(m.getScope()).toEqual({ mode: "all", selected: [] });
expect(m.activeSourcesParam()).toBeNull();
});
it("activeSourcesParam maps each mode correctly", async () => {
const m = await freshModule();
m.setScope({ mode: "all", selected: [] });
expect(m.activeSourcesParam()).toBeNull();
m.setScope({ mode: "local", selected: [] });
expect(m.activeSourcesParam()).toBe("local");
m.setScope({ mode: "selected", selected: ["local", "src_abc"] });
expect(m.activeSourcesParam()).toBe("local,src_abc");
});
it("'selected' with an empty selection degrades to local-only (never empty app)", async () => {
const m = await freshModule();
m.setScope({ mode: "selected", selected: [] });
expect(m.activeSourcesParam()).toBe("local");
});
it("persists to localStorage and reloads on next module init", async () => {
const m1 = await freshModule();
m1.setScope({ mode: "selected", selected: ["src_1", "src_2"] });
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
mode: "selected",
selected: ["src_1", "src_2"],
});
// A fresh init (new tab) should read the persisted value back.
const m2 = await import("../dataScope").then(async () => {
vi.resetModules();
return import("../dataScope");
});
expect(m2.getScope()).toEqual({ mode: "selected", selected: ["src_1", "src_2"] });
});
it("notifies subscribers on change and stops after unsubscribe", async () => {
const m = await freshModule();
const cb = vi.fn();
const unsub = m.subscribeScope(cb);
m.setScope({ mode: "local", selected: [] });
m.setScope({ mode: "all", selected: [] });
expect(cb).toHaveBeenCalledTimes(2);
unsub();
m.setScope({ mode: "local", selected: [] });
expect(cb).toHaveBeenCalledTimes(2); // no further calls
});
it("getScope returns a stable reference between changes (for useSyncExternalStore)", async () => {
const m = await freshModule();
const a = m.getScope();
const b = m.getScope();
expect(a).toBe(b);
m.setScope({ mode: "local", selected: [] });
expect(m.getScope()).not.toBe(a);
});
it("setScope copies the selected array (no external mutation leaks in)", async () => {
const m = await freshModule();
const sel = ["src_1"];
m.setScope({ mode: "selected", selected: sel });
sel.push("src_2");
expect(m.getScope().selected).toEqual(["src_1"]);
});
it("tolerates malformed stored JSON and falls back to the default", async () => {
localStorage.setItem(STORAGE_KEY, "{not valid json");
vi.resetModules();
const m = await import("../dataScope");
expect(m.getScope()).toEqual({ mode: "all", selected: [] });
});
it("sanitizes an unknown mode / non-array selected from storage", async () => {
const m = await freshModule({ mode: "bogus", selected: "nope" });
expect(m.getScope()).toEqual({ mode: "all", selected: [] });
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* @file eventBus.test.ts
* @description Unit tests for the eventBus module to ensure correct subscription, publishing, and unsubscription behavior in the agent dashboard application.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { eventBus } from "../eventBus";
import type { WSMessage } from "../types";
function makeMsg(type: WSMessage["type"] = "new_event"): WSMessage {
return {
type,
data: {
id: 1,
session_id: "s1",
agent_id: null,
event_type: "PreToolUse",
tool_name: "Bash",
summary: "test",
data: null,
created_at: new Date().toISOString(),
},
timestamp: new Date().toISOString(),
};
}
describe("eventBus", () => {
it("should call subscriber when message is published", () => {
const handler = vi.fn();
const unsub = eventBus.subscribe(handler);
const msg = makeMsg();
eventBus.publish(msg);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(msg);
unsub();
});
it("should support multiple subscribers", () => {
const h1 = vi.fn();
const h2 = vi.fn();
const u1 = eventBus.subscribe(h1);
const u2 = eventBus.subscribe(h2);
eventBus.publish(makeMsg());
expect(h1).toHaveBeenCalledTimes(1);
expect(h2).toHaveBeenCalledTimes(1);
u1();
u2();
});
it("should stop calling handler after unsubscribe", () => {
const handler = vi.fn();
const unsub = eventBus.subscribe(handler);
eventBus.publish(makeMsg());
expect(handler).toHaveBeenCalledTimes(1);
unsub();
eventBus.publish(makeMsg());
expect(handler).toHaveBeenCalledTimes(1); // still 1
});
it("should not fail when publishing with no subscribers", () => {
expect(() => eventBus.publish(makeMsg())).not.toThrow();
});
it("should handle unsubscribe called multiple times", () => {
const handler = vi.fn();
const unsub = eventBus.subscribe(handler);
unsub();
unsub(); // second call should be harmless
eventBus.publish(makeMsg());
expect(handler).not.toHaveBeenCalled();
});
it("should not add duplicate handler references", () => {
const handler = vi.fn();
const u1 = eventBus.subscribe(handler);
const u2 = eventBus.subscribe(handler); // same ref, Set deduplicates
eventBus.publish(makeMsg());
expect(handler).toHaveBeenCalledTimes(1); // Set prevents double-add
u1();
u2();
});
});
+258
View File
@@ -0,0 +1,258 @@
/**
* @file format.test.ts
* @description Unit tests for the format utility functions to ensure correct formatting of durations, time ago, truncation, and locale-aware date/time in the agent dashboard application.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import i18n from "i18next";
import {
formatMs,
formatDuration,
timeAgo,
truncate,
fmt,
fmtCost,
formatDateTime,
formatTime,
getCurrentLocale,
formatModelName,
} from "../format";
describe("formatMs", () => {
it("should return 0s for negative values", () => {
expect(formatMs(-1000)).toBe("0s");
expect(formatMs(-1)).toBe("0s");
});
it("should return 0s for zero", () => {
expect(formatMs(0)).toBe("0s");
});
it("should format seconds only", () => {
expect(formatMs(1000)).toBe("1s");
expect(formatMs(5000)).toBe("5s");
expect(formatMs(59000)).toBe("59s");
});
it("should format minutes and seconds", () => {
expect(formatMs(60000)).toBe("1m 0s");
expect(formatMs(90000)).toBe("1m 30s");
expect(formatMs(125000)).toBe("2m 5s");
expect(formatMs(3599000)).toBe("59m 59s");
});
it("should format hours and minutes", () => {
expect(formatMs(3600000)).toBe("1h 0m");
expect(formatMs(5400000)).toBe("1h 30m");
expect(formatMs(7260000)).toBe("2h 1m");
});
it("should truncate sub-second precision", () => {
expect(formatMs(1500)).toBe("1s");
expect(formatMs(999)).toBe("0s");
});
});
describe("formatDuration", () => {
it("should compute duration between two ISO strings", () => {
const start = "2026-03-05T10:00:00.000Z";
const end = "2026-03-05T10:05:30.000Z";
expect(formatDuration(start, end)).toBe("5m 30s");
});
it("should handle zero duration", () => {
const t = "2026-03-05T10:00:00.000Z";
expect(formatDuration(t, t)).toBe("0s");
});
it("should handle long durations", () => {
const start = "2026-03-05T10:00:00.000Z";
const end = "2026-03-05T12:30:00.000Z";
expect(formatDuration(start, end)).toBe("2h 30m");
});
});
describe("timeAgo", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should return "just now" for recent times', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-05T10:00:30Z"));
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("just now");
});
it("should return minutes ago", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-05T10:05:00Z"));
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("5m ago");
});
it("should return hours ago", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-05T13:00:00Z"));
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("3h ago");
});
it("should return days ago", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T10:00:00Z"));
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("2d ago");
});
});
describe("locale-aware date formatting", () => {
it("should map selected language to the expected locale", async () => {
await i18n.changeLanguage("vi");
expect(getCurrentLocale()).toBe("vi-VN");
await i18n.changeLanguage("en");
expect(getCurrentLocale()).toBe("en-US");
// A language the dashboard no longer ships falls back to en-US.
await i18n.changeLanguage("ko");
expect(getCurrentLocale()).toBe("en-US");
});
it("should format date-time using the active locale", async () => {
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockReturnValue("formatted-datetime");
await i18n.changeLanguage("vi");
expect(formatDateTime("2026-03-05T10:00:00.000Z")).toBe("formatted-datetime");
expect(spy).toHaveBeenCalledWith(
"vi-VN",
expect.objectContaining({
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
);
});
it("should format time using the active locale", async () => {
const spy = vi.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("formatted-time");
await i18n.changeLanguage("vi");
expect(formatTime("2026-03-05T10:00:00.000Z")).toBe("formatted-time");
expect(spy).toHaveBeenCalledWith(
"vi-VN",
expect.objectContaining({
hour: "2-digit",
minute: "2-digit",
})
);
});
});
describe("truncate", () => {
it("should return string unchanged when shorter than max", () => {
expect(truncate("hello", 10)).toBe("hello");
});
it("should return string unchanged when exactly max length", () => {
expect(truncate("hello", 5)).toBe("hello");
});
it("should truncate and add ellipsis when longer than max", () => {
expect(truncate("hello world", 8)).toBe("hello w\u2026");
});
it("should handle max of 1", () => {
expect(truncate("hello", 1)).toBe("\u2026");
});
it("should handle empty string", () => {
expect(truncate("", 5)).toBe("");
});
});
describe("fmt", () => {
it("should return raw number below 1000", () => {
expect(fmt(0)).toBe("0");
expect(fmt(999)).toBe("999");
});
it("should format thousands with K suffix", () => {
expect(fmt(1000)).toBe("1.0K");
expect(fmt(1957)).toBe("2.0K");
expect(fmt(21986)).toBe("22.0K");
});
it("should format millions with M suffix", () => {
expect(fmt(1_000_000)).toBe("1.0M");
expect(fmt(1_009_500_000)).toBe("1.0B");
});
it("should format billions with B suffix", () => {
expect(fmt(1_000_000_000)).toBe("1.0B");
expect(fmt(2_500_000_000)).toBe("2.5B");
});
});
describe("fmtCost", () => {
it("should format small costs with dollar sign", () => {
expect(fmtCost(0)).toBe("$0.00");
expect(fmtCost(833.97)).toBe("$833.97");
expect(fmtCost(999.99)).toBe("$999.99");
});
it("should format thousands with K suffix", () => {
expect(fmtCost(1000)).toBe("$1.00K");
expect(fmtCost(2500.5)).toBe("$2.50K");
});
it("should format millions with M suffix", () => {
expect(fmtCost(1_000_000)).toBe("$1.00M");
});
});
describe("formatModelName", () => {
it("returns null for falsy input", () => {
expect(formatModelName(null)).toBeNull();
expect(formatModelName(undefined)).toBeNull();
expect(formatModelName("")).toBeNull();
});
it("formats Claude model names with version dots", () => {
expect(formatModelName("claude-opus-4-7")).toBe("Claude Opus 4.7");
expect(formatModelName("claude-sonnet-4-5")).toBe("Claude Sonnet 4.5");
expect(formatModelName("claude-haiku-3-5")).toBe("Claude Haiku 3.5");
});
it("strips date suffixes", () => {
expect(formatModelName("claude-opus-4-7-20260101")).toBe("Claude Opus 4.7");
expect(formatModelName("claude-sonnet-4-5-20250514")).toBe("Claude Sonnet 4.5");
});
it("strips -latest suffix", () => {
expect(formatModelName("claude-sonnet-4-5-latest")).toBe("Claude Sonnet 4.5");
});
it("handles context-window [1m] tag", () => {
expect(formatModelName("claude-opus-4-7[1m]")).toBe("Claude Opus 4.7 (1M)");
expect(formatModelName("claude-opus-4-7-20260101[1m]")).toBe("Claude Opus 4.7 (1M)");
});
it("formats GPT model names with hyphenated brand-version", () => {
expect(formatModelName("gpt-4o")).toBe("GPT-4o");
expect(formatModelName("gpt-4o-mini")).toBe("GPT-4o Mini");
expect(formatModelName("gpt-4-turbo")).toBe("GPT-4 Turbo");
});
it("formats Gemini model names", () => {
expect(formatModelName("gemini-1-5-pro")).toBe("Gemini 1.5 Pro");
});
it("strips provider prefix", () => {
expect(formatModelName("anthropic/claude-opus-4-7")).toBe("Claude Opus 4.7");
});
it("title-cases unknown models", () => {
expect(formatModelName("o1-mini")).toBe("O1 Mini");
expect(formatModelName("o1-preview")).toBe("O1 Preview");
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* @file highlight.test.ts
* @description Unit tests for the syntax highlighter used by the conversation viewer.
* Ensures token classification stays stable for languages we actually render.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { canonicalLang, highlight, tokenClass } from "../highlight";
function joinByType(tokens: { type: string; text: string }[], type: string): string {
return tokens
.filter((t) => t.type === type)
.map((t) => t.text)
.join("|");
}
describe("highlight()", () => {
it("preserves the original source verbatim when concatenated", () => {
const sources = [
"const x = 42;\nconsole.log(x);",
"def foo(a, b):\n return a + b\n",
'{"a": 1, "b": [true, null]}',
"echo $HOME && ls -la",
];
for (const src of sources) {
const langs = ["js", "python", "json", "bash"];
for (const lang of langs) {
const out = highlight(src, lang)
.map((t) => t.text)
.join("");
expect(out).toBe(src);
}
}
});
it("classifies JS keywords, strings, numbers, comments", () => {
const tokens = highlight('const x = "hi"; // greet\nreturn 42;', "ts");
expect(joinByType(tokens, "keyword")).toContain("const");
expect(joinByType(tokens, "keyword")).toContain("return");
expect(joinByType(tokens, "string")).toContain('"hi"');
expect(joinByType(tokens, "comment")).toContain("// greet");
expect(joinByType(tokens, "number")).toContain("42");
});
it("classifies JSON keys as property and bare strings as string", () => {
const tokens = highlight('{"name": "Son", "age": 30}', "json");
expect(joinByType(tokens, "property")).toContain('"name"');
expect(joinByType(tokens, "property")).toContain('"age"');
expect(joinByType(tokens, "string")).toContain('"Son"');
expect(joinByType(tokens, "number")).toContain("30");
});
it("classifies bash builtins and variables", () => {
const tokens = highlight("echo $USER", "bash");
expect(joinByType(tokens, "builtin")).toContain("echo");
expect(joinByType(tokens, "variable")).toContain("$USER");
});
it("returns plain tokens for unknown languages", () => {
const tokens = highlight("anything goes here", "klingon");
expect(tokens).toHaveLength(1);
expect(tokens[0]!.type).toBe("plain");
});
it("classifies diff lines", () => {
const src = "diff --git a b\n+++ b\n--- a\n+added\n-removed\n unchanged";
const tokens = highlight(src, "diff");
expect(joinByType(tokens, "diff-add")).toContain("+added");
expect(joinByType(tokens, "diff-del")).toContain("-removed");
expect(joinByType(tokens, "diff-meta")).toContain("diff --git a b");
});
});
describe("canonicalLang()", () => {
it("maps common aliases to canonical keys", () => {
expect(canonicalLang("JavaScript")).toBe("js");
expect(canonicalLang("tsx")).toBe("ts");
expect(canonicalLang("py")).toBe("python");
expect(canonicalLang("zsh")).toBe("bash");
expect(canonicalLang("yml")).toBe("yaml");
expect(canonicalLang("")).toBe("plain");
});
});
describe("tokenClass()", () => {
it("returns a non-empty class for every token type", () => {
const types = [
"plain",
"comment",
"string",
"number",
"keyword",
"builtin",
"function",
"operator",
"punctuation",
"property",
"tag",
"attr",
"variable",
"boolean",
"diff-add",
"diff-del",
"diff-meta",
] as const;
for (const t of types) {
expect(tokenClass(t).length).toBeGreaterThan(0);
}
});
});
@@ -0,0 +1,53 @@
/**
* @file Pins the URL and method `api.lanes.git` hits. The card renders whatever
* this returns, so a wrong path would surface as a permanently missing git row
* rather than as a visible failure — worth a test even though it is one line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api } from "../api";
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch(body: unknown) {
const spy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => body,
text: async () => JSON.stringify(body),
});
vi.stubGlobal("fetch", spy);
return spy;
}
describe("api.lanes.git", () => {
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
const facts = {
available: true,
branch: "feat/x",
head: "abc1234",
subject: "do a thing",
dirty: 2,
untracked: 1,
};
const spy = stubFetch(facts);
const result = await api.lanes.git(3);
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
expect(call[1]?.method ?? "GET").toBe("GET");
expect(result).toEqual(facts);
});
it("passes an available:false body straight through", async () => {
stubFetch({ available: false });
expect(await api.lanes.git(9)).toEqual({ available: false });
});
});
@@ -0,0 +1,61 @@
/**
* @file Tests for remote-data WebSocket refresh helpers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { isRemoteDataRefreshMessage } from "../remoteDataEvents";
import type { ImportProgressMessage, WSMessage } from "../types";
function msg(type: WSMessage["type"], data: WSMessage["data"]): WSMessage {
return { type, data, timestamp: "2026-01-01T00:00:00.000Z" };
}
describe("isRemoteDataRefreshMessage", () => {
it("returns true for remote_data.updated", () => {
expect(
isRemoteDataRefreshMessage(
msg("remote_data.updated", {
sourceId: "src_1",
source: "src_1",
})
)
).toBe(true);
});
it("returns true when remote_source.status is ok", () => {
expect(
isRemoteDataRefreshMessage(msg("remote_source.status", { id: "src_1", status: "ok" }))
).toBe(true);
});
it("returns false when remote_source.status is syncing", () => {
expect(
isRemoteDataRefreshMessage(msg("remote_source.status", { id: "src_1", status: "syncing" }))
).toBe(false);
});
it("returns true for remote import.progress complete", () => {
expect(
isRemoteDataRefreshMessage(
msg("import.progress", {
phase: "complete",
source: "remote",
importId: "remote-src_1",
} as ImportProgressMessage)
)
).toBe(true);
});
it("returns false for local import.progress complete", () => {
expect(
isRemoteDataRefreshMessage(
msg("import.progress", {
phase: "complete",
source: "default",
importId: "x",
} as ImportProgressMessage)
)
).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
/**
* @file dataScope.ts
* @description Global "data scope" store — which source machines' data the whole
* dashboard should show. Backs the Remote Data Sources feature: the user picks
* Local only / All / a specific subset, and every scoped page reflects it
* immediately.
*
* This is a tiny module-level singleton (one per tab), mirroring the eventBus
* pattern. `api.ts` reads {@link activeSourcesParam} to append `?sources=...` to
* the scoped GET endpoints; React components read {@link useDataScope} (via
* `useSyncExternalStore`) to render the selector and to re-fetch when the scope
* changes. The choice is persisted to localStorage so it survives reloads.
*
* Scope semantics:
* - `all` → no `sources` param (server returns every machine's data)
* - `local` → `sources=local` (only this machine)
* - `selected` → `sources=<comma-separated ids>` (an empty selection falls
* back to `local` so the UI never shows a confusing empty app)
*
* @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
* - `ScopeMode` — exported API; see TSDoc on the symbol for behavior.
* - `DataScope` — exported API; see TSDoc on the symbol for behavior.
* - `getScope` — exported API; see TSDoc on the symbol for behavior.
* - `setScope` — exported API; see TSDoc on the symbol for behavior.
* - `subscribeScope` — exported API; see TSDoc on the symbol for behavior.
* - `activeSourcesParam` — exported API; see TSDoc on the symbol for behavior.
* - `useDataScope` — 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).
* -----------------------------------------------------------------------------
* **ScopeMode**
* 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.
*
* **DataScope**
* 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.
*
* **getScope**
* 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.
*
* **setScope**
* 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.
*
* **subscribeScope**
* 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.
*
* **activeSourcesParam**
* 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.
*
* **useDataScope**
* 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 { useSyncExternalStore } from "react";
export type ScopeMode = "all" | "local" | "selected";
export interface DataScope {
mode: ScopeMode;
/** Source ids selected when `mode === "selected"`. */
selected: string[];
}
const STORAGE_KEY = "ccam-data-scope";
const DEFAULT_SCOPE: DataScope = { mode: "all", selected: [] };
function load(): DataScope {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_SCOPE;
const parsed = JSON.parse(raw) as Partial<DataScope>;
const mode: ScopeMode =
parsed.mode === "local" || parsed.mode === "selected" || parsed.mode === "all"
? parsed.mode
: "all";
const selected = Array.isArray(parsed.selected)
? parsed.selected.filter((s): s is string => typeof s === "string")
: [];
return { mode, selected };
} catch {
return DEFAULT_SCOPE;
}
}
// The single source of truth for this tab. Replaced wholesale on every change so
// useSyncExternalStore's getSnapshot returns a stable reference between changes.
let current: DataScope = load();
const listeners = new Set<() => void>();
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
} catch {
/* storage disabled — in-memory scope still works for this session */
}
}
/** Current scope (stable reference until the next {@link setScope}). */
export function getScope(): DataScope {
return current;
}
/** Replace the scope, persist it, and notify all subscribers. */
export function setScope(next: DataScope): void {
current = { mode: next.mode, selected: [...next.selected] };
persist();
listeners.forEach((l) => l());
}
/** Subscribe to scope changes (for useSyncExternalStore / manual wiring). */
export function subscribeScope(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
/**
* The `sources` query-param value for the current scope, or `null` when no
* filter should be sent (mode "all"). `api.ts` calls this for every scoped
* endpoint so a scope change transparently narrows all data.
*/
export function activeSourcesParam(): string | null {
if (current.mode === "all") return null;
if (current.mode === "local") return "local";
// "selected": empty selection degrades to local-only rather than showing
// nothing, which would look like a broken/empty dashboard.
return current.selected.length > 0 ? current.selected.join(",") : "local";
}
/**
* React binding: returns `[scope, setScope]`. Components include `scope` in
* their data-loading effect deps so a change re-fetches; the selector calls the
* setter. `getScope` is a stable snapshot getter (server snapshot is the same,
* so SSR/first paint is consistent).
*/
export function useDataScope(): [DataScope, (next: DataScope) => void] {
const scope = useSyncExternalStore(subscribeScope, getScope, getScope);
return [scope, setScope];
}
+851
View File
@@ -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;
}
+662
View File
@@ -0,0 +1,662 @@
/**
* @file event-summary.ts
* @description Produces a short, human-readable summary of a DashboardEvent
* for the top of the expanded EventDetail panel. Purely data-driven - parses
* `tool_input` / `tool_response` and extracts the most useful facts. Returns
* null for events where a summary would add nothing (e.g. unknown tools with
* empty payloads).
*
* ## Output
* The single public entry point, {@link buildEventSummary}, returns an
* {@link EventSummary} — an `{ icon, headline, bullets }` triple. The icon is an
* emoji chosen per event/tool kind, the headline is a one-line description, and
* the bullets are optional supporting facts (diff stats, line counts, error
* flags). It returns null only when there is genuinely nothing to show.
*
* ## How it differs from event-grouping.ts
* `event-grouping.ts` produces the *collapsed* one-line title for a timeline
* row. This file produces the *expanded* detail summary and therefore also
* parses `tool_response` (not just `tool_input`) to report on outcomes — how
* many lines a command printed, how many hunks an edit touched, how many matches
* a search found, and so on.
*
* ## Event shape
* Each helper reads a {@link DashboardEvent}. Relevant fields: `event_type`
* (lifecycle name), `tool_name` (present only on tool events), and `data` — a
* JSON *string* whose parsed form usually holds `tool_input` (arguments) and
* `tool_response` (result). All parsing is defensive: malformed JSON, missing
* fields, and unexpected types degrade to a smaller summary or null rather than
* throwing.
*
* @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
* - `EventSummary` — exported API; see TSDoc on the symbol for behavior.
* - `buildEventSummary` — 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).
* -----------------------------------------------------------------------------
* **EventSummary**
* 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.
*
* **buildEventSummary**
* 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";
/** Rendered result of {@link buildEventSummary}: an emoji icon, a one-line
* headline, and zero or more supporting detail lines shown underneath it. */
export type EventSummary = {
/** Single emoji representing the event kind (tool-specific or lifecycle). */
icon: string;
/** Primary one-line description, e.g. "Edited SessionDetail.tsx". */
headline: string;
/** Secondary detail lines (diff stats, line counts, error flags, …); may be empty. */
bullets: string[];
};
// ════════════════════════════════════════════════════════════════════════════
// Small value / formatting helpers
// ════════════════════════════════════════════════════════════════════════════
/** Coerces an unknown value to a string, returning "" for non-strings.
* @param v Any value pulled out of a parsed payload.
* @returns The value when it's a string, otherwise "".
*/
function str(v: unknown): string {
return typeof v === "string" ? v : "";
}
/** Narrows an unknown value to a plain object (not null, not an array).
* @param v Any value (typically from `JSON.parse`).
* @returns The value typed as a record, or null when it isn't a plain object.
*/
function obj(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/** Compact two-segment path label (parent dir + filename), mirroring the helper
* of the same name in event-grouping.ts. Falls back to the sole segment.
* @param path An absolute or relative path (POSIX or Windows separators).
* @returns The last two segments joined with "/", or the single segment.
* @example shortPath("/repo/src/lib/x.ts") // "lib/x.ts"
*/
function shortPath(path: string): string {
const parts = path.split(/[/\\]/).filter(Boolean);
if (parts.length <= 1) return parts[0] ?? path;
return parts.slice(-2).join("/");
}
/** Truncates `text` to `max` chars, appending an ellipsis when it was clipped.
* @param text The string to clamp.
* @param max Maximum length before truncation (exclusive of the "...").
* @returns The original string, or its first `max` chars followed by "...".
*/
function trunc(text: string, max: number): string {
return text.length > max ? text.slice(0, max) + "..." : text;
}
/** Parses `event.data` (JSON) into a plain object, or null on empty/invalid data.
* @param event The event whose JSON `data` string should be decoded.
* @returns The parsed payload object, or null when data is absent, invalid JSON,
* or not a plain object.
*/
function parseData(event: DashboardEvent): Record<string, unknown> | null {
if (!event.data) return null;
try {
const v = JSON.parse(event.data);
// Reuse `obj` so arrays / primitives (never valid payloads here) yield null.
return obj(v);
} catch {
// Malformed JSON — no summary data available.
return null;
}
}
// ════════════════════════════════════════════════════════════════════════════
// Tool-response analyzers
// ════════════════════════════════════════════════════════════════════════════
/** Counts hunks and +/- lines in an Edit tool response's `structuredPatch`.
*
* Claude Code's Edit/NotebookEdit responses include a `structuredPatch`: an
* array of hunks, each with a `lines: string[]` where every entry is prefixed
* by " ", "+", or "-" (unified-diff style). This tallies additions / removals
* so the summary can show "3 hunks · +12 4".
* @param structuredPatch The `tool_response.structuredPatch` value (untyped).
* @returns `{ hunks, added, removed }`; all zero when the input isn't an array.
* @example
* countHunks([{ lines: ["+new", "-old", " ctx"] }]) // { hunks:1, added:1, removed:1 }
*/
function countHunks(structuredPatch: unknown): { hunks: number; added: number; removed: number } {
// Non-array (missing / failed patch) → nothing to count.
if (!Array.isArray(structuredPatch)) return { hunks: 0, added: 0, removed: 0 };
let added = 0;
let removed = 0;
for (const raw of structuredPatch) {
const r = obj(raw);
// Skip malformed hunks that lack a `lines` array.
if (!r || !Array.isArray(r.lines)) continue;
for (const line of r.lines) {
if (typeof line !== "string") continue;
// Leading "+" = added line, leading "-" = removed line; " " = context.
if (line.startsWith("+")) added++;
else if (line.startsWith("-")) removed++;
}
}
// Hunk count is simply the number of patch entries.
return { hunks: structuredPatch.length, added, removed };
}
/** Finds the nearest function/class/const definition line surrounding an
* Edit hunk, so the summary can show "Inside: function foo(...)".
*
* Scans the patch's lines for the first one matching a definition-like shape.
* The pattern intentionally requires a leading space (`^\s+`) so it matches
* *context* lines (unchanged surroundings) rather than the "+"/"-" changed
* lines — the enclosing definition is usually context, not the edit itself. It
* recognizes JS/TS `function` / `const|let|var` / `name = (`, Python `def`, and
* `class` across languages.
* @param structuredPatch The `tool_response.structuredPatch` value (untyped).
* @returns The trimmed definition line, or null when none is found.
*/
function firstEnclosingContext(structuredPatch: unknown): string | null {
// Look for a context line that looks like a function/class/const definition.
if (!Array.isArray(structuredPatch)) return null;
const defPattern =
/^\s+(?:function\s+\w+|def\s+\w+|class\s+\w+|(?:const|let|var)\s+\w+|\w+\s*=\s*\()/;
for (const raw of structuredPatch) {
const r = obj(raw);
if (!r || !Array.isArray(r.lines)) continue;
for (const line of r.lines) {
// First matching line wins; trim the diff indentation for display.
if (typeof line === "string" && defPattern.test(line)) {
return line.trim();
}
}
}
return null;
}
/** Counts lines in `text` (empty string counts as 0, not 1).
*
* Splits on LF or CRLF. The empty-string guard matters: `"".split(/\n/)`
* returns `[""]` (length 1), which would wrongly report an empty output as
* "1 line".
* @param text The text whose lines to count.
* @returns The number of newline-separated lines, or 0 for empty input.
*/
function lineCount(text: string): number {
if (!text) return 0;
return text.split(/\r?\n/).length;
}
/** Formats a millisecond duration as "Nms" / "N.Ns" / "Nm Ns", for TurnDuration events.
*
* Three tiers keep the label readable at any scale: raw milliseconds under a
* second, one-decimal seconds under a minute, and "Xm Ys" beyond that.
* @param ms A non-negative duration in milliseconds.
* @returns A compact human-readable duration string.
* @example formatDuration(450) // "450ms"
* @example formatDuration(1500) // "1.5s"
* @example formatDuration(95000) // "1m 35s"
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
}
/**
* Builds the icon/headline/bullets summary shown at the top of the expanded
* EventDetail panel. Dispatches first on `event_type` for lifecycle events
* (Stop, TurnDuration, Compaction, Notification, SessionStart/End, APIError),
* then on `tool_name` for tool events - parsing `data`'s `tool_input`/
* `tool_response` to surface tool-specific facts (diff stats for Edit, line
* counts for Read/Write, match counts for Grep/Glob, etc.).
* @param event The raw event to summarize.
* @returns An {@link EventSummary}, or null when there's no tool name and no
* recognized event type to build anything useful from.
*/
export function buildEventSummary(event: DashboardEvent): EventSummary | null {
const data = parseData(event);
// ── Non-tool events first ──────────────────────────────────────────
// Lifecycle events have no tool payload; each maps to a fixed icon / headline
// plus any incidental detail carried in `data`.
// Stop / SubagentStop: a turn (or subagent turn) ended. Optionally note the
// stop-hook flag and the first line of the last assistant message.
if (event.event_type === "Stop" || event.event_type === "SubagentStop") {
const stopHookActive = data?.stop_hook_active === true;
const msg = str(data?.last_assistant_message);
const bullets: string[] = [];
if (stopHookActive) bullets.push("stop hook active");
// Only the first line keeps the bullet compact for multi-line messages.
if (msg) bullets.push(`Last message: ${trunc(msg.split(/\r?\n/)[0] ?? "", 80)}`);
return {
icon: "🛑",
headline: event.event_type === "SubagentStop" ? "Subagent turn ended" : "Turn ended",
bullets,
};
}
// TurnDuration: synthetic event carrying how long the turn took, in ms.
if (event.event_type === "TurnDuration") {
const durationMs = typeof data?.durationMs === "number" ? data.durationMs : null;
return {
icon: "⏱️",
headline: durationMs != null ? `Turn took ${formatDuration(durationMs)}` : "Turn finished",
bullets: [],
};
}
// Compaction: the transcript was summarized to reclaim context window.
if (event.event_type === "Compaction") {
return {
icon: "🗜️",
headline: "Transcript compacted",
bullets: ["Token usage reset for the following turn"],
};
}
// Notification: a user-facing message from the agent; show text + optional type.
if (event.event_type === "Notification") {
const msg = str(data?.message);
const type = str(data?.notification_type);
return {
icon: "🔔",
headline: msg ? `Notification: ${trunc(msg, 80)}` : "Notification",
bullets: type ? [`Type: ${type}`] : [],
};
}
// SessionStart / SessionEnd: bracket a session; surface its source and model
// when the payload includes them. The `.filter(Boolean)` drops empty bullets.
if (event.event_type === "SessionStart" || event.event_type === "SessionEnd") {
const source = str(data?.source);
const model = str(data?.model);
return {
icon: event.event_type === "SessionStart" ? "🎬" : "🏁",
headline: event.event_type === "SessionStart" ? "Session started" : "Session ended",
bullets: [source && `Source: ${source}`, model && `Model: ${model}`].filter(
Boolean
) as string[],
};
}
// APIError: a provider / API failure was recorded for this event.
if (event.event_type === "APIError") {
return {
icon: "⚠️",
headline: "API error recorded",
bullets: [],
};
}
// ── Tool events ────────────────────────────────────────────────────
// Past the lifecycle branches, only tool events remain. Without a tool_name
// there's nothing to summarize.
const tool = event.tool_name;
if (!tool) return null;
// Both may be null: `tool_input` is the arguments, `tool_response` the result.
const input = obj(data?.tool_input);
const response = obj(data?.tool_response);
// MCP tools - generic summary. Any "mcp__" tool is summarized structurally,
// pulling the most relevant field out of the call args and the response.
if (tool.startsWith("mcp__")) {
const headline = humanizeMcp(tool);
const bullets: string[] = [];
if (input) {
// Surface the most identifying call argument, if any.
const top = firstStringField(input, ["title", "query", "q", "url", "name", "id"]);
if (top) bullets.push(`Called with: ${trunc(top, 80)}`);
}
if (response) {
// Prefer a recognizable response field; otherwise report its field count.
const resTop = firstStringField(response, ["title", "name", "state", "status", "url"]);
if (resTop) bullets.push(`Response: ${trunc(resTop, 80)}`);
else bullets.push(`Returned ${Object.keys(response).length} fields`);
}
return { icon: "🧩", headline, bullets };
}
// ── Native tools ───────────────────────────────────────────────────
// Each case builds an icon + headline + outcome bullets from that tool's
// specific input / response shape.
switch (tool) {
case "Bash":
case "PowerShell": {
// Report the command plus stdout/stderr line counts and interruption.
const cmd = str(input?.command);
const desc = str(input?.description);
const stdout = str(response?.stdout);
const stderr = str(response?.stderr);
const interrupted = response?.interrupted === true;
const bullets: string[] = [];
if (desc) bullets.push(`"${desc}"`);
if (stdout) bullets.push(`${lineCount(stdout)} lines stdout`);
if (stderr) bullets.push(`${lineCount(stderr)} lines stderr`);
// Distinguish "empty stderr" (we have a response) from "unknown" (no
// response yet) — only claim "no stderr" once some output / response exists.
else if (stdout || response) bullets.push("no stderr");
if (interrupted) bullets.push("⚠ interrupted");
return {
icon: "💻",
// Headline leads with the binary, then the full (clamped) command.
headline: cmd ? `Ran ${trunc(firstWord(cmd), 40)}: ${trunc(cmd, 80)}` : `${tool} call`,
bullets,
};
}
case "Edit":
case "NotebookEdit": {
// Summarize the diff: enclosing definition, hunk / line counts, and whether
// it was a global replace_all.
const path = str(input?.file_path);
const { hunks, added, removed } = countHunks(response?.structuredPatch);
const ctx = firstEnclosingContext(response?.structuredPatch);
const replaceAll = input?.replace_all === true;
const bullets: string[] = [];
if (ctx) bullets.push(`Inside: ${trunc(ctx, 80)}`);
// Pluralize "hunk" and show the "+added removed" tally.
if (hunks > 0) bullets.push(`${hunks} hunk${hunks === 1 ? "" : "s"} · +${added} ${removed}`);
if (replaceAll) bullets.push("replace_all mode");
return {
icon: "✏️",
headline: path ? `Edited ${shortPath(path)}` : `${tool} call`,
bullets,
};
}
case "Write": {
// Report the size of the written content in lines and bytes.
const path = str(input?.file_path);
const content = str(input?.content);
const bullets: string[] = [];
if (content) bullets.push(`${lineCount(content)} lines · ${content.length} bytes`);
return {
icon: "📝",
headline: path ? `Wrote ${shortPath(path)}` : `Write call`,
bullets,
};
}
case "Read": {
// Note whether a partial range (offset/limit) or the full file was read,
// plus how many lines came back.
const path = str(input?.file_path);
const offset = input?.offset;
const limit = input?.limit;
const bullets: string[] = [];
if (offset != null || limit != null) {
// A range read — describe whichever bounds were provided.
const parts: string[] = [];
if (offset != null) parts.push(`offset ${offset}`);
if (limit != null) parts.push(`limit ${limit}`);
bullets.push(`Range: ${parts.join(", ")}`);
} else {
bullets.push("Full file");
}
// Read responses come back as a raw string of file contents.
if (typeof response === "string") {
bullets.push(`${lineCount(response)} lines returned`);
}
return {
icon: "📖",
headline: path ? `Read ${shortPath(path)}` : "Read call",
bullets,
};
}
case "Grep": {
// Headline the search pattern (+ scope); bullet the match count.
const pattern = str(input?.pattern);
const path = str(input?.path);
const bullets: string[] = [];
const matchCount = countGrepMatches(response);
// Pluralize "match" / "matches" based on the count.
if (matchCount != null) bullets.push(`${matchCount} match${matchCount === 1 ? "" : "es"}`);
return {
icon: "🔍",
headline: pattern
? `Searched "${trunc(pattern, 50)}"${path ? ` in ${shortPath(path)}` : ""}`
: "Grep call",
bullets,
};
}
case "Glob": {
// Headline the glob pattern; bullet how many files matched.
const pattern = str(input?.pattern);
const bullets: string[] = [];
const fileCount = countFiles(response);
if (fileCount != null) bullets.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
return {
icon: "🗂️",
headline: pattern ? `Listed files matching "${pattern}"` : "Glob call",
bullets,
};
}
case "WebFetch": {
// Headline the fetched host; bullet the extraction prompt and response size.
const url = str(input?.url);
const prompt = str(input?.prompt);
const bullets: string[] = [];
if (prompt) bullets.push(`Prompt: ${trunc(prompt, 80)}`);
if (typeof response === "string") bullets.push(`${lineCount(response)} lines returned`);
let host = "";
try {
host = new URL(url).host;
} catch {
// Malformed / relative URL — show the raw string instead of the host.
host = url;
}
return {
icon: "🌐",
headline: url ? `Fetched ${host}` : "WebFetch call",
bullets,
};
}
case "Task":
case "Agent": {
// Detail the spawned subagent's kind, task, and output size.
const subtype = str(input?.subagent_type);
const desc = str(input?.description);
const bullets: string[] = [];
if (subtype) bullets.push(`Subagent: ${subtype}`);
if (desc) bullets.push(`Description: ${trunc(desc, 80)}`);
if (typeof response === "string") bullets.push(`${lineCount(response)} lines output`);
return { icon: "🤖", headline: `Spawned subagent`, bullets };
}
case "TaskCreate": {
// A task was created — headline its description.
const d = str(input?.description);
return {
icon: "✅",
headline: d ? `Created task: ${trunc(d, 80)}` : "TaskCreate",
bullets: [],
};
}
case "TaskUpdate": {
// A task was updated — prefer its description, falling back to its id.
const d = str(input?.description) || str(input?.id);
return {
icon: "🔄",
headline: d ? `Updated task: ${trunc(d, 80)}` : "TaskUpdate",
bullets: [],
};
}
case "AskUserQuestion": {
// Headline the first question's text from the `questions` array.
const qs = Array.isArray(input?.questions) ? input?.questions : null;
const first = qs && qs.length > 0 ? obj(qs[0]) : null;
const q = first ? str(first.question) : "";
return {
icon: "❓",
headline: q ? `Asked: "${trunc(q, 80)}"` : "Asked user",
bullets: [],
};
}
case "ScheduleWakeup": {
// Headline the delay; bullet the reason when supplied.
const delay = input?.delaySeconds;
const reason = str(input?.reason);
return {
icon: "😴",
headline: typeof delay === "number" ? `Scheduled wakeup in ${delay}s` : "Scheduled wakeup",
bullets: reason ? [`Reason: ${trunc(reason, 80)}`] : [],
};
}
default: {
// Unknown native tool - minimal summary. With neither input nor response
// there is nothing worth showing, so return null (no summary card).
if (!input && !response) return null;
return {
icon: "🔧",
headline: `${tool} call`,
// At least report how many input fields were passed.
bullets: input ? [`${Object.keys(input).length} input fields`] : [],
};
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// MCP name + field extraction helpers
// ════════════════════════════════════════════════════════════════════════════
/** Turns an `mcp__server__tool_name` tool name into "Server · tool name" for
* the MCP-tool summary headline (duplicates the dedupe/casing logic in
* event-grouping.ts's `humanizeMcpServer`, kept local to avoid a cross-import).
* @param toolName A namespaced MCP tool name, e.g. "mcp__github__list_issues".
* @returns "Server · tool name" (e.g. "Github · list issues"), or the raw name
* when it doesn't have the expected 3+ `__`-separated segments.
* @example humanizeMcp("mcp__claude_ai_Slack__send_message") // "Slack · send message"
*/
function humanizeMcp(toolName: string): string {
const parts = toolName.split("__").filter(Boolean);
// Not a well-formed MCP name — return it untouched.
if (parts.length < 3) return toolName;
const rawServer = parts[1] ?? "";
// Everything past the server is the (possibly multi-word) action.
const rest = parts.slice(2).join(" ");
// Reuse the same server-humanization logic as elsewhere: split, dedupe, last token.
const tokens = rawServer.split(/[_-]+/).filter(Boolean);
const dedup: string[] = [];
for (const t of tokens) if (dedup[dedup.length - 1] !== t) dedup.push(t);
const last = dedup[dedup.length - 1] ?? rawServer;
// Capitalize only when the token is all-lowercase (preserves "GitLab" etc.).
const server = last.toLowerCase() === last ? last.charAt(0).toUpperCase() + last.slice(1) : last;
// Normalize the action to lowercase spaced words.
const toolPart = rest.replace(/_+/g, " ").trim().toLowerCase();
return `${server} · ${toolPart}`;
}
/** Extracts the first whitespace-delimited token of a shell command (the binary).
* @param command The full command string.
* @returns The first non-whitespace run, or the original string if none.
* @example firstWord(" npm run build") // "npm"
*/
function firstWord(command: string): string {
const m = command.trim().match(/^(\S+)/);
return m ? (m[1] ?? command) : command;
}
/** Returns the first non-empty string value found in `obj` among `priority`
* keys, in order - used to surface the most relevant MCP call/response field.
* @param obj The object to inspect (a parsed input or response).
* @param priority Keys to try, most-preferred first.
* @returns The first matching non-empty string, or null when none match.
*/
function firstStringField(obj: Record<string, unknown>, priority: string[]): string | null {
for (const k of priority) {
const v = obj[k];
if (typeof v === "string" && v.length > 0) return v;
}
return null;
}
/** Best-effort match count from a Grep tool response, checking a few
* known response shapes (array, `.matches`, `.files`, `.count`, `.numFiles`).
*
* Grep results have varied over time and by output mode, so each known shape is
* probed in turn and the first that fits wins.
* @param response The `tool_response` value (untyped).
* @returns The match / file count, or null when no known shape applies.
*/
function countGrepMatches(response: unknown): number | null {
if (!response) return null;
// Plain array of matches.
if (Array.isArray(response)) return response.length;
const r = obj(response);
if (!r) return null;
// Object wrappers seen across Grep output modes.
if (Array.isArray(r.matches)) return r.matches.length;
if (Array.isArray(r.files)) return r.files.length;
if (typeof r.count === "number") return r.count;
if (typeof r.numFiles === "number") return r.numFiles;
return null;
}
/** Best-effort file count from a Glob tool response (array, `.files`, or `.paths`).
* @param response The `tool_response` value (untyped).
* @returns The number of matched files, or null when no known shape applies.
*/
function countFiles(response: unknown): number | null {
if (Array.isArray(response)) return response.length;
const r = obj(response);
if (!r) return null;
if (Array.isArray(r.files)) return r.files.length;
if (Array.isArray(r.paths)) return r.paths.length;
return null;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* @file eventBus.ts
* @description Implements a simple event bus for managing WebSocket messages and connection status in the agent dashboard application. It allows components to subscribe to real-time updates from the server and react to changes in WebSocket connectivity. The event bus maintains a list of handlers for incoming messages and connection status changes, providing a clean interface for publishing events and managing subscriptions.
*
* ## Design
* This is a module-level singleton (there is exactly one bus per browser tab) built on
* two `Set`s of callbacks. It exists to break the one-to-many coupling between the single
* WebSocket connection and the many UI components that care about it:
* - The producer side is the `useWebSocket` hook, which owns the actual socket and calls
* {@link eventBus.publish} for every inbound frame and {@link eventBus.setConnected}
* on open/close.
* - The consumer side is any component that calls {@link eventBus.subscribe} (for message
* data) or {@link eventBus.onConnection} (for a connectivity indicator), typically from
* a `useEffect`, and calls the returned unsubscribe function on cleanup.
*
* ## Why `Set` (not array)?
* A `Set` gives O(1) add/delete and, crucially, natural idempotency: subscribing the same
* handler reference twice registers it once, and the returned disposer removes exactly that
* reference. Handlers are notified in insertion order (Set iteration order).
*
* ## Delivery semantics
* Dispatch is synchronous and fire-and-forget: {@link eventBus.publish} iterates the current
* handler set and calls each in turn. There is no error isolation, so a handler that throws
* will abort the remaining handlers for that message - subscribers should keep their work
* cheap and defensive. There is also no buffering: a message published while nobody is
* subscribed is simply dropped.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** In-memory pub/sub bus bridging `useWebSocket` to any page without prop drilling.
*
* ## 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
* - `eventBus` — 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).
* -----------------------------------------------------------------------------
* **eventBus**
* 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 { WSMessage } from "./types";
/** Callback invoked with every message received from the dashboard WebSocket. */
type Handler = (msg: WSMessage) => void;
/** Callback invoked whenever the WebSocket connection state changes. */
type ConnectionHandler = (connected: boolean) => void;
// --- Module-private subscription state (the single source of truth per tab) ---
/** All active message subscribers; iterated on every {@link eventBus.publish}. */
const handlers = new Set<Handler>();
/** All active connection-state subscribers; iterated on every {@link eventBus.setConnected}. */
const connectionHandlers = new Set<ConnectionHandler>();
/** Latest known socket state; the backing store for the {@link eventBus.connected} getter. */
let wsConnected = false;
/**
* Process-wide pub/sub singleton that decouples the single WebSocket
* connection (owned by `useWebSocket`, which calls {@link publish}/
* {@link setConnected}) from the many components that want to react to
* server pushes or show a connection indicator. Any number of components can
* {@link subscribe}/{@link onConnection} independently of whether they're
* mounted at the same time as the socket itself.
*/
export const eventBus = {
/**
* Registers a handler for every {@link WSMessage} the socket receives.
* @param handler Called synchronously with each message, in subscription order.
* @returns An unsubscribe function; call it (e.g. in a `useEffect` cleanup)
* to stop receiving messages and avoid a memory leak.
*/
subscribe(handler: Handler): () => void {
handlers.add(handler); // idempotent: re-adding the same reference is a no-op
return () => handlers.delete(handler); // disposer removes exactly this handler
},
/** Broadcasts `msg` to every currently-subscribed {@link Handler}. Called by
* `useWebSocket` on each parsed inbound frame - not intended to be called
* directly by UI code. Dispatch is synchronous and in subscription order; a
* throwing handler aborts delivery to the handlers after it. */
publish(msg: WSMessage): void {
handlers.forEach((handler) => handler(msg)); // notify each subscriber in turn
},
/** Current WebSocket connection state, as last reported via {@link setConnected}. */
get connected(): boolean {
return wsConnected;
},
/** Updates the shared connection flag and notifies every {@link onConnection}
* listener. Called by `useWebSocket` on socket open/close. */
setConnected(value: boolean): void {
wsConnected = value; // update the shared flag first so late reads see the new state
connectionHandlers.forEach((handler) => handler(value)); // then fan out the transition
},
/**
* Registers a handler for connection-state transitions (e.g. to drive a
* "reconnecting…" indicator).
* @param handler Called with the new connected state on every change.
* @returns An unsubscribe function.
* @remarks The handler fires only on subsequent {@link eventBus.setConnected} calls, not
* immediately with the current value - read {@link eventBus.connected} once up front if
* the initial state matters.
*/
onConnection(handler: ConnectionHandler): () => void {
connectionHandlers.add(handler); // idempotent add (Set semantics)
return () => connectionHandlers.delete(handler); // disposer for useEffect cleanup
},
};
+568
View File
@@ -0,0 +1,568 @@
/**
* @file format.ts
* @description Provides utility functions for formatting dates, times, durations, and numbers in the agent dashboard application. It includes functions to parse ISO timestamp strings while normalizing UTC, format time and date-time strings for display, calculate and format durations between timestamps, and format large numbers with appropriate suffixes (K/M/B) for better readability. These utilities help ensure consistent and user-friendly presentation of temporal and numerical data throughout the application.
*
* ## Two cross-cutting concerns
* 1. **UTC normalization.** The backend stores timestamps via SQLite's
* `datetime('now')`, which yields a naive `'YYYY-MM-DD HH:MM:SS'` string with no
* timezone. `new Date()` would interpret that as *local* time and silently shift it
* by the viewer's UTC offset. Every date helper here therefore routes its input
* through {@link parseDate}, which appends a `Z` when no timezone is present so the
* value is unambiguously UTC, then relies on `toLocale*` to render it back in the
* viewer's local zone. Timestamps that already carry a `Z` or `±HH:MM` offset are
* parsed as-is.
* 2. **Locale awareness.** The dashboard ships four UI languages (English, Chinese,
* Vietnamese, Korean). {@link getCurrentLocale} maps the active i18next language to a
* BCP-47 tag (`en-US`, `vi-VN`) that the `Intl`/`toLocale*` APIs
* understand, so month names, AM/PM vs. 24-hour clocks, digit grouping and currency
* punctuation all follow the chosen language. Relative-time strings ("5m ago") are
* instead produced from translated i18next keys rather than `Intl.RelativeTimeFormat`.
*
* Number/cost helpers ({@link fmt}, {@link fmtCost}, {@link fmtCostFull}) guard against
* non-finite and negative input, and abbreviate large magnitudes with K/M/B suffixes for
* compact stat tiles while a full comma-grouped form is available for tooltips.
*
* @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
* - `../i18n`
*
* ## Public surface
* - `getCurrentLocale` — exported API; see TSDoc on the symbol for behavior.
* - `formatTime` — exported API; see TSDoc on the symbol for behavior.
* - `formatDateTime` — exported API; see TSDoc on the symbol for behavior.
* - `formatDateShort` — exported API; see TSDoc on the symbol for behavior.
* - `formatDateTimeFull` — exported API; see TSDoc on the symbol for behavior.
* - `formatDuration` — exported API; see TSDoc on the symbol for behavior.
* - `formatMs` — exported API; see TSDoc on the symbol for behavior.
* - `timeAgo` — exported API; see TSDoc on the symbol for behavior.
* - `truncate` — exported API; see TSDoc on the symbol for behavior.
* - `fmt` — exported API; see TSDoc on the symbol for behavior.
* - `fmtCost` — exported API; see TSDoc on the symbol for behavior.
* - `fmtCostFull` — exported API; see TSDoc on the symbol for behavior.
* - `shortModel` — exported API; see TSDoc on the symbol for behavior.
* - `formatModelName` — exported API; see TSDoc on the symbol for behavior.
* - `pathBasename` — 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).
* -----------------------------------------------------------------------------
* **getCurrentLocale**
* 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.
*
* **formatTime**
* 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.
*
* **formatDateTime**
* 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.
*
* **formatDateShort**
* 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.
*
* **formatDateTimeFull**
* 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.
*
* **formatDuration**
* 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.
*
* **formatMs**
* 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.
*
* **timeAgo**
* 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.
*
* **truncate**
* 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.
*
* **fmt**
* 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.
*
* **fmtCost**
* 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.
*
* **fmtCostFull**
* 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.
*
* **shortModel**
* 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.
*
* **formatModelName**
* 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.
*
* **pathBasename**
* 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 i18n from "../i18n";
// ===========================================================================
// Timestamp parsing + locale resolution (shared internals)
// ===========================================================================
/**
* Parse a timestamp string into a Date, normalizing UTC.
* SQLite datetime('now') returns 'YYYY-MM-DD HH:MM:SS' (no timezone).
* JS treats that as local time, causing offset bugs. This ensures
* timestamps without a timezone indicator are treated as UTC.
* @param iso An ISO-8601 string, or SQLite's space-separated `'YYYY-MM-DD HH:MM:SS'`.
* @returns A `Date`. Callers that pass malformed input get an `Invalid Date` (whose
* `getTime()` is `NaN`); several formatters below guard for that explicitly.
* @example
* parseDate("2026-04-18 08:49:13") // treated as UTC (Z appended)
* parseDate("2026-04-18T08:49:13Z") // parsed as-is
* parseDate("2026-04-18T08:49:13-04:00") // parsed as-is (explicit offset)
*/
function parseDate(iso: string): Date {
// Already has timezone info (Z or +/- offset) - parse directly
// (`/[+-]\d{2}:\d{2}$/` catches trailing `+04:00`-style offsets).
if (/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso)) {
return new Date(iso);
}
// No timezone - treat as UTC by appending Z
// Handle both 'YYYY-MM-DD HH:MM:SS' and 'YYYY-MM-DDTHH:MM:SS' formats
// (the single space -> "T" swap makes the SQLite form valid ISO before adding Z).
return new Date(iso.replace(" ", "T") + "Z");
}
/** The UI languages the dashboard localizes formatting for. */
type SupportedLanguage = "en" | "vi";
/**
* Resolve the active i18next language down to one of the {@link SupportedLanguage}
* codes, defaulting to English for anything unrecognized.
* @returns `"en" | "vi"`.
* @remarks Reads `resolvedLanguage` first (the language i18next actually settled on after
* detection/fallback), then `language`, then `"en"`. The value is lowercased and its
* region subtag stripped (`split("-")[0]`), so `"en-US"`, `"vi-VN"` etc. collapse
* to their base language before the whitelist check.
*/
function getCurrentLanguage(): SupportedLanguage {
const language = (i18n.resolvedLanguage ?? i18n.language ?? "en").toLowerCase().split("-")[0];
if (language === "vi" || language === "en") {
return language;
}
return "en"; // any other/undetected language -> English
}
/**
* Maps the active i18next language to a `toLocaleString` BCP-47 locale tag,
* so date/number formatting matches the UI's chosen language. Falls back to
* "en-US" for any language not explicitly supported.
* @returns One of `"vi-VN" | "en-US"`.
* @remarks The region subtag matters: it drives clock convention (English uses 12-hour
* AM/PM here via the `hour: "2-digit"` options, Vietnamese leans 24-hour), month-name
* localization, and digit-group/decimal separators used by {@link fmtCostFull}.
*/
export function getCurrentLocale(): string {
const language = getCurrentLanguage();
if (language === "vi") return "vi-VN"; // Vietnamese
return "en-US"; // default: US English
}
// ===========================================================================
// Date / time formatters (all locale-aware, all UTC-normalized via parseDate)
// ===========================================================================
/**
* Formats an ISO/SQLite timestamp as a locale-aware clock time, e.g. "8:49 AM".
* @param iso Timestamp string (see {@link parseDate}).
* @returns The time-of-day only, using the current locale's clock convention.
*/
export function formatTime(iso: string): string {
const d = parseDate(iso);
return d.toLocaleTimeString(getCurrentLocale(), { hour: "2-digit", minute: "2-digit" });
}
/**
* Formats an ISO/SQLite timestamp as "Apr 18, 8:49 AM" - the default compact
* timestamp used across list rows.
* @param iso Timestamp string (see {@link parseDate}).
* @returns Abbreviated month + day + clock time in the current locale.
* @remarks Deliberately omits the year to stay compact; use {@link formatDateTimeFull}
* when the year/seconds/timezone matter. Does not guard against invalid dates, so a
* malformed input renders as the locale's "Invalid Date" string.
*/
export function formatDateTime(iso: string): string {
const d = parseDate(iso);
return d.toLocaleString(getCurrentLocale(), {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Date only, e.g. "Apr 18" - paired with formatTime as a small second line in
* narrow list rows (timeline, activity feed) so the date is visible too.
* @param iso Timestamp string (see {@link parseDate}).
* @returns Abbreviated month + day, or `""` when the timestamp is unparseable
* (so an empty second line simply collapses rather than showing "Invalid Date").
*/
export function formatDateShort(iso: string): string {
const d = parseDate(iso);
if (isNaN(d.getTime())) return ""; // hide rather than render garbage
return d.toLocaleString(getCurrentLocale(), { month: "short", day: "numeric" });
}
/**
* Fully detailed timestamp with weekday, full date, seconds, and timezone -
* e.g. "Sat, Apr 18, 2026, 08:49:13 AM PDT". For detail panels.
* @param iso Timestamp string (see {@link parseDate}).
* @returns The fully-qualified localized timestamp, or the original `iso` string
* unchanged when it can't be parsed (preserving whatever the backend sent).
* @remarks `timeZoneName: "short"` renders the viewer's local zone abbreviation (PDT,
* KST, …) - a reminder that the underlying value was normalized from UTC to local.
*/
export function formatDateTimeFull(iso: string): string {
const d = parseDate(iso);
if (isNaN(d.getTime())) return iso; // fall back to the raw string on bad input
return d.toLocaleString(getCurrentLocale(), {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
}
// ===========================================================================
// Duration + relative-time formatters
// ===========================================================================
/**
* Formats the elapsed time between two ISO/SQLite timestamps as "Nh Nm" /
* "Nm Ns" / "Ns" (see {@link formatMs}). Negative spans (end before start,
* e.g. clock skew) clamp to "0s".
* @param start Earlier timestamp (see {@link parseDate}).
* @param end Later timestamp (see {@link parseDate}).
* @returns The formatted duration (delegated to {@link formatMs}).
*/
export function formatDuration(start: string, end: string): string {
const ms = parseDate(end).getTime() - parseDate(start).getTime();
return formatMs(ms);
}
/**
* Formats a millisecond duration as the coarsest two-unit representation:
* "Nh Nm" once >= 1 hour, "Nm Ns" once >= 1 minute, else "Ns".
* @param ms Duration in milliseconds.
* @returns A compact two-unit string; sub-second and negative inputs both render as `"0s"`.
* @remarks Only ever shows the two most-significant units - hours never spill into days
* (a 26-hour span reads "26h 0m"), matching the dashboard's short session lifetimes.
* @example
* formatMs(3_930_000) // "1h 5m"
* formatMs(65_000) // "1m 5s"
* formatMs(4_000) // "4s"
* formatMs(-10) // "0s"
*/
export function formatMs(ms: number): string {
if (ms < 0) return "0s"; // clamp negative spans (clock skew) to zero
const totalSec = Math.floor(ms / 1000);
const hours = Math.floor(totalSec / 3600); // whole hours
const minutes = Math.floor((totalSec % 3600) / 60); // leftover whole minutes
const seconds = totalSec % 60; // leftover whole seconds
if (hours > 0) return `${hours}h ${minutes}m`; // >= 1h: hours + minutes
if (minutes > 0) return `${minutes}m ${seconds}s`; // >= 1m: minutes + seconds
return `${seconds}s`; // < 1m: seconds only
}
/**
* Formats how long ago an ISO/SQLite timestamp was, as a translated relative
* string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
* @param iso A past timestamp (see {@link parseDate}).
* @returns A localized relative-time phrase; i18next handles pluralization via `count`.
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest
* bucket, so a 40-day-old event reads "40d ago"). Under a minute collapses to the
* "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
* wording is controlled by the `common:time.*` translation resources.
*/
export function timeAgo(iso: string): string {
const ms = Date.now() - parseDate(iso).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return i18n.t("common:time.justNow");
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return i18n.t("common:time.mAgo", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return i18n.t("common:time.hAgo", { count: hours });
const days = Math.floor(hours / 24);
return i18n.t("common:time.dAgo", { count: days });
}
// ===========================================================================
// String + number formatters
// ===========================================================================
/**
* Truncates `str` to at most `max` characters, appending an ellipsis ("\u2026")
* in place of the last character when truncation occurs.
* @param str Source string.
* @param max Maximum length of the returned string, *including* the ellipsis.
* @returns `str` unchanged when it already fits; otherwise its first `max - 1`
* characters followed by a single "\u2026" so the result is exactly `max` chars long.
* @remarks Counts UTF-16 code units, not grapheme clusters, so a `max` that lands inside
* a surrogate pair or combining sequence could split it - fine for the ASCII-ish labels
* this is used on.
*/
export function truncate(str: string, max: number): string {
if (str.length <= max) return str;
return str.slice(0, max - 1) + "\u2026"; // reserve one slot for the ellipsis
}
/**
* Format large numbers with B/M/K suffixes.
* @param n The number to abbreviate (typically a token count or event tally).
* @returns A compact magnitude string: `"1.2B"`, `"3.4M"`, `"5.6K"`, or the number
* verbatim below 1,000. Non-finite input (`NaN`/`±Infinity`) yields `"0"`.
* @remarks Thresholds are checked largest-first so exactly one suffix applies. Values
* under 1,000 are returned unabbreviated via `String(n)` (no forced decimals), so
* `fmt(42)` is `"42"` and `fmt(999)` is `"999"`. One decimal place is kept for the
* abbreviated tiers (`toFixed(1)`). Negative numbers are passed through unabbreviated.
* @example fmt(1_500) // "1.5K" fmt(2_400_000) // "2.4M" fmt(950) // "950"
*/
export function fmt(n: number): string {
if (!Number.isFinite(n)) return "0"; // NaN / Infinity guard
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; // billions
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; // millions
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; // thousands
return String(n); // < 1000: show as-is
}
/**
* Format dollar amounts with K/M suffixes.
* @param n A dollar amount (e.g. accumulated API spend).
* @returns A compact currency string with two decimals: `"$1.23M"`, `"$4.56K"`, or
* `"$7.89"`. Non-finite *or negative* input yields `"$0.00"`.
* @remarks Unlike {@link fmt}, negatives are clamped (a cost is never shown below zero)
* and the abbreviated tiers keep two decimals to preserve cents-level precision. Caps
* at the millions suffix - there is no billions tier for costs.
* @example fmtCost(12_500) // "$12.50K" fmtCost(3.5) // "$3.50" fmtCost(-1) // "$0.00"
*/
export function fmtCost(n: number): string {
if (!Number.isFinite(n) || n < 0) return "$0.00"; // guard NaN/Infinity/negative
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; // millions
if (n >= 1_000) return `$${(n / 1_000).toFixed(2)}K`; // thousands
return `$${n.toFixed(2)}`; // < $1000: full cents
}
/**
* Format dollar amounts with commas (for tooltips / full display).
* @param n A dollar amount.
* @param decimals Fixed number of fraction digits to show (default 2).
* @returns The un-abbreviated amount with locale-aware digit grouping, e.g.
* `"$1,234,567.89"` (en-US) or the locale's equivalent separators. `"$0.00"` for
* non-finite/negative input.
* @remarks Complements {@link fmtCost}: that one is compact for stat tiles, this one is
* exact for tooltips/detail views. Grouping and decimal marks come from
* {@link getCurrentLocale}, so the same value renders `1,234.50` in en-US and `1.234,50`
* in locales that swap the separators. `minimumFractionDigits === maximumFractionDigits`
* forces exactly `decimals` places (no trimming, no rounding drift beyond `toLocaleString`).
*/
export function fmtCostFull(n: number, decimals = 2): string {
if (!Number.isFinite(n) || n < 0) return "$0.00"; // guard NaN/Infinity/negative
return `$${n.toLocaleString(getCurrentLocale(), {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}`;
}
// ===========================================================================
// Model-name + path formatters
// ===========================================================================
/**
* Strip the date suffix from a Claude model ID:
* "claude-opus-4-7-20260101" → "opus-4-7". Returns the original string
* when the pattern doesn't match, and null/undefined unchanged.
* @param model A raw model identifier, or null/undefined.
* @returns The captured `tier-major(-minor)` slug (e.g. `"opus-4-7"`), the original
* string if it isn't a `claude-…` id, or `null` for falsy input.
* @remarks The capture group `([a-z]+-\d+(?:-\d+)?)` grabs the family plus a one- or
* two-segment version (`sonnet-4`, `opus-4-7`) but stops before the trailing
* `-YYYYMMDD` date. This is the terse form; {@link formatModelName} is the pretty one.
*/
export function shortModel(model: string | null | undefined): string | null {
if (!model) return null;
const m = model.match(/claude-([a-z]+-\d+(?:-\d+)?)/i);
return m?.[1] ?? model; // captured slug, else the input unchanged
}
/**
* Lookup from a lowercased leading token to its display brand. Drives the
* brand-specific formatting branches in {@link formatModelName}; a token absent
* here just gets generic title-casing.
*/
const MODEL_BRANDS: Record<string, string> = {
claude: "Claude",
gpt: "GPT",
gemini: "Gemini",
};
/**
* Human-friendly model name:
* "claude-opus-4-7-20260101" → "Claude Opus 4.7"
* "gpt-4o-mini" → "GPT-4o Mini"
* Returns null for falsy input.
* @param model A raw model id, optionally provider-prefixed and/or context-tagged.
* @returns A display name, or `null` for falsy input.
* @remarks Normalization runs in fixed stages:
* 1. Drop any provider prefix before the last `/` (`anthropic/claude-…` -> `claude-…`).
* 2. Peel a trailing bracketed context-window tag `[1m]`/`[200k]` off and remember it
* as a parenthesized upper-cased suffix (` (1M)`), re-appended at the very end.
* 3. Strip a trailing `-YYYYMMDD` snapshot date and a trailing `-latest`.
* 4. Split on `-` and branch by brand:
* - **GPT**: keep the brand glued to its version token (`GPT-4o`) and title-case the
* remaining words (`mini` -> `Mini`), because GPT versions read as one unit.
* - **Claude/Gemini/generic**: title-case each word, but join *runs of numeric
* segments* with dots so `4-7` becomes `4.7`; alphanumerics like `4o` pass through.
* @example
* formatModelName("anthropic/claude-opus-4-7-20260101[1m]") // "Claude Opus 4.7 (1M)"
* formatModelName("gpt-4o-mini") // "GPT-4o Mini"
* formatModelName("gemini-1-5-pro") // "Gemini 1.5 Pro"
*/
export function formatModelName(model: string | null | undefined): string | null {
if (!model) return null;
// Strip provider prefix ("anthropic/claude-opus-4-7" → "claude-opus-4-7")
let name = model.includes("/") ? model.split("/").pop()! : model;
// Extract bracketed context-window tag like "[1m]" → suffix " (1M)"
let ctxSuffix = "";
const ctxMatch = name.match(/\[(\d+[mk])\]$/i);
if (ctxMatch) {
ctxSuffix = ` (${(ctxMatch[1] as string).toUpperCase()})`; // "[1m]" -> " (1M)"
name = name.slice(0, -ctxMatch[0].length); // remove the bracketed tag from `name`
}
// Strip date suffix and "-latest"
name = name.replace(/-\d{8}$/, "").replace(/-latest$/i, "");
const parts: string[] = name.split("-");
const first = parts[0] ?? name; // family/brand token (e.g. "claude", "gpt")
const brand = MODEL_BRANDS[first.toLowerCase()]; // undefined if not a known brand
// GPT-style names keep the brand hyphenated with the version token:
// "gpt-4o-mini" → "GPT-4o Mini"
if (brand === "GPT" && parts.length >= 2) {
const versionToken = parts[1] as string; // e.g. "4o" - stays glued to the brand
const rest = parts.slice(2); // trailing qualifiers, e.g. ["mini"]
const suffix = rest
// Numeric segments stay as-is; word segments get title-cased.
.map((seg) => (/^\d+$/.test(seg) ? seg : seg.charAt(0).toUpperCase() + seg.slice(1)))
.join(" ");
const base = suffix ? `${brand}-${versionToken} ${suffix}` : `${brand}-${versionToken}`;
return base + ctxSuffix;
}
// Claude / Gemini / generic: title-case words, dot-join version digits
// Seed with the known brand, or a title-cased first token when the brand is unknown.
const result: string[] = [brand ?? first.charAt(0).toUpperCase() + first.slice(1)];
let i = 1;
while (i < parts.length) {
const seg = parts[i] as string;
if (/^\d+$/.test(seg)) {
// Purely numeric segment: greedily absorb following numeric segments and
// join them with dots so "4-7" -> "4.7", "1-5" -> "1.5".
const ver = [seg];
while (i + 1 < parts.length && /^\d+$/.test(parts[i + 1] as string)) {
i++;
ver.push(parts[i] as string);
}
result.push(ver.join("."));
} else if (/^\d+\w+$/.test(seg)) {
// Alphanumeric like "4o"/"3b": keep verbatim (don't title-case or split).
result.push(seg);
} else {
// Plain word: title-case it ("opus" -> "Opus", "pro" -> "Pro").
result.push(seg.charAt(0).toUpperCase() + seg.slice(1));
}
i++;
}
return result.join(" ") + ctxSuffix; // re-attach the context-window suffix, if any
}
/**
* Last segment of a filesystem path. POSIX-only - fine for cwd display.
* "/Users/dav/code/my-project" → "my-project".
* @param p An absolute or relative POSIX path, or null/undefined.
* @returns The final path segment, or `null` for falsy input.
* @remarks Trailing slashes are stripped first (`/a/b/` -> `b`). A path with no `/`
* returns unchanged. The `|| trimmed` fallback guards the degenerate case where the
* input is just slashes (e.g. `"/"`), returning the trimmed value rather than `""`.
* Backslash-separated (Windows) paths are not handled.
*/
export function pathBasename(p: string | null | undefined): string | null {
if (!p) return null;
const trimmed = p.replace(/\/+$/, ""); // drop trailing slash(es)
const idx = trimmed.lastIndexOf("/");
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed;
}
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
/**
* @file push.ts
* @description Provides functions for managing push notifications in the agent dashboard application. It includes utilities for subscribing and unsubscribing to push notifications using the Push API and Service Workers. The module handles the conversion of VAPID public keys, manages push subscriptions, and communicates with the backend API to register or unregister push endpoints. This allows the application to send real-time notifications to users about important events or updates.
*
* ## Web Push in one paragraph
* Web Push lets the server deliver a notification to this browser even when the dashboard
* tab is closed, by going through the browser vendor's push service. Authentication uses
* VAPID (Voluntary Application Server Identification): the server holds a key pair and
* exposes its public key; the browser bakes that public key into the subscription so the
* push service will only accept notifications signed by the matching private key.
*
* ## Subscription lifecycle handled here
* 1. {@link subscribeToPush} - feature-detect Service Worker + Push support, wait for the
* active service worker, fetch the server's VAPID public key, ask the browser's
* `PushManager` to create a subscription bound to that key, then POST the resulting
* endpoint/keys to the backend so it can target this browser later.
* 2. {@link unsubscribeFromPush} - tear the subscription down in the browser and DELETE it
* on the backend so no further deliveries are attempted to a dead endpoint.
*
* Both entry points are idempotent and fail-soft: they no-op on unsupported browsers and on
* the "already in the desired state" case, so callers can invoke them freely (e.g. on every
* app load, or on a settings toggle) without tracking prior state themselves.
*
* @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
* - `subscribeToPush` — exported API; see TSDoc on the symbol for behavior.
* - `unsubscribeFromPush` — 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).
* -----------------------------------------------------------------------------
* **subscribeToPush**
* 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.
*
* **unsubscribeFromPush**
* 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.
*
* ----------------------------------------------------------------------------- */
// ===========================================================================
// VAPID key decoding
// ===========================================================================
/**
* Decodes a URL-safe base64 VAPID public key (as served by
* GET /api/push/vapid-public-key) into the raw byte buffer the Push API's
* `applicationServerKey` option requires.
* @param base64String URL-safe base64 string (`-`/`_` instead of `+`/`/`,
* `=` padding optional - this re-pads before decoding).
* @returns The decoded bytes as an `ArrayBuffer`.
* @remarks Why this dance is necessary: VAPID keys are transmitted in *URL-safe* base64
* (base64url) and usually unpadded, but the browser's `atob` only understands *standard*
* base64 with proper `=` padding. So we:
* 1. Re-pad to a multiple of 4 chars - `(4 - len % 4) % 4` yields 0..3 `=` (the outer
* `% 4` collapses the "already aligned" case from 4 back to 0).
* 2. Translate the URL-safe alphabet back to standard (`-`->`+`, `_`->`/`).
* 3. `atob` to a binary string, then copy char codes into a `Uint8Array`.
* `.buffer` is returned because `applicationServerKey` accepts an `ArrayBuffer`/typed view.
*/
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
// 1. Compute the missing `=` padding so the length is a multiple of 4.
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
// 2. Re-pad and map the base64url alphabet (`-`/`_`) to standard base64 (`+`/`/`).
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
// 3. Decode to a binary string and copy each byte into the output buffer.
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let index = 0; index < rawData.length; index++) {
outputArray[index] = rawData.charCodeAt(index); // each char code is one byte (0..255)
}
return outputArray.buffer;
}
// ===========================================================================
// Subscribe / unsubscribe
// ===========================================================================
/**
* Subscribes the browser to Web Push notifications, if not already
* subscribed. No-ops silently when the browser lacks Service Worker/Push API
* support, or when a subscription already exists (idempotent - safe to call
* on every app load / every time notifications are enabled in settings).
* Fetches the server's VAPID public key, creates the push subscription via
* the active service worker, then registers it with the backend so
* `/api/push/send` can target this browser.
* @returns A promise that resolves once the (possibly new) subscription is registered,
* or immediately when the environment/state makes subscribing unnecessary.
* @remarks
* - Feature-detects both `navigator.serviceWorker` and `window.PushManager`; on older or
* non-secure contexts (Push requires HTTPS/localhost) it simply returns.
* - `serviceWorker.ready` resolves only once a service worker is active, so this must run
* after the SW registration has taken control.
* - `userVisibleOnly: true` is mandatory in Chromium-based browsers: it promises every
* push will surface a user-visible notification (no silent pushes).
* - The permission prompt is triggered implicitly by `pushManager.subscribe`; if the user
* denies it, the returned promise rejects and this function throws (callers decide how to
* surface that).
* - `subscription.toJSON()` serializes the endpoint URL plus the `p256dh`/`auth` keys the
* backend needs to encrypt payloads for this browser.
*/
export async function subscribeToPush(): Promise<void> {
// Bail on browsers without Service Worker or Push API (or insecure contexts).
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
const registration = await navigator.serviceWorker.ready; // wait for an active SW
const existing = await registration.pushManager.getSubscription();
if (existing) return; // already subscribed -> idempotent no-op
// Fetch the server's VAPID public key and decode it for `applicationServerKey`.
const res = await fetch("/api/push/vapid-public-key");
const { publicKey } = await res.json();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // required by Chromium: every push must be user-visible
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Register the endpoint + keys with the backend so it can push to this browser.
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()), // { endpoint, keys: { p256dh, auth } }
});
}
/**
* Unsubscribes the browser from Web Push notifications, if currently
* subscribed, and tells the backend to forget the endpoint (so it stops
* attempting deliveries to it). No-ops silently when there's no active
* subscription or the browser lacks Service Worker support.
* @returns A promise that resolves once both the browser-side unsubscribe and the
* backend DELETE have completed (or immediately when there's nothing to remove).
* @remarks Mirrors {@link subscribeToPush}. The endpoint is captured *before* calling
* `subscription.unsubscribe()` because the subscription object's `endpoint` is what the
* backend keys deliveries on - it's read first so the DELETE can identify the right row
* even though the subscription is torn down locally beforehand.
*/
export async function unsubscribeFromPush(): Promise<void> {
if (!("serviceWorker" in navigator)) return; // no SW support -> nothing to do
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return; // not subscribed -> idempotent no-op
const endpoint = subscription.endpoint; // capture before tearing down the subscription
await subscription.unsubscribe(); // remove the browser-side subscription
// Tell the backend to forget this endpoint so it stops attempting deliveries.
await fetch("/api/push/subscribe", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
});
}
+23
View File
@@ -0,0 +1,23 @@
/**
* @file Helpers for WebSocket messages that signal remote SSH sources finished
* syncing and scoped stats (sessions, costs, analytics) should refetch.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ImportProgressMessage, RemoteSourceStatusPayload, WSMessage } from "./types";
/**
* True when a WebSocket message means remote-imported data may have changed and
* pages should refetch API data (not merely show a sync spinner).
*/
export function isRemoteDataRefreshMessage(msg: WSMessage): boolean {
if (msg.type === "remote_data.updated") return true;
if (msg.type === "remote_source.status") {
return (msg.data as RemoteSourceStatusPayload).status === "ok";
}
if (msg.type === "import.progress") {
const d = msg.data as ImportProgressMessage;
return d.phase === "complete" && d.source === "remote";
}
return false;
}
File diff suppressed because it is too large Load Diff