/** * @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ĩ */ /* ============================================================================= * 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([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [loadingHistory, setLoadingHistory] = useState(false); const [selectedTranscript, setSelectedTranscript] = useState( initialTranscriptId ?? null ); const [hasMore, setHasMore] = useState(false); const [error, setError] = useState(null); const [transcripts, setTranscripts] = useState([]); 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(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 (
{/* Toolbar - always rendered after the initial load so users can refresh even when no messages have streamed yet. */} {!loading && (
{transcripts.length > 1 && (
)} {total} message{total !== 1 ? "s" : ""}
)} {/* Error alert */} {error && (
{error}
)} {/* Message list container */}
{/* History loading indicator */} {loadingHistory && (
Loading history...
)} {/* Scroll-up for history hint */} {hasMore && !loadingHistory && !loading && (
↑ Scroll up for older messages
)} {loading ? (
Loading conversation...
) : messages.length === 0 ? (

No conversation records found.

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 (cleanupPeriodDays, 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.

) : ( )}
{/* New messages indicator */} {showNewMsg && ( )}
); }