Files
Claude-Code-Monitor/docs/superpowers/specs/2026-04-24-agent-conversation-viewer-design.md
T
nntrivi2001 57dc91585d 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.
2026-07-30 14:39:03 +07:00

419 lines
20 KiB
Markdown

# 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:
1. **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.
2. **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.
3. **Sub-agent selection uses database IDs** — v1's `agent_id` parameter 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):**
```json
{
"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
- `id` field maps directly to the filename: `agent-<id>.jsonl`, used as the `agent_id` parameter for the `transcript` API
- Compaction file name format: `agent-acompact-<hex>.jsonl`, id is `acompact-<hex>`
- Attempts to read `.meta.json` in 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):**
```json
{
"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"` or `type: "assistant"`; skip system/progress entries
- Match `tool_use` and `tool_result` via `id` field; 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 `cwd` is null, scan all `projects/` subdirectories to find the JSONL for the sessionId
- Internally use JSONL line numbers as cursors; remove the `line` field from responses, expose `first_line` / `last_line` to 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=50` to get the latest 50 messages
- Auto-scroll to bottom after rendering
- Track `last_line` and `first_line` for subsequent requests
**Real-time updates (WebSocket-driven):**
- Subscribe to `eventBus` `new_event` events
- Only process events where `session_id` matches the current session
- On event, call `transcript?after=last_line&limit=50` for 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 < 50` and `has_more` is true
- Call `transcript?before=first_line&limit=50` to fetch older messages
- Prepend to top of list; preserve scroll position via `scrollHeight` delta
- 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 requests
- `firstLineRef` — tracks the JSONL line number of the oldest loaded message, used for history loading
- `scrollContainerRef` — scroll container DOM reference
- `isAtBottomRef` — 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_event` with matching `session_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
```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 use `agent-<shortId>.jsonl`. v2 bypasses database IDs entirely via the `transcripts` endpoint, 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 use `agent-acompact-<hex>.jsonl`. `findSubagentTranscriptPath` supports prefix fuzzy matching for `agent-acompact-*.jsonl`.
- **Scroll position preservation (v2)**: When loading history, the scroll position is preserved by computing the `scrollHeight` delta, ensuring the viewport content doesn't jump.
- **Duplicate events (v2)**: WebSocket may send multiple `new_event` messages; incremental loading uses `after` line 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/`) |