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,258 @@
/**
* @file CodeBlock.tsx
* @description Reusable, syntax-highlighted code block with a chrome bar (language pill,
* optional filename, copy-to-clipboard, line count) and optional gutter line numbers.
* Used by MarkdownContent for fenced code blocks and by ToolCallBlock for tool I/O.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `../../lib/highlight`
*
* ## Public surface
* - `CodeBlock` — 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).
* -----------------------------------------------------------------------------
* **CodeBlock**
* 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 { useMemo, useState } from "react";
import { Check, Copy, FileCode } from "lucide-react";
import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight";
interface CodeBlockProps {
code: string;
lang?: string;
/** Optional filename to display in the chrome bar. */
filename?: string;
/** Render compact (no chrome bar). */
compact?: boolean;
/** Override for the right-side label (e.g. "Output", "Error"). */
label?: string;
/** Tone - "default" matches the surface, "danger" tints red, "success" tints emerald. */
tone?: "default" | "danger" | "success";
/** Cap the rendered height; pass null to disable. Default 24rem. */
maxHeight?: string | null;
/** Show a left gutter with line numbers. Default true for >= 4 lines. */
showLineNumbers?: boolean;
}
const LANG_DISPLAY: Record<string, string> = {
js: "JavaScript",
ts: "TypeScript",
python: "Python",
json: "JSON",
bash: "Shell",
html: "HTML",
css: "CSS",
sql: "SQL",
yaml: "YAML",
diff: "Diff",
plain: "Text",
};
function langDisplay(lang: string): string {
const canon = canonicalLang(lang);
return LANG_DISPLAY[canon] ?? (lang || "Text");
}
/**
* Split tokens that span multiple lines so we can render one line at a time
* (necessary for the gutter line-number column to align).
*/
function splitTokensByLine(tokens: Token[]): Token[][] {
const lines: Token[][] = [[]];
for (const t of tokens) {
const parts = t.text.split("\n");
for (let i = 0; i < parts.length; i++) {
if (i > 0) lines.push([]);
const piece = parts[i]!;
if (piece.length > 0) {
lines[lines.length - 1]!.push({ type: t.type, text: piece });
}
}
}
return lines;
}
export function CodeBlock({
code,
lang = "",
filename,
compact = false,
label,
tone = "default",
maxHeight = "24rem",
showLineNumbers,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const tokens = useMemo(() => highlight(code, lang), [code, lang]);
const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]);
const totalLines = lineTokens.length;
const gutter = showLineNumbers ?? totalLines >= 4;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be unavailable in some contexts - fail silently.
}
};
const palette =
tone === "danger"
? {
wrapper: "border-red-500/30 bg-red-500/5",
chrome: "bg-red-500/10 border-b border-red-500/20",
label: "text-red-300",
}
: tone === "success"
? {
wrapper: "border-emerald-500/30 bg-emerald-500/5",
chrome: "bg-emerald-500/10 border-b border-emerald-500/20",
label: "text-emerald-300",
}
: {
wrapper: "border-surface-3 bg-surface-4/50",
chrome: "bg-surface-3/70 border-b border-surface-3",
label: "text-gray-400",
};
const preStyle: React.CSSProperties = {};
if (maxHeight) preStyle.maxHeight = maxHeight;
return (
<div
className={`group/code rounded-md border overflow-hidden shadow-[0_1px_0_rgba(255,255,255,0.02)_inset,0_2px_8px_-4px_rgba(0,0,0,0.4)] ${palette.wrapper}`}
>
{!compact && (
<div className={`flex items-center gap-2 px-3 py-1.5 text-[11px] ${palette.chrome}`}>
{/* Language pill */}
<span
className={`inline-flex items-center gap-1 font-mono uppercase tracking-wider ${palette.label}`}
>
{filename ? <FileCode className="w-3 h-3 opacity-70" /> : null}
{filename ?? label ?? langDisplay(lang)}
</span>
{/* Filename + lang together when both are set */}
{filename && !label && (
<span className="text-gray-600 font-mono lowercase">{langDisplay(lang)}</span>
)}
{filename && label && (
<span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span>
)}
{/* Right side: line count + copy */}
<div className="ml-auto flex items-center gap-3">
{totalLines > 1 && (
<span className="text-gray-600 font-mono">
{totalLines} {totalLines === 1 ? "line" : "lines"}
</span>
)}
<button
type="button"
onClick={handleCopy}
className={`inline-flex items-center gap-1 transition-colors ${
copied ? "text-emerald-300" : "text-gray-500 hover:text-gray-200"
}`}
aria-label="Copy code"
>
{copied ? (
<>
<Check className="w-3 h-3" /> Copied
</>
) : (
<>
<Copy className="w-3 h-3" /> Copy
</>
)}
</button>
</div>
</div>
)}
<div className="overflow-auto" style={preStyle}>
<pre className="font-mono text-[12.5px] leading-[1.6]">
<code>
{gutter ? (
<table className="border-collapse" style={{ width: "max-content", minWidth: "100%" }}>
<tbody>
{lineTokens.map((line, i) => (
<tr key={i} className="align-top">
<td
className="select-none text-right pl-3 pr-3 text-gray-600 font-mono text-[11px] leading-[1.6] sticky left-0 bg-inherit"
style={{ width: "1%", whiteSpace: "nowrap" }}
>
{i + 1}
</td>
<td className="pl-0 pr-3 whitespace-pre">
{line.length === 0 ? (
<span>&nbsp;</span>
) : (
line.map((t, j) => (
<span key={j} className={tokenClass(t.type)}>
{t.text}
</span>
))
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="px-3 py-2 whitespace-pre">
{tokens.map((t, i) => (
<span key={i} className={tokenClass(t.type)}>
{t.text}
</span>
))}
</div>
)}
</code>
</pre>
</div>
</div>
);
}
@@ -0,0 +1,495 @@
/**
* @file ConversationView.tsx
* @description Conversation tab on the Session detail page. Loads a session
* (or sub-agent) JSONL transcript, paginates it incrementally, and renders
* the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `../../lib/api`
* - `../../lib/eventBus`
* - `./MessageList`
* - `../../lib/types`
*
* ## Public surface
* - `ConversationView` — 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).
* -----------------------------------------------------------------------------
* **ConversationView**
* 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 { useEffect, useState, useCallback, useRef } from "react";
import { ChevronDown, Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any
// assistant text written between two hook fires is invisible until the next
// hook event. A short visibility-gated poll closes that gap and also rescues
// the conversation from missed/late WebSocket frames.
const POLL_INTERVAL_MS = 3000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
interface ConversationViewProps {
sessionId: string;
initialTranscriptId?: string | null;
}
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedTranscript, setSelectedTranscript] = useState<string | null>(
initialTranscriptId ?? null
);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
// Track JSONL line numbers for incremental requests and history loading
const lastLineRef = useRef(0);
const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll,
// manual refresh), we queue exactly one re-fetch so events that landed
// during the in-flight request aren't silently dropped.
const pendingFetchRef = useRef(false);
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
const [refreshing, setRefreshing] = useState(false);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => {
let cancelled = false;
async function loadTranscripts() {
try {
const result = await api.sessions.transcripts(sessionId);
if (cancelled) return;
setTranscripts(result.transcripts);
} catch {
// Non-fatal
}
}
loadTranscripts();
const interval = window.setInterval(loadTranscripts, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [sessionId]);
// Sync external initialTranscriptId to internal state
useEffect(() => {
if (initialTranscriptId != null) {
setSelectedTranscript(initialTranscriptId);
}
}, [initialTranscriptId]);
// Initial load: fetch the latest N messages
useEffect(() => {
let cancelled = false;
async function load() {
try {
setError(null);
setLoading(true);
setShowNewMsg(false);
const result = await api.sessions.transcript(sessionId, {
agent_id: selectedTranscript || undefined,
limit: 50,
});
if (cancelled) return;
setMessages(result.messages);
setTotal(result.total);
setHasMore(result.has_more);
lastLineRef.current = result.last_line;
firstLineRef.current = result.first_line;
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load transcript");
setMessages([]);
setTotal(0);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [sessionId, selectedTranscript]);
// Incrementally load new messages. Two modes:
// - bootstrap (lastLineRef === 0): the initial load saw an empty
// transcript, so we pull the latest 50 to seed the view. This unblocks
// fresh sessions where the JSONL hadn't been written yet at mount.
// - incremental (lastLineRef > 0): tail-fetch lines after the highest
// parsed message we've seen. The server already de-overlaps via
// afterLine, so we can safely append.
const fetchNewMessages = useCallback(async () => {
if (fetchingRef.current) {
// Coalesce: remember a trigger arrived during this fetch and re-run
// exactly once when the in-flight request settles.
pendingFetchRef.current = true;
return;
}
fetchingRef.current = true;
pendingFetchRef.current = false;
const wasBootstrap = lastLineRef.current === 0;
try {
const result = await api.sessions.transcript(sessionId, {
agent_id: selectedTranscript || undefined,
...(wasBootstrap ? {} : { after: lastLineRef.current }),
limit: 50,
});
if (result.messages.length === 0) return;
lastLineRef.current = result.last_line;
if (wasBootstrap) {
// Seed the view in a single render so the user sees the whole
// catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages);
firstLineRef.current = result.first_line;
setHasMore(result.has_more);
} else {
setMessages((prev) => [...prev, ...result.messages]);
}
setTotal(result.total);
// Auto-scroll if user is at bottom; otherwise show "new messages" indicator
if (isAtBottomRef.current) {
scrollToBottom();
} else {
setShowNewMsg(true);
}
} catch {
// Non-fatal
} finally {
fetchingRef.current = false;
// Drain a queued trigger if one arrived during the fetch.
if (pendingFetchRef.current) {
pendingFetchRef.current = false;
// Defer one tick so React state updates from this call commit first.
setTimeout(() => fetchNewMessages(), 0);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, selectedTranscript]);
// WebSocket subscription: refetch on every new_event for this session.
// Hook coverage isn't complete (a user-typed message fires no hook), so we
// also poll below to catch what WS misses.
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
if (isRemoteDataRefreshMessage(msg)) {
fetchNewMessages();
return;
}
if (msg.type !== "new_event") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchNewMessages();
});
return unsubscribe;
}, [sessionId, fetchNewMessages]);
// Resync on WebSocket reconnect: events that landed during a transient
// disconnect are gone from the bus, but the JSONL still has them, so a
// single tail-fetch on reconnect catches the conversation up.
useEffect(() => {
return eventBus.onConnection((connected) => {
if (connected) fetchNewMessages();
});
}, [fetchNewMessages]);
// Visibility-gated polling fallback. Covers:
// 1. User-typed messages (no Claude Code hook fires for those).
// 2. Long assistant turns where text streams between hook fires.
// 3. Late JSONL flushes that arrive after the triggering hook's fetch.
// 4. Dropped/missed WebSocket frames.
useEffect(() => {
let interval: number | null = null;
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (document.visibilityState === "visible") fetchNewMessages();
}, POLL_INTERVAL_MS);
}
function stop() {
if (interval !== null) {
window.clearInterval(interval);
interval = null;
}
}
function onVisibility() {
if (document.visibilityState === "visible") {
// Tab just became visible - fire a one-shot catch-up immediately
// and resume polling. Backgrounded tabs throttle setInterval, so
// restarting on focus avoids a stale conversation.
fetchNewMessages();
start();
} else {
stop();
}
}
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
const refresh = useCallback(async () => {
setRefreshing(true);
try {
await fetchNewMessages();
} finally {
setRefreshing(false);
}
}, [fetchNewMessages]);
// Scroll-up to load history
const loadHistory = useCallback(async () => {
if (loadingHistory || !hasMore) return;
// Need the first message's line number
// Since message objects don't have a _line field, we track it via firstLineRef
// firstLineRef is updated on initial load and each history load
try {
setLoadingHistory(true);
const container = scrollContainerRef.current;
const prevScrollHeight = container?.scrollHeight ?? 0;
const result = await api.sessions.transcript(sessionId, {
agent_id: selectedTranscript || undefined,
before: firstLineRef.current || undefined,
limit: 50,
});
if (result.messages.length === 0) {
// Nothing older exists - clear hasMore so the hint stops showing
// even if the server still claims more is available.
setHasMore(false);
setLoadingHistory(false);
return;
}
// Update firstLineRef to the oldest message's line number in the history batch
firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]);
setHasMore(result.has_more);
// Preserve scroll position (don't jump to top)
requestAnimationFrame(() => {
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - prevScrollHeight;
}
});
} catch {
// Non-fatal
} finally {
setLoadingHistory(false);
}
}, [sessionId, selectedTranscript, loadingHistory, hasMore]);
// Scroll to bottom
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
const container = scrollContainerRef.current;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}, []);
// Listen for scroll events: detect bottom position + trigger history load
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Detect if at bottom
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
isAtBottomRef.current = atBottom;
// Hide "new messages" indicator when scrolled to bottom
if (atBottom) {
setShowNewMsg(false);
}
// Load history when scrolled to top
if (container.scrollTop < 50 && hasMore && !loadingHistory) {
loadHistory();
}
}, [hasMore, loadingHistory, loadHistory]);
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
scrollToBottom();
}
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
<select
value={selectedTranscript || ""}
onChange={(e) => setSelectedTranscript(e.target.value || null)}
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-gray-300 focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
>
{transcripts.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 text-gray-500 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
)}
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500 font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
<MessagesSquare className="w-3 h-3" />
{total} message{total !== 1 ? "s" : ""}
</span>
<button
type="button"
onClick={refresh}
disabled={refreshing || loading}
title="Refresh conversation"
aria-label="Refresh conversation"
className="inline-flex items-center gap-1.5 text-[11px] text-gray-400 hover:text-gray-200 bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
)}
{/* Error alert */}
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 flex-shrink-0">
{error}
</div>
)}
{/* Message list container */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" />
<span className="text-xs text-gray-500 ml-2">Loading history...</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-gray-600"> Scroll up for older messages</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
Loading conversation...
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-gray-400">No conversation records found.</p>
<p className="mt-2 text-xs leading-relaxed text-gray-500">
This session's metadata was imported, but its transcript file is no longer on disk.
Claude Code automatically deletes inactive session transcripts after a retention
period (<code className="text-gray-400">cleanupPeriodDays</code>, default 30 days), so
older conversations may already be gone. Sessions imported from now on are snapshotted
and kept even after Claude Code prunes the originals.
</p>
</div>
) : (
<MessageList messages={messages} loading={false} />
)}
</div>
{/* New messages indicator */}
{showNewMsg && (
<button
onClick={() => {
scrollToBottom();
setShowNewMsg(false);
}}
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-violet-600 hover:bg-violet-500 text-white text-xs font-medium px-3 py-1.5 rounded-full shadow-lg transition-colors z-10"
>
<ArrowDown className="w-3 h-3" />
New messages
</button>
)}
</div>
);
}
@@ -0,0 +1,513 @@
/**
* @file MarkdownContent.tsx
* @description Lightweight markdown renderer for conversation messages. Supports the
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
* copy-to-clipboard.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `./CodeBlock`
*
* ## Public surface
* - `MarkdownContent` — 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).
* -----------------------------------------------------------------------------
* **MarkdownContent**
* 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 React from "react";
import { CodeBlock } from "./CodeBlock";
type Block =
| { kind: "code"; lang: string; code: string }
| { kind: "heading"; level: number; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "quote"; text: string }
| { kind: "hr" }
| {
kind: "table";
header: string[];
aligns: ("left" | "center" | "right" | null)[];
rows: string[][];
}
| { kind: "para"; text: string };
const FENCE_RE = /^([ \t]*)(```|~~~)(\s*[\w+-]*)\s*$/;
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
const QUOTE_RE = /^\s*>\s?(.*)$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
function splitTableRow(line: string): string[] {
// Trim leading/trailing pipes, then split, respecting escaped pipes.
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|")) s = s.slice(0, -1);
// Split on unescaped pipes
const parts: string[] = [];
let cur = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && s[i + 1] === "|") {
cur += "|";
i++;
continue;
}
if (s[i] === "|") {
parts.push(cur.trim());
cur = "";
} else {
cur += s[i];
}
}
parts.push(cur.trim());
return parts;
}
function parseAlignments(divider: string): ("left" | "center" | "right" | null)[] {
return splitTableRow(divider).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
if (left && right) return "center";
if (right) return "right";
if (left) return "left";
return null;
});
}
function parseBlocks(src: string): Block[] {
const lines = src.split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
// Fenced code block
const fence = line.match(FENCE_RE);
if (fence) {
const fenceMarker = fence[2]!;
const lang = (fence[3] ?? "").trim();
const codeLines: string[] = [];
i++;
while (i < lines.length) {
const closing = lines[i]!.match(/^([ \t]*)(```|~~~)\s*$/);
if (closing && closing[2] === fenceMarker) {
i++;
break;
}
codeLines.push(lines[i]!);
i++;
}
blocks.push({ kind: "code", lang, code: codeLines.join("\n") });
continue;
}
// Blank line
if (line.trim() === "") {
i++;
continue;
}
// ATX heading
const heading = line.match(HEADING_RE);
if (heading) {
blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! });
i++;
continue;
}
// Horizontal rule
if (HR_RE.test(line)) {
blocks.push({ kind: "hr" });
i++;
continue;
}
// Table: header line followed by an alignment divider
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
const header = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1]!);
i += 2;
const rows: string[][] = [];
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") {
rows.push(splitTableRow(lines[i]!));
i++;
}
blocks.push({ kind: "table", header, aligns, rows });
continue;
}
// Lists
const ulMatch = line.match(UL_RE);
const olMatch = line.match(OL_RE);
if (ulMatch || olMatch) {
const ordered = !!olMatch;
const itemRe = ordered ? OL_RE : UL_RE;
const items: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(itemRe);
if (m) {
items.push(m[3]!);
i++;
while (
i < lines.length &&
lines[i]!.trim() !== "" &&
!lines[i]!.match(UL_RE) &&
!lines[i]!.match(OL_RE) &&
/^\s+\S/.test(lines[i]!)
) {
items[items.length - 1] += "\n" + lines[i]!.trim();
i++;
}
} else {
break;
}
}
blocks.push({ kind: "list", ordered, items });
continue;
}
// Blockquote
if (QUOTE_RE.test(line)) {
const qLines: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(QUOTE_RE);
if (!m) break;
qLines.push(m[1]!);
i++;
}
blocks.push({ kind: "quote", text: qLines.join("\n") });
continue;
}
// Paragraph: collect until a blank line or the start of another block
const paraLines: string[] = [line];
i++;
while (i < lines.length) {
const nl = lines[i]!;
if (
nl.trim() === "" ||
FENCE_RE.test(nl) ||
HEADING_RE.test(nl) ||
HR_RE.test(nl) ||
UL_RE.test(nl) ||
OL_RE.test(nl) ||
QUOTE_RE.test(nl)
) {
break;
}
paraLines.push(nl);
i++;
}
blocks.push({ kind: "para", text: paraLines.join("\n") });
}
return blocks;
}
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
let i = 0;
let buf = "";
let n = 0;
const flush = () => {
if (buf) {
out.push(buf);
buf = "";
}
};
const push = (node: React.ReactNode) => {
flush();
out.push(<React.Fragment key={`${baseKey}-${n++}`}>{node}</React.Fragment>);
};
while (i < text.length) {
const rest = text.slice(i);
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
push(
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-amber-200">
{codeM[1]}
</code>
);
i += codeM[0].length;
continue;
}
// Bold: **...** or __...__
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-semibold text-gray-50">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
i += boldM[0].length;
continue;
}
// Italic: *...* or _..._
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
if (italicM) {
push(
<em className="italic text-gray-200">{renderInline(italicM[2]!, `${baseKey}-i${n}`)}</em>
);
i += italicM[0].length;
continue;
}
// Strikethrough
const strikeM = rest.match(/^~~(.+?)~~/);
if (strikeM) {
push(
<span className="line-through text-gray-500">
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
</span>
);
i += strikeM[0].length;
continue;
}
// Markdown link
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
if (linkM) {
push(
<a
href={linkM[2]!}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</a>
);
i += linkM[0].length;
continue;
}
// Auto-link
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
if (urlM) {
push(
<a
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
);
i += urlM[0].length;
continue;
}
buf += text[i]!;
i++;
}
flush();
return out;
}
/** Render a single list item, handling [ ] / [x] task list prefixes. */
function renderListItem(item: string, key: string): React.ReactNode {
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
if (taskMatch) {
const checked = taskMatch[1]!.toLowerCase() === "x";
return (
<span className="inline-flex items-baseline gap-2">
<span
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
checked ? "bg-emerald-500/40 border-emerald-400/60" : "bg-surface-4 border-surface-3"
}`}
aria-hidden="true"
/>
<span className={checked ? "text-gray-500 line-through" : ""}>
{renderInline(taskMatch[2]!, key)}
</span>
</span>
);
}
return renderInline(item, key);
}
interface MarkdownContentProps {
text: string;
/** Tighter spacing for nested contexts (list items, quotes). */
dense?: boolean;
}
const HEADING_STYLES = [
"text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3",
"text-[16px] font-semibold text-gray-50 mt-2",
"text-[15px] font-semibold text-gray-100",
"text-sm font-semibold text-gray-100",
"text-sm font-medium text-gray-200",
"text-xs font-medium text-gray-300 uppercase tracking-wider",
];
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
const blocks = parseBlocks(text);
const gap = dense ? "space-y-1.5" : "space-y-2.5";
return (
<div className={`text-sm text-gray-300 leading-relaxed ${gap}`}>
{blocks.map((b, idx) => {
switch (b.kind) {
case "code":
return <CodeBlock key={idx} code={b.code} lang={b.lang} />;
case "heading": {
const cls = HEADING_STYLES[b.level - 1] ?? HEADING_STYLES[5];
return (
<div key={idx} className={cls}>
{renderInline(b.text, `h${idx}`)}
</div>
);
}
case "list":
if (b.ordered) {
return (
<ol
key={idx}
className="list-decimal pl-5 space-y-1 marker:text-gray-500 marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-gray-300">
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
</ol>
);
}
return (
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
{b.items.map((item, i) => (
<li key={i} className="text-sm text-gray-300">
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
</ul>
);
case "quote":
return (
<blockquote
key={idx}
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-gray-400 italic bg-violet-500/[0.04] rounded-r"
>
{renderInline(b.text, `q${idx}`)}
</blockquote>
);
case "hr":
return (
<hr
key={idx}
className="border-0 h-px bg-gradient-to-r from-transparent via-surface-3 to-transparent my-2"
/>
);
case "table": {
const alignClass = (a: "left" | "center" | "right" | null) =>
a === "center" ? "text-center" : a === "right" ? "text-right" : "text-left";
return (
<div
key={idx}
className="overflow-x-auto rounded-md border border-surface-3 bg-surface-4/40"
>
<table className="w-full text-xs border-collapse">
<thead className="bg-surface-3/60">
<tr>
{b.header.map((cell, i) => (
<th
key={i}
className={`px-3 py-1.5 font-semibold text-gray-200 border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
>
{renderInline(cell, `th${idx}-${i}`)}
</th>
))}
</tr>
</thead>
<tbody>
{b.rows.map((row, ri) => (
<tr
key={ri}
className="border-b border-surface-3/50 last:border-b-0 hover:bg-surface-3/30"
>
{row.map((cell, ci) => (
<td
key={ci}
className={`px-3 py-1.5 text-gray-300 align-top ${alignClass(b.aligns[ci] ?? null)}`}
>
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
case "para":
return (
<p key={idx} className="text-sm text-gray-300 whitespace-pre-wrap break-words">
{renderInline(b.text, `p${idx}`)}
</p>
);
}
})}
</div>
);
}
@@ -0,0 +1,522 @@
/**
* @file MessageList.tsx
* @description Renders the chronological message stream of a Claude Code
* transcript: alternating user / assistant rows with collapsible thinking
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `../../lib/types`
* - `./ToolCallBlock`
* - `./MarkdownContent`
* - `../../lib/format`
* - `./tuiSegments`
*
* ## Public surface
* - `MessageList` — 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).
* -----------------------------------------------------------------------------
* **MessageList**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
Bot,
User,
Brain,
ScrollText,
Terminal,
Info,
AlertTriangle,
Pencil,
Workflow,
Cog,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line
* is not always the human (tool results, harness task-notifications, the
* orchestrator's task to a subagent) — each sender gets its own label, icon,
* and accent so attribution is unambiguous. */
const SENDER_STYLES: Record<
TranscriptSender,
{ label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string }
> = {
user: {
label: "User",
icon: User,
avatarRing:
"bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30",
accentBar: "before:bg-blue-500/40",
headerText: "text-blue-200",
},
assistant: {
label: "Assistant",
icon: Bot,
avatarRing:
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
accentBar: "before:bg-violet-500/40",
headerText: "text-violet-200",
},
orchestrator: {
label: "Main agent",
icon: Workflow,
avatarRing:
"bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30",
accentBar: "before:bg-teal-500/40",
headerText: "text-teal-200",
},
system: {
label: "System",
icon: Cog,
avatarRing:
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30",
accentBar: "before:bg-slate-500/40",
headerText: "text-gray-300",
},
tool: {
label: "Tool",
icon: Terminal,
avatarRing:
"bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30",
accentBar: "before:bg-amber-500/40",
headerText: "text-amber-200",
},
};
import { ToolCallBlock } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
interface MessageListProps {
messages: TranscriptMessage[];
loading: boolean;
}
/** Build a map from tool_use id → tool_result for matching */
function buildToolResultMap(messages: TranscriptMessage[]): Map<string, TranscriptContent> {
const map = new Map<string, TranscriptContent>();
for (const msg of messages) {
if (msg.type !== "user") continue;
for (const c of msg.content) {
if (c.type === "tool_result" && c.id) {
map.set(c.id, c);
}
}
}
return map;
}
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
function isSkillContent(text: string): boolean {
return text.startsWith("Base directory for this skill:");
}
/** Detect if text is a task notification (contains <task-notification> tag) */
function isTaskNotification(text: string): boolean {
return text.includes("<task-notification>") || text.includes("<task-id>");
}
/** Format a timestamp as compact local time (e.g. "14:23:01"). */
function formatLocalTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString();
} catch {
return "";
}
}
/** Centered marker for a session rename (/rename, `claude -n`, picker Ctrl+R).
* These TUI-only commands write no conversation turn, so without this they're
* invisible in the transcript. */
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
return (
<div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-gray-400 bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
<span className="text-gray-500">Renamed session </span>
<span className="text-gray-200 font-medium truncate">{title || "(untitled)"}</span>
{timestamp && (
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">
{formatLocalTime(timestamp)}
</span>
)}
</div>
</div>
);
}
/** Compact pill for /command invocations parsed out of TUI markup. */
function CommandPill({ display }: { display: string }) {
return (
<div className="inline-flex items-center gap-2 text-sm text-emerald-300 font-mono bg-emerald-500/10 border border-emerald-500/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-emerald-500/70"></span>
<span className="break-all">{display}</span>
</div>
);
}
/** Terminal-style fenced block for stdout/stderr captured from local commands. */
function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "stderr" }) {
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
const isErr = stream === "stderr";
const accent = isErr
? "border-red-500/30 bg-red-950/30 text-red-200/90"
: "border-surface-3 bg-surface-4/60 text-gray-200";
const labelColor = isErr ? "text-red-300/80" : "text-gray-400";
return (
<div className={`rounded-lg border ${accent} overflow-hidden`}>
<div
className={`flex items-center gap-1.5 px-3 py-1 text-[10px] uppercase tracking-wider border-b border-current/10 ${labelColor}`}
>
<Terminal className="w-3 h-3" />
<span>{stream}</span>
</div>
<pre className="px-3 py-2 text-xs font-mono whitespace-pre-wrap break-words leading-relaxed max-h-96 overflow-y-auto">
{cleaned}
</pre>
</div>
);
}
/** Subtle inline note for the local-command-caveat banner. */
function CaveatBlock({ text }: { text: string }) {
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-[11px] text-amber-200/70">
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
</div>
);
}
/** Render a single segment produced by parseTuiSegments. */
function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
switch (seg.kind) {
case "command":
return <CommandPill key={key} display={seg.display} />;
case "stdout":
return <TerminalBlock key={key} text={seg.text} stream="stdout" />;
case "stderr":
return <TerminalBlock key={key} text={seg.text} stream="stderr" />;
case "caveat":
return <CaveatBlock key={key} text={seg.text} />;
case "system-reminder":
return (
<CollapsibleBlock
key={key}
text={seg.text}
icon={<AlertTriangle className="w-3.5 h-3.5 text-amber-400/70 flex-shrink-0" />}
title="System reminder"
borderClass="border-amber-500/20"
bgClass="bg-amber-500/5"
textClass="text-amber-300/80"
/>
);
case "persisted-output":
return (
<CollapsibleBlock
key={key}
text={seg.text}
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
title="Persisted output"
borderClass="border-violet-500/20"
bgClass="bg-violet-500/5"
textClass="text-violet-300/80"
/>
);
case "text": {
const cleaned = stripAnsi(seg.text);
if (!cleaned.trim()) return null;
return (
<div key={key} className="min-w-0">
<MarkdownContent text={cleaned} />
</div>
);
}
}
}
/** Generic collapsible content block */
function CollapsibleBlock({
text,
icon,
title,
borderClass,
bgClass,
textClass,
}: {
text: string;
icon: React.ReactNode;
title: string;
borderClass: string;
bgClass: string;
textClass: string;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 opacity-60 flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 opacity-60 flex-shrink-0" />
)}
{icon}
<span className={`text-xs ${textClass} truncate`}>{title}</span>
</button>
{expanded && (
<div className="border-t border-current/10 px-3 py-2">
<pre className="text-xs opacity-60 whitespace-pre-wrap break-words leading-relaxed max-h-96 overflow-y-auto">
{text}
</pre>
</div>
)}
</div>
);
}
export function MessageList({ messages, loading }: MessageListProps) {
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => new Set());
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
Loading conversation...
</div>
);
}
if (messages.length === 0) {
return (
<div className="text-center py-12 text-gray-500 text-sm">No conversation records found.</div>
);
}
const toolResultMap = buildToolResultMap(messages);
// Track which user messages are pure tool_result (no text) - we merge those into the preceding assistant message
const userMsgHasText = useMemo(() => {
const map = new Map<number, boolean>();
messages.forEach((msg, idx) => {
if (msg.type !== "user") return;
const hasText = msg.content.some((c) => c.type === "text");
map.set(idx, hasText);
});
return map;
}, [messages]);
return (
<div className="space-y-3">
{messages.map((msg, idx) => {
// Session lifecycle markers (e.g. /rename) render as a centered chip,
// not as a user/assistant row.
if (msg.type === "session_event") {
return <SessionEventRow key={idx} title={msg.title} timestamp={msg.timestamp} />;
}
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
if (msg.type === "user" && !userMsgHasText.get(idx)) {
return null;
}
const isAssistant = msg.type === "assistant";
// The true sender (classified server-side) drives the label + styling.
// Falls back to the coarse type for older payloads without `sender`.
const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user");
const style = SENDER_STYLES[sender] ?? SENDER_STYLES.user;
const SenderIcon = style.icon;
return (
<div
key={idx}
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
>
<SenderIcon className="w-4 h-4" />
</div>
{/* Message body */}
<div className="flex-1 min-w-0 space-y-2">
{/* Header line */}
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
{style.label}
</span>
{msg.model && (
<span className="text-[10px] text-gray-400 font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
{formatModelName(msg.model)}
</span>
)}
{msg.usage && (
<span className="text-[10px] text-gray-500 font-mono inline-flex items-center gap-1">
<span className="text-emerald-300/70"> {fmt(msg.usage.input_tokens)}</span>
<span className="text-gray-700">·</span>
<span className="text-orange-300/70"> {fmt(msg.usage.output_tokens)}</span>
</span>
)}
{msg.timestamp && (
<span className="text-[10px] text-gray-600 ml-auto font-mono">
{formatLocalTime(msg.timestamp)}
</span>
)}
</div>
{/* Content blocks */}
{msg.content.map((block, bIdx) => {
if (block.type === "text" && block.text) {
// Detect task notifications, collapsed by default
if (isTaskNotification(block.text)) {
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
title="Task Notification"
borderClass="border-cyan-500/20"
bgClass="bg-cyan-500/5"
textClass="text-cyan-400/80"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(block.text)) {
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-blue-400/60 flex-shrink-0" />}
title={skillPath}
borderClass="border-blue-500/20"
bgClass="bg-blue-500/5"
textClass="text-blue-400/80"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(block.text)) {
const segments = parseTuiSegments(block.text);
return (
<div key={bIdx} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={bIdx} className="min-w-0">
<MarkdownContent text={stripAnsi(block.text)} />
</div>
);
}
if (block.type === "thinking" && block.text) {
const thinkKey = idx * 100 + bIdx;
const isExpanded = expandedThinking.has(thinkKey);
return (
<div
key={bIdx}
className="rounded-lg border border-amber-500/20 bg-amber-500/5 overflow-hidden"
>
<button
onClick={() =>
setExpandedThinking((prev) => {
const next = new Set(prev);
if (next.has(thinkKey)) next.delete(thinkKey);
else next.add(thinkKey);
return next;
})
}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-amber-500/10 transition-colors"
>
<ChevronRight
className={`w-3.5 h-3.5 text-amber-500/60 transition-transform duration-150 ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Brain className="w-3.5 h-3.5 text-amber-400/80" />
<span className="text-xs text-amber-200/90 font-medium">Thinking</span>
{!isExpanded && (
<span className="text-[10px] text-amber-300/40 font-mono ml-auto">
{block.text.length.toLocaleString()} chars
</span>
)}
</button>
{isExpanded && (
<div className="border-t border-amber-500/10 px-3 py-2 text-amber-100/80">
<MarkdownContent text={block.text} dense />
</div>
)}
</div>
);
}
if (block.type === "tool_use") {
const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null;
return <ToolCallBlock key={bIdx} toolUse={block} toolResult={matchedResult} />;
}
// tool_result blocks rendered inside ToolCallBlock, skip standalone
return null;
})}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,330 @@
/**
* @file ToolCallBlock.tsx
* @description Collapsible block rendered inside an assistant message for each
* tool_use / tool_result pair. Shows tool icon + name in the header, with the
* paired result inline when present. Per-tool styling comes from toolStyle.ts;
* the tool's input/output payload is delegated to <CodeBlock /> for syntax
* highlighting.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `../../lib/types`
* - `./CodeBlock`
* - `./toolStyle`
*
* ## Public surface
* - `ToolCallBlock` — 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).
* -----------------------------------------------------------------------------
* **ToolCallBlock**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useState } from "react";
import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react";
import type { TranscriptContent } from "../../lib/types";
import { CodeBlock } from "./CodeBlock";
import { styleForTool } from "./toolStyle";
interface ToolCallBlockProps {
toolUse: TranscriptContent;
toolResult?: TranscriptContent | null;
}
/** Detect a likely language from a file path's extension. */
function langFromPath(path: string): string {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
ts: "ts",
tsx: "ts",
js: "js",
jsx: "js",
mjs: "js",
cjs: "js",
py: "python",
json: "json",
yml: "yaml",
yaml: "yaml",
sh: "bash",
bash: "bash",
zsh: "bash",
html: "html",
htm: "html",
css: "css",
scss: "css",
sql: "sql",
md: "plain",
txt: "plain",
diff: "diff",
patch: "diff",
};
return map[ext] ?? "plain";
}
/** Build a one-line summary of the tool call to show in the collapsed header. */
function buildSummary(toolUse: TranscriptContent): string | null {
const input = toolUse.input;
if (!input || typeof input !== "object" || "_truncated" in input) return null;
const obj = input as Record<string, unknown>;
if (typeof obj.file_path === "string") return obj.file_path;
if (typeof obj.path === "string") return obj.path;
if (typeof obj.command === "string") return obj.command.slice(0, 200);
if (typeof obj.pattern === "string") return obj.pattern;
if (typeof obj.query === "string") return obj.query;
if (typeof obj.url === "string") return obj.url;
if (typeof obj.description === "string") return obj.description;
return null;
}
/** Render the input pane with tool-aware formatting. */
function renderInput(toolUse: TranscriptContent) {
const input = toolUse.input;
if (!input) return null;
// Truncated payload from the backend
if (typeof input === "object" && "_truncated" in input) {
return (
<CodeBlock
code={String((input as { _truncated: string })._truncated)}
lang="plain"
label="Input (truncated)"
/>
);
}
const obj = input as Record<string, unknown>;
const tool = (toolUse.name ?? "").toLowerCase();
// Bash: show the command with shell highlighting
if (tool === "bash" && typeof obj.command === "string") {
return (
<div className="space-y-2">
<CodeBlock code={obj.command} lang="bash" label="Command" />
{typeof obj.description === "string" && (
<p className="text-xs text-gray-500 italic px-1">{obj.description}</p>
)}
</div>
);
}
// Write: render new content as code with the file path as the chrome label
if (tool === "write" && typeof obj.file_path === "string" && typeof obj.content === "string") {
return (
<CodeBlock
code={obj.content}
lang={langFromPath(obj.file_path)}
filename={obj.file_path}
label="New file"
/>
);
}
// Edit: side-by-side old/new
if (tool === "edit" && typeof obj.file_path === "string") {
const lang = langFromPath(obj.file_path);
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<FileText className="w-3.5 h-3.5 text-violet-400" />
<span className="font-mono">{obj.file_path}</span>
{obj.replace_all === true && (
<span className="text-[10px] uppercase tracking-wider text-amber-300/80 bg-amber-500/10 border border-amber-500/20 rounded px-1.5 py-0.5">
replace all
</span>
)}
</div>
{typeof obj.old_string === "string" && (
<CodeBlock code={obj.old_string} lang={lang} label="Removed" tone="danger" />
)}
{typeof obj.new_string === "string" && (
<CodeBlock code={obj.new_string} lang={lang} label="Added" tone="success" />
)}
</div>
);
}
// Read: just show the path with offset/limit
if (tool === "read" && typeof obj.file_path === "string") {
return (
<div className="flex items-center gap-1.5 text-xs text-gray-300 bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
<span className="font-mono break-all">{obj.file_path}</span>
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
<span className="text-gray-500 font-mono ml-auto flex-shrink-0">
{typeof obj.offset === "number" ? `:${obj.offset}` : ""}
{typeof obj.limit === "number" ? `+${obj.limit}` : ""}
</span>
)}
</div>
);
}
// Grep: pattern + path
if (tool === "grep" && typeof obj.pattern === "string") {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
Pattern
</span>
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
{obj.pattern}
</code>
</div>
{typeof obj.path === "string" && (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
Path
</span>
<code className="font-mono text-gray-300">{obj.path}</code>
</div>
)}
{typeof obj.glob === "string" && (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
Glob
</span>
<code className="font-mono text-gray-300">{obj.glob}</code>
</div>
)}
</div>
);
}
// Default: pretty JSON
return <CodeBlock code={JSON.stringify(obj, null, 2)} lang="json" label="Input" />;
}
/** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? "";
if (text.length === 0) return <div className="text-xs text-gray-500 italic px-1">(empty)</div>;
const isError = !!toolResult.is_error;
const tool = toolName.toLowerCase();
const label = isError ? "Error" : "Output";
// Heuristics for language
let lang = "plain";
const trimmed = text.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
JSON.parse(trimmed);
lang = "json";
} catch {
// fall through
}
} else if (/^(\+\+\+|---|@@) /m.test(text) || /^diff --git /m.test(text)) {
lang = "diff";
} else if (tool === "bash") {
lang = "bash";
}
return <CodeBlock code={text} lang={lang} label={label} tone={isError ? "danger" : "default"} />;
}
export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
const [expanded, setExpanded] = useState(false);
const isError = toolResult?.is_error;
const hasResult = toolResult != null;
const summary = buildSummary(toolUse);
const style = styleForTool(toolUse.name);
const Icon = style.Icon;
const wrapperBorder = isError ? "border-red-500/30" : style.border;
const wrapperBg = isError ? "bg-red-500/5" : "bg-surface-2/60";
return (
<div
className={`rounded-lg border ${wrapperBorder} ${wrapperBg} overflow-hidden transition-colors`}
>
{/* Collapsed/expanded toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
>
<ChevronRight
className={`w-3.5 h-3.5 text-gray-500 flex-shrink-0 transition-transform duration-150 ${
expanded ? "rotate-90" : ""
}`}
/>
<span
className={`flex-shrink-0 inline-flex items-center justify-center w-5 h-5 rounded ${style.chip}`}
>
<Icon className="w-3 h-3" />
</span>
<span className={`font-mono font-medium text-[13px] flex-shrink-0 ${style.text}`}>
{toolUse.name}
</span>
{summary && (
<span className="text-gray-500 text-xs font-mono truncate min-w-0" title={summary}>
{summary}
</span>
)}
<span className="ml-auto flex-shrink-0">
{isError ? (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-red-300 bg-red-500/15 border border-red-500/20 rounded px-1.5 py-0.5">
<AlertCircle className="w-3 h-3" />
error
</span>
) : hasResult ? (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-300/80 bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-0.5">
<CheckCircle2 className="w-3 h-3" />
ok
</span>
) : (
<span className="text-gray-600 text-[10px] uppercase tracking-wider font-mono">
pending
</span>
)}
</span>
</button>
{/* Expanded body */}
{expanded && (
<div className="border-t border-surface-3 bg-surface-1/40 px-3 py-3 space-y-2.5 animate-fade-in">
{renderInput(toolUse)}
{hasResult && renderResult(toolResult, toolUse.name ?? "")}
</div>
)}
</div>
);
}
@@ -0,0 +1,75 @@
/**
* @file MarkdownContent.test.tsx
* @description Tests for the lightweight markdown renderer used by the conversation viewer.
* Focuses on the block parser since the inline parser is well-exercised by snapshot-style
* DOM assertions.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MarkdownContent } from "../MarkdownContent";
describe("<MarkdownContent />", () => {
it("renders fenced code blocks with the language label", () => {
render(<MarkdownContent text={"Here is some code:\n```js\nconst x = 1;\n```"} />);
// The CodeBlock header shows the language
expect(screen.getByText(/javascript/i)).toBeInTheDocument();
// The code text is present (split across syntax-highlighted spans, so use a substring)
expect(screen.getByText(/const/)).toBeInTheDocument();
});
it("renders headings as semantic-looking elements", () => {
render(<MarkdownContent text={"# Title\n\nbody"} />);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("body")).toBeInTheDocument();
});
it("renders unordered and ordered lists", () => {
const { container } = render(<MarkdownContent text={"- one\n- two\n\n1. first\n2. second"} />);
expect(container.querySelectorAll("ul li")).toHaveLength(2);
expect(container.querySelectorAll("ol li")).toHaveLength(2);
});
it("renders blockquotes", () => {
const { container } = render(<MarkdownContent text={"> a quote"} />);
expect(container.querySelector("blockquote")).not.toBeNull();
expect(screen.getByText("a quote")).toBeInTheDocument();
});
it("renders inline code, bold, and italic", () => {
const { container } = render(
<MarkdownContent text={"This has `code`, **bold**, and *italic*."} />
);
expect(container.querySelector("code")).not.toBeNull();
expect(container.querySelector("strong")).not.toBeNull();
expect(container.querySelector("em")).not.toBeNull();
});
it("auto-links bare URLs and renders explicit markdown links", () => {
const { container } = render(
<MarkdownContent text={"See https://example.com or [docs](https://example.com/docs)."} />
);
const links = container.querySelectorAll("a");
expect(links.length).toBe(2);
expect(links[0]!.getAttribute("href")).toBe("https://example.com");
expect(links[1]!.getAttribute("href")).toBe("https://example.com/docs");
// Both should open in a new tab safely
for (const a of links) {
expect(a.getAttribute("target")).toBe("_blank");
expect(a.getAttribute("rel")).toContain("noopener");
}
});
it("renders plain text without any markdown features", () => {
render(<MarkdownContent text={"just a normal sentence."} />);
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
});
it("handles empty input safely", () => {
const { container } = render(<MarkdownContent text="" />);
// Wrapper exists but no block elements
expect(container.firstChild).not.toBeNull();
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
});
});
@@ -0,0 +1,54 @@
/**
* @file MessageList.sender.test.tsx
* @description Verifies the transcript renders each message under its TRUE
* sender label — User / Assistant / Main agent / System — instead of labeling
* every `type:"user"` line "User" (reported transcript mis-attribution).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
return {
type: "user",
timestamp: "2026-06-26T08:14:00.000Z",
content: [{ type: "text", text: "hello" }],
...partial,
} as TranscriptMessage;
}
describe("MessageList — sender attribution", () => {
it("labels each row by its sender, not blanket 'User'", () => {
const messages: TranscriptMessage[] = [
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "on it" }],
}),
msg({
sender: "system",
content: [{ type: "text", text: "<task-notification>\n<task-id>x</task-id>\n" }],
}),
msg({ sender: "orchestrator", content: [{ type: "text", text: "Light research task…" }] }),
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByText("System")).toBeInTheDocument();
expect(screen.getByText("Main agent")).toBeInTheDocument();
});
it("falls back to type-based labels when sender is absent (legacy payloads)", () => {
const messages: TranscriptMessage[] = [
msg({ content: [{ type: "text", text: "hi there" }] }), // no sender → "User"
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
});
});
@@ -0,0 +1,203 @@
/**
* @file toolStyle.ts
* @description Per-tool visual styling - icon component, accent colour, and tinted
* surface classes. Keeps the conversation viewer's tool blocks visually distinct so
* users can scan a long transcript quickly.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `ToolStyle` — exported API; see TSDoc on the symbol for behavior.
* - `styleForTool` — 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).
* -----------------------------------------------------------------------------
* **ToolStyle**
* 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.
*
* **styleForTool**
* 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 {
Wrench,
Terminal,
FileText,
FilePlus2,
FilePen,
Search,
Globe,
Bot,
ListTodo,
Clock,
Sparkles,
FolderTree,
type LucideIcon,
} from "lucide-react";
export interface ToolStyle {
Icon: LucideIcon;
/** Tailwind text colour for the icon and tool name. */
text: string;
/** Tailwind tinted background for the icon chip (15% opacity - sits behind
* the icon glyph; staying low-saturation keeps the icon legible). */
chip: string;
/** Tailwind background for solid fills like progress bars (60% opacity -
* high enough to read at a glance against the dark surface, distinct
* from the chip used for the icon backdrop). */
bar: string;
/** Tailwind border colour for the tool block when not in error state. */
border: string;
}
const VIOLET: ToolStyle = {
Icon: Wrench,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
};
const STYLES: Record<string, ToolStyle> = {
bash: {
Icon: Terminal,
text: "text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-300",
bar: "bg-emerald-500/60",
border: "border-emerald-500/20",
},
read: {
Icon: FileText,
text: "text-sky-300",
chip: "bg-sky-500/15 text-sky-300",
bar: "bg-sky-500/60",
border: "border-sky-500/20",
},
write: {
Icon: FilePlus2,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
},
edit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
multiedit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
grep: {
Icon: Search,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
glob: {
Icon: FolderTree,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
webfetch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
websearch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
task: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
agent: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
todowrite: {
Icon: ListTodo,
text: "text-rose-300",
chip: "bg-rose-500/15 text-rose-300",
bar: "bg-rose-500/60",
border: "border-rose-500/20",
},
schedulewakeup: {
Icon: Clock,
text: "text-orange-300",
chip: "bg-orange-500/15 text-orange-300",
bar: "bg-orange-500/60",
border: "border-orange-500/20",
},
skill: {
Icon: Sparkles,
text: "text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-300",
bar: "bg-fuchsia-500/60",
border: "border-fuchsia-500/20",
},
};
export function styleForTool(toolName: string | undefined | null): ToolStyle {
if (!toolName) return VIOLET;
const key = toolName.toLowerCase().replace(/[^a-z0-9]/g, "");
return STYLES[key] ?? VIOLET;
}
@@ -0,0 +1,190 @@
/**
* @file tuiSegments.ts
* @description Parses Claude TUI tag markup that appears in user messages -
* caveats, command invocations, captured stdout/stderr, system reminders -
* into a flat segment list the renderer can lay out inline. Also strips bare
* ANSI/SGR escape sequences (e.g. "[1m...[22m") that survive the JSONL pipe
* so messages render as plain text instead of leaking codes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## 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
* - `TuiSegment` — exported API; see TSDoc on the symbol for behavior.
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
* - `parseTuiSegments` — exported API; see TSDoc on the symbol for behavior.
* - `hasTuiTags` — 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).
* -----------------------------------------------------------------------------
* **TuiSegment**
* 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.
*
* **stripAnsi**
* 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.
*
* **parseTuiSegments**
* 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.
*
* **hasTuiTags**
* 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.
*
* ----------------------------------------------------------------------------- */
export type TuiSegment =
| { kind: "caveat"; text: string }
| { kind: "stdout"; text: string }
| { kind: "stderr"; text: string }
| { kind: "system-reminder"; text: string }
| { kind: "persisted-output"; text: string }
| { kind: "command"; display: string }
| { kind: "text"; text: string };
const SIMPLE_TAGS: Record<string, TuiSegment["kind"]> = {
"local-command-caveat": "caveat",
"local-command-stdout": "stdout",
"local-command-stderr": "stderr",
"system-reminder": "system-reminder",
"persisted-output": "persisted-output",
};
const COMMAND_TAGS = ["command-name", "command-message", "command-args"] as const;
const KNOWN_TAG_RE = new RegExp(
`<(?:${[...Object.keys(SIMPLE_TAGS), ...COMMAND_TAGS].join("|")})\\b`
);
// Strip both real ESC-prefixed SGR codes and the bare "[Nm" forms that show up
// when the ESC byte is dropped during JSON encoding. Only matches when followed
// by `m` (the SGR terminator), so it does not eat ordinary bracketed text.
const ANSI_RE = /\[[\d;]*m|\[\d+(?:;\d+)*m/g;
export function stripAnsi(text: string): string {
return text.replace(ANSI_RE, "");
}
interface MatchSpan {
start: number;
end: number;
segment: TuiSegment;
}
function findSimpleTagMatches(input: string): MatchSpan[] {
const matches: MatchSpan[] = [];
for (const [tag, kind] of Object.entries(SIMPLE_TAGS)) {
const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g");
let m: RegExpExecArray | null;
while ((m = re.exec(input)) !== null) {
matches.push({
start: m.index,
end: m.index + m[0].length,
segment: { kind, text: m[1] ?? "" } as TuiSegment,
});
}
}
return matches;
}
function findCommandBlocks(input: string): MatchSpan[] {
// A command block is one or more <command-name|message|args> tags possibly
// separated by whitespace. Group them so a single pill renders even when
// the tags arrive in name -> message -> args order.
const re = /(?:<command-(?:name|message|args)>[^<]*<\/command-(?:name|message|args)>\s*){1,3}/g;
const out: MatchSpan[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(input)) !== null) {
const block = m[0];
const name = /<command-name>([^<]*)<\/command-name>/.exec(block)?.[1] ?? "";
const args = /<command-args>([^<]*)<\/command-args>/.exec(block)?.[1] ?? "";
if (!name) continue;
const trimmedArgs = args.trim();
out.push({
start: m.index,
end: m.index + block.length,
segment: {
kind: "command",
display: trimmedArgs ? `${name} ${trimmedArgs}` : name,
},
});
}
return out;
}
/**
* Walks a message text and splits out recognized TUI/command segments while
* preserving the surrounding prose as `text` segments. Returns a single
* `text` segment for inputs that contain no recognized markup.
*/
export function parseTuiSegments(input: string): TuiSegment[] {
if (!KNOWN_TAG_RE.test(input)) {
return [{ kind: "text", text: input }];
}
const matches = [...findSimpleTagMatches(input), ...findCommandBlocks(input)].sort(
(a, b) => a.start - b.start
);
const segments: TuiSegment[] = [];
let cursor = 0;
for (const m of matches) {
if (m.start < cursor) continue;
if (m.start > cursor) {
const between = input.slice(cursor, m.start);
if (between.trim()) {
segments.push({ kind: "text", text: between });
}
}
segments.push(m.segment);
cursor = m.end;
}
if (cursor < input.length) {
const tail = input.slice(cursor);
if (tail.trim()) segments.push({ kind: "text", text: tail });
}
return segments.length > 0 ? segments : [{ kind: "text", text: input }];
}
/** True if any recognized TUI tag would alter the rendering of this text. */
export function hasTuiTags(input: string): boolean {
return KNOWN_TAG_RE.test(input);
}