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.
20 KiB
Agent Conversation Viewer Design
Overview
Add a conversation viewer to the SessionDetail page, enabling visual inspection of Main Agent and sub-agent interactions (message content and tool call details), with data sourced from real-time JSONL transcript files.
Problem
The current dashboard tracks agent sessions, events, and tool usage at a summary level, but does not expose the actual conversation content — user messages, assistant replies, tool call parameters, and tool results. Users cannot see what each agent actually did or said, limiting debugging and audit capabilities.
v2 Additional Problems: Poor Pagination UX + No Real-time Updates
After v1 implementation, two core UX issues emerged:
- Pagination doesn't match conversation intuition — v1 uses offset-based pagination starting from the beginning, so users see the oldest messages first and must page through to reach recent interactions, which doesn't align with chat product conventions.
- No real-time updates — v1 doesn't subscribe to WebSocket events, so users must manually refresh to see new messages, making it impossible to follow active sessions in real time.
- Sub-agent selection uses database IDs — v1's
agent_idparameter relies on database agent IDs, but JSONL files are named with short IDs (e.g.ad18a79192af10ed1), causing a mismatch that prevents sub-agent transcripts from loading.
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Data source | Real-time JSONL reads | Data is always current, no extra storage needed |
| UI location | Conversation tab within SessionDetail | User-requested; keeps agent tree in the same context |
| Claude home path | Configurable via CLAUDE_HOME env var |
Supports non-default paths like ~/.codefuse/engine/cc/ |
| Message rendering | Collapsible tool calls and thinking blocks | Keeps the view scannable; expand for details |
| Load strategy (v2) | Chat-flow: load latest N by default, scroll up for history | Matches chat product intuition; users care most about recent interactions |
| Real-time updates (v2) | WebSocket new_event triggers incremental load |
Active sessions don't need manual refresh |
| Agent selection (v2) | Filesystem scan + dropdown | Bypasses database ID mismatch by using file short IDs directly |
Architecture
Data Flow
v1 (deprecated):
User clicks "Conversation" tab
→ Frontend calls GET /api/sessions/:id/transcript[?agent_id=xxx&limit=50&offset=0]
→ Server resolves JSONL path via claude-home.js
→ Server reads and parses JSONL file
→ Server returns structured message list
→ Frontend renders MessageList (with collapsible blocks)
v2 Chat-flow (current implementation):
Initial load:
User opens Conversation tab
→ GET /api/sessions/:id/transcripts ← fetch available transcript list
→ GET /api/sessions/:id/transcript?limit=50 ← default returns latest 50 messages
→ Frontend renders message list + auto-scrolls to bottom
Real-time updates:
CLI Hook → POST /api/hooks/event → processEvent()
→ broadcast("new_event", {session_id, ...})
→ WebSocket → ConversationView
→ GET /api/sessions/:id/transcript?after=N ← incremental load
→ Append to bottom + auto-scroll (if user is at bottom)
History load:
User scrolls to top
→ GET /api/sessions/:id/transcript?before=M&limit=50 ← load older messages
→ Prepend to top + preserve scroll position (no jump)
Configurable Claude Home Directory
New module server/lib/claude-home.js centralizes all Claude directory path logic:
CLAUDE_HOME env var (default: ~/.claude)
├── projects/<encoded-cwd>/<session-id>.jsonl ← main session transcript
│ (encoding rule: all non-alphanumeric chars → "-", e.g. "/Users/txj/.codefuse" → "-Users-txj--codefuse")
├── projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl ← sub-agent transcript
│ (sub-agent ID format: ad18a79192af10ed1, acompact-f8427be966459435)
└── settings.json ← hooks configuration
Existing hardcoded paths in import-history.js, install-hooks.js, and settings.js are migrated to use this module.
API
GET /api/sessions/:id/transcripts (v2 new)
List available transcript files for a session (main + sub-agents), scanned directly from the filesystem.
Response (200):
{
"transcripts": [
{ "id": "main", "name": "Main Agent", "type": "main", "has_transcript": true },
{ "id": "ad18a79192af10ed1", "name": "code-reviewer", "type": "subagent", "subagent_type": "code-reviewer", "has_transcript": true },
{ "id": "acompact-f8427be966459435", "name": "Context Compaction", "type": "compaction", "has_transcript": true }
]
}
Design notes:
- Bypasses database agent IDs; scans the filesystem directly for JSONL file short IDs
idfield maps directly to the filename:agent-<id>.jsonl, used as theagent_idparameter for thetranscriptAPI- Compaction file name format:
agent-acompact-<hex>.jsonl, id isacompact-<hex> - Attempts to read
.meta.jsonin the same directory for agent type description - Falls back to scanning all
projects/subdirectories when the exact encoded path doesn't exist
GET /api/sessions/:id/transcript
Read a session's JSONL transcript file and return a structured message list.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
agent_id |
string | null | Transcript short ID (from transcripts endpoint); omit for main session |
limit |
number | 50 | Max messages to return (max 200) |
after |
number | null | Incremental mode: only return messages with JSONL line > after (v2 new) |
before |
number | null | History mode: only return the latest N messages with JSONL line < before (v2 new) |
offset |
number | 0 | Legacy pagination offset (compatible, mutually exclusive with after/before) |
Response (200):
{
"messages": [
{
"type": "user",
"timestamp": "2026-04-24T10:23:45Z",
"content": [
{ "type": "text", "text": "Please implement the login feature" }
]
},
{
"type": "assistant",
"timestamp": "2026-04-24T10:23:52Z",
"model": "claude-sonnet-4-6",
"usage": { "input_tokens": 1500, "output_tokens": 800 },
"content": [
{ "type": "text", "text": "I'll help you implement the login feature." },
{ "type": "thinking", "text": "Let me analyze the codebase..." },
{
"type": "tool_use",
"name": "Read",
"id": "toolu_abc123",
"input": { "file_path": "/src/auth.ts" }
}
]
}
],
"total": 120,
"has_more": true,
"last_line": 523,
"first_line": 474
}
v2 New Response Fields:
| Field | Type | Description |
|---|---|---|
last_line |
number | JSONL line number of the last message in the current response; used as the after parameter for incremental requests |
first_line |
number | JSONL line number of the first message in the current response; used as the before parameter for history loading |
Loading Modes:
| Mode | Parameters | Behavior | Use Case |
|---|---|---|---|
| Default | No after/before/offset | Return the latest N messages | Initial load |
| Incremental | after=N |
Return messages with line > N (up to limit) | WebSocket-triggered new message loading |
| History | before=M |
Return the latest N messages with line < M | Scroll-up to load older messages |
| Compatible | offset=K |
Skip first K, return next N | Legacy pagination (kept for compatibility) |
Error Responses:
| Status | Condition |
|---|---|
| 200 | When JSONL file doesn't exist, returns empty { messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 } |
| 404 | Session ID not found in database |
Implementation Rules:
- Only extract entries with
type: "user"ortype: "assistant"; skip system/progress entries - Match
tool_useandtool_resultviaidfield; unpaired tool_use shows no result section - Truncate individual content exceeding 10KB, appending
[truncated] - Re-read the file on every request (no server-side caching) to ensure real-time freshness
- When
cwdis null, scan allprojects/subdirectories to find the JSONL for the sessionId - Internally use JSONL line numbers as cursors; remove the
linefield from responses, exposefirst_line/last_lineto the client
Frontend
SessionDetail Page Changes
Replace the current flat layout with a tabbed interface:
[Agents] [Conversation] [Timeline]
- Agents tab — existing agent hierarchy tree (active by default)
- Conversation tab — new conversation viewer
- Timeline tab — existing event timeline
Conversation Tab Components
v2 Chat-flow architecture:
ConversationView.tsx
├── TranscriptSelector — dropdown selector (v2 replaces AgentFilter)
├── ScrollContainer — scrollable message container
│ ├── HistoryLoader — scroll-up history loading indicator
│ └── MessageList.tsx
│ ├── UserMessage — user message
│ └── AssistantMessage
│ ├── TextBlock — plain text content
│ ├── ThinkingBlock — collapsible thinking content
│ └── ToolCallBlock — collapsible tool call + result
│ ├── ToolUse — tool name + parameters
│ └── ToolResult — execution result / error
└── NewMsgButton — "New messages" floating button (v2 new)
TranscriptSelector (v2 replaces AgentFilter)
- Top dropdown selector:
[Main Agent ▾]or[Context Compaction ▾] - Data source:
GET /api/sessions/:id/transcripts(filesystem scan, not database) - Reloads the corresponding transcript on switch
- Only shown when transcripts > 1
- Message count displayed alongside:
518 messages
Chat-flow Behavior (v2 new)
Initial load:
- Call
transcript?limit=50to get the latest 50 messages - Auto-scroll to bottom after rendering
- Track
last_lineandfirst_linefor subsequent requests
Real-time updates (WebSocket-driven):
- Subscribe to
eventBusnew_eventevents - Only process events where
session_idmatches the current session - On event, call
transcript?after=last_line&limit=50for incremental loading - If user is at bottom (< 100px from bottom), auto-scroll to latest message
- If user has scrolled up, show "New messages" floating button; click to scroll to bottom
Scroll-up history loading:
- Listen for scroll events; trigger when
scrollTop < 50andhas_moreis true - Call
transcript?before=first_line&limit=50to fetch older messages - Prepend to top of list; preserve scroll position via
scrollHeightdelta - Show spinner while loading; show "↑ Scroll up for older messages" hint at top
Key Refs:
lastLineRef— tracks the JSONL line number of the newest message, used for incremental requestsfirstLineRef— tracks the JSONL line number of the oldest loaded message, used for history loadingscrollContainerRef— scroll container DOM referenceisAtBottomRef— boolean flag tracking whether user is at the bottom
Message Rendering
- User messages: right-aligned, blue background, display text content
- Assistant messages: left-aligned, default background, including:
- Model name and token usage as faded metadata
- Text blocks rendered inline
- Thinking blocks: collapsed by default, click to expand (dimmed style)
- Tool calls: collapsed by default showing only tool name, click to expand:
- Tool name as header with icon
- Input parameters formatted as JSON (collapsible)
- Tool result with success/error indicator
Interaction Details
- Long text truncation: content over 500 characters is truncated by default, with an "expand" link
- Lazy loading (v2): initial load of latest 50 messages; scroll-up auto-loads older 50; WebSocket-driven incremental append
- Real-time updates (v2): on WebSocket
new_eventwith matchingsession_id, incrementally load new messages - Auto-scroll (v2): auto-scroll to latest when user is at bottom; show floating "New messages" button when user has scrolled up
- Empty state: when JSONL is missing or empty, show "No conversation records found."
Server Module: claude-home.js
// Centralized Claude home directory path management
function getClaudeHome() {
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
}
function getProjectsDir() {
return path.join(getClaudeHome(), "projects");
}
function getSettingsPath() {
return path.join(getClaudeHome(), "settings.json");
}
// Encoding rule: all non-alphanumeric characters replaced with "-"
// Example: "/Users/txj/.codefuse" → "-Users-txj--codefuse"
function encodeCwd(cwd) {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}
function getTranscriptPath(sessionId, cwd) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
// Fallback: scan projects/ subdirectories
return findTranscriptPath(sessionId);
}
function getSubagentTranscriptPath(sessionId, cwd, agentId) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(getProjectsDir(), encoded, sessionId, "subagents", `agent-${agentId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
// Fallback: scan all project directories
return findSubagentTranscriptPath(sessionId, agentId);
}
function findTranscriptPath(sessionId) {
// Fallback: when cwd is unknown, scan projects/ subdirectories
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
// v2 new: support prefix fuzzy matching for compaction type
function findSubagentTranscriptPath(sessionId, agentId) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
if (!fs.existsSync(subagentsDir)) continue;
// Exact match
const exact = path.join(subagentsDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(exact)) return exact;
// Prefix fuzzy match (compaction type: agentId starts with "acompact-")
if (agentId.startsWith("acompact-")) {
const files = fs.readdirSync(subagentsDir);
const match = files.find(f => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subagentsDir, match);
}
}
return null;
}
File Changes
| File | Action | Description |
|---|---|---|
server/lib/claude-home.js |
New | Claude home directory path management; v2 adds findSubagentTranscriptPath prefix fuzzy matching |
server/routes/sessions.js |
Modified | v1: add GET /sessions/:id/transcript; v2: add GET /sessions/:id/transcripts, transcript endpoint gains after/before params and first_line/last_line response |
scripts/import-history.js |
Modified | Use getClaudeHome() instead of hardcoded path |
scripts/install-hooks.js |
Modified | Use getSettingsPath() instead of hardcoded path |
server/routes/settings.js |
Modified | Use getClaudeHome() for hooks detection |
client/src/lib/types.ts |
Modified | v1: add TranscriptMessage, TranscriptContent; v2: add TranscriptInfo, TranscriptListResult, TranscriptResult gains last_line/first_line |
client/src/lib/api.ts |
Modified | v1: add sessions.transcript(); v2: add sessions.transcripts(), transcript() gains after/before params |
client/src/pages/SessionDetail.tsx |
Modified | Add tab switching and Conversation tab; v2: remove agents prop from ConversationView |
client/src/components/conversation/ConversationView.tsx |
New → v2 rewrite | v1: basic pagination; v2: chat-flow mode (WebSocket incremental + scroll-up history + auto-scroll) |
client/src/components/conversation/MessageList.tsx |
New | Message list (with collapsible blocks, command formatting, skill content folding, task notification folding) |
client/src/components/conversation/ToolCallBlock.tsx |
New | Collapsible tool call display |
Error Handling
| Scenario | Handling |
|---|---|
| JSONL file doesn't exist | Return { messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 }; UI shows "No conversation records found." |
| JSONL line parse failure | Skip the line, continue processing remaining lines |
| Single content exceeds 10KB | Truncate and append [truncated] marker |
| Sub-agent JSONL doesn't exist | Same as main file — return empty list |
| Session cwd is null | Use findTranscriptPath() to scan project directories |
| CLAUDE_HOME path invalid | Log warning, return empty list |
| Incremental load returns no new messages (v2) | after request returns empty array, frontend silently ignores |
| History load failure (v2) | Silent failure, doesn't interrupt user experience |
| WebSocket disconnection (v2) | Doesn't affect loaded messages; next event after reconnect triggers incremental load |
Edge Cases
- Compaction: After
/compact, older messages are lost from the JSONL. The viewer only shows what's currently in the file — this is expected behavior. Compact transcripts appear as separate entries in the transcript selector. - Active sessions: JSONL may be actively written to. Every request re-reads the file for real-time freshness. WebSocket events trigger incremental loading — no polling needed.
- Unpaired tool_use/tool_result: Display the tool call without the result section; no error.
- Message order: JSONL is ordered chronologically; responses preserve the same order (oldest first).
- Database ID vs file ID mismatch (v2): Database agent IDs use format
<sessionId>-jsonl-<shortId>, but JSONL filenames useagent-<shortId>.jsonl. v2 bypasses database IDs entirely via thetranscriptsendpoint, which scans the filesystem and uses file short IDs. - Compaction filename format (v2): In the database, compaction agent IDs use format
<sessionId>-compact-<uuid>, but filenames useagent-acompact-<hex>.jsonl.findSubagentTranscriptPathsupports prefix fuzzy matching foragent-acompact-*.jsonl. - Scroll position preservation (v2): When loading history, the scroll position is preserved by computing the
scrollHeightdelta, ensuring the viewport content doesn't jump. - Duplicate events (v2): WebSocket may send multiple
new_eventmessages; incremental loading usesafterline number for deduplication, preventing duplicate appends.
Testing Strategy
| Layer | Test Content |
|---|---|
| API unit tests | GET /sessions/:id/transcript — normal response, file not found, invalid session, pagination params, agent_id filtering |
| API unit tests | GET /sessions/:id/transcript — v2: after incremental loading, before history loading, first_line/last_line response |
| API unit tests | GET /sessions/:id/transcripts — v2: file scanning, compaction type, meta.json reading |
| API unit tests | claude-home.js — path inference logic, env var override, fallback scanning, compaction prefix fuzzy matching |
| Frontend component tests | MessageList rendering, ToolCallBlock collapse/expand, command formatting, skill content folding |
| Frontend component tests | ConversationView — v2: initial load, incremental append, history load, scroll detection, new messages indicator |
Environment Variables
| Variable | Default | Description |
|---|---|---|
CLAUDE_HOME |
~/.claude |
Claude Code home directory (e.g. ~/.codefuse/engine/cc/) |