feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,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> </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);
|
||||
}
|
||||
Reference in New Issue
Block a user