Files
Claude-Code-Monitor/docs/superpowers/specs/2026-04-24-agent-conversation-viewer-design.md
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

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:

  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):

{
  "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):

{
  "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

// 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/)