7cb4f64781
- A menu the CLI asks with (AskUserQuestion — one question or several,
multiSelect —, a permission or trust prompt, Codex's approval, a
first-start login menu) is read off the terminal under the chat
(`parsePaneMenu`/`readRunMenu`) and shown in the prompt box's place as a
card of buttons. A pick presses its digit (picks it, or ticks its box),
Claude's unnumbered Submit row is reached with the arrows and Enter, and
Cancel presses Esc. "Type something" opens a field in the card: its text
goes in by the arrows, Ctrl+U over anything already typed there, a
bracketed paste and Enter. That row presses no key — a second press of
its digit typed the digit into the CLI's field ("44444…"). Keys verified
on Claude Code 2.1.296 and Codex 0.161. AskUserQuestion fires no
Notification, so a menu on screen also counts as waiting (live line, tab
badge). Client only: no new route.
- #74: Esc before the model wrote anything puts the prompt back in Claude
Code's input box and writes no "[Request interrupted by user]" line, so
the transcript ended on the prompt and the chat read "Thinking…" for
15 min. The chat's Stop now ends the live line until a newer transcript
line arrives (matched by line, not by clock).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
630 lines
25 KiB
TypeScript
630 lines
25 KiB
TypeScript
/**
|
|
* @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). The view sticks to its end while
|
|
* the reader is there: anything that makes it taller — a new line, the live
|
|
* step, an image loading, the pane shown again after being hidden by CSS
|
|
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
|
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
|
|
* chat passes in whether the CLI waits on the user (`waiting`, with
|
|
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
|
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
|
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
|
* @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.
|
|
*
|
|
* ## 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.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `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 { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
|
import { api } from "../../lib/api";
|
|
import { eventBus } from "../../lib/eventBus";
|
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
|
import { MessageList, promptTokens } from "./MessageList";
|
|
import { useSpin } from "../../hooks/useSpin";
|
|
import { useTranslation } from "react-i18next";
|
|
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;
|
|
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
|
* page-height cap — the parent sizes it and puts a composer under it. */
|
|
fill?: boolean;
|
|
/** Opens a file named in a turn's "Edited N files" card. */
|
|
onOpenFile?: (path: string) => void;
|
|
/** The session runs in a pane: show the step in progress. */
|
|
live?: boolean;
|
|
/** Changed (a chat's send): back to the end, and stick to it. */
|
|
follow?: number;
|
|
/** The CLI is asking the user something (a permission prompt). */
|
|
waiting?: boolean;
|
|
/** Shows the pane's terminal, where that question is answered. */
|
|
onOpenTerminal?: () => void;
|
|
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
|
|
stopped?: number;
|
|
/** The newest reply's prompt size (input + cache) and model: how full the
|
|
* context is. */
|
|
onContext?: (tokens: number, model: string | null) => void;
|
|
/** A live pane's session has no row yet: the CLI has not started it. */
|
|
onNotStarted?: (notStarted: boolean) => void;
|
|
}
|
|
|
|
export function ConversationView({
|
|
sessionId,
|
|
initialTranscriptId,
|
|
fill = false,
|
|
onOpenFile,
|
|
live = false,
|
|
follow = 0,
|
|
waiting = false,
|
|
onOpenTerminal,
|
|
stopped = 0,
|
|
onContext,
|
|
onNotStarted,
|
|
}: ConversationViewProps) {
|
|
const { t } = useTranslation("sessions");
|
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
|
// A live pane's session has no row until its first hook — and Claude Code
|
|
// runs no hook before the folder is trusted, so a trust prompt (or any
|
|
// other question before the session starts) waits unseen in the terminal.
|
|
const [notStarted, setNotStarted] = useState(false);
|
|
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
|
useEffect(() => {
|
|
if (!onContext) return;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const m = messages[i]!;
|
|
if (m.type === "assistant" && m.usage)
|
|
return onContext(promptTokens(m.usage), m.model ?? null);
|
|
}
|
|
}, [messages, onContext]);
|
|
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);
|
|
// Scrolled up from the end: a chat (`fill`) offers the way back down.
|
|
const [awayFromEnd, setAwayFromEnd] = 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 contentRef = 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);
|
|
// Bumped whenever the session or the selected transcript changes: a tail
|
|
// fetch started for the previous transcript must not append to the new one.
|
|
const genRef = useRef(0);
|
|
// Refresh-button spinner state - separate from initial `loading` so the
|
|
// existing skeleton doesn't blink during a manual refresh.
|
|
|
|
// 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(() => {
|
|
if (document.visibilityState === "visible") 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;
|
|
genRef.current++;
|
|
lastLineRef.current = 0;
|
|
firstLineRef.current = 0;
|
|
|
|
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;
|
|
setNotStarted(false);
|
|
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;
|
|
// A live pane's new session has no row until its first hook: nothing
|
|
// to show yet, and the poll below picks it up once it exists.
|
|
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
|
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
|
setNotStarted(notYet);
|
|
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;
|
|
const gen = genRef.current;
|
|
try {
|
|
const result = await api.sessions.transcript(sessionId, {
|
|
agent_id: selectedTranscript || undefined,
|
|
...(wasBootstrap ? {} : { after: lastLineRef.current }),
|
|
limit: 50,
|
|
});
|
|
if (gen !== genRef.current) return; // another transcript is shown now
|
|
setNotStarted(false);
|
|
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) => {
|
|
// Hidden tab: the visibility handler below catches up once on show.
|
|
if (document.visibilityState !== "visible") return;
|
|
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 [refreshing, refresh] = useSpin(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;
|
|
setAwayFromEnd(!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]);
|
|
|
|
// Stick to the end while the reader is there. Scroll events say whether
|
|
// they are; a size change — of the content, or of the box itself when a
|
|
// hidden pane is shown — never fires one, so it is watched here.
|
|
useEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
const content = contentRef.current;
|
|
if (!container || !content || typeof ResizeObserver === "undefined") return;
|
|
const observer = new ResizeObserver(() => {
|
|
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
|
|
});
|
|
observer.observe(container);
|
|
observer.observe(content);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// The reader sent a message: show it, and what follows, as it lands.
|
|
useEffect(() => {
|
|
if (!follow) return;
|
|
isAtBottomRef.current = true;
|
|
setAwayFromEnd(false);
|
|
setShowNewMsg(false);
|
|
scrollToBottom();
|
|
}, [follow, scrollToBottom]);
|
|
|
|
// 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 ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
|
{/* Toolbar - always rendered after the initial load so users can
|
|
refresh even when no messages have streamed yet. */}
|
|
{!loading && !fill && (
|
|
<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)}
|
|
aria-label={t("conversation.transcript")}
|
|
className="select py-1.5"
|
|
>
|
|
{transcripts.map((tr) => (
|
|
<option key={tr.id} value={tr.id}>
|
|
{tr.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
|
|
<MessagesSquare className="w-3 h-3" />
|
|
{t("conversation.messages", { count: total })}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={refresh}
|
|
disabled={refreshing || loading}
|
|
title={t("conversation.refresh")}
|
|
aria-label={t("conversation.refresh")}
|
|
className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-accent/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" : ""}`} />
|
|
{t("common:refresh")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error alert */}
|
|
{error && (
|
|
<div className="text-sm text-status-danger bg-status-danger/10 border border-status-danger/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 [scrollbar-gutter:stable]"
|
|
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
|
>
|
|
{/* A chat keeps its first turn clear of the pane's top-right controls
|
|
(the outline's file button floats there). */}
|
|
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
|
|
{/* History loading indicator */}
|
|
{loadingHistory && (
|
|
<div className="flex justify-center py-3">
|
|
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
|
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scroll-up for history hint */}
|
|
{hasMore && !loadingHistory && !loading && (
|
|
<div className="flex justify-center py-2">
|
|
<span className="text-[11px] text-fg-muted">
|
|
↑ {t("conversation.scrollForOlder")}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
|
{t("conversation.loading")}
|
|
</div>
|
|
) : messages.length === 0 && notStarted ? (
|
|
<div className="mx-auto max-w-md py-12 text-center">
|
|
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
|
{t("conversation.notStartedBody")}
|
|
</p>
|
|
{onOpenTerminal && (
|
|
<button
|
|
type="button"
|
|
onClick={onOpenTerminal}
|
|
className="btn-secondary mt-3 text-xs"
|
|
>
|
|
{t("conversation.openTerminal")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="mx-auto max-w-md py-12 text-center">
|
|
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
|
{t("conversation.emptyBody")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<MessageList
|
|
messages={messages}
|
|
loading={false}
|
|
onOpenFile={onOpenFile}
|
|
live={live}
|
|
waiting={waiting}
|
|
onOpenTerminal={onOpenTerminal}
|
|
stopped={stopped}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Claude Desktop's round "back to the end" button */}
|
|
{fill && awayFromEnd && (
|
|
<button
|
|
type="button"
|
|
data-testid="conversation-to-end"
|
|
onClick={() => {
|
|
scrollToBottom();
|
|
setShowNewMsg(false);
|
|
}}
|
|
aria-label={t("conversation.newMessages")}
|
|
title={t("conversation.newMessages")}
|
|
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
|
|
>
|
|
<ArrowDown className="h-4 w-4" />
|
|
{showNewMsg && (
|
|
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* New messages indicator */}
|
|
{showNewMsg && !fill && (
|
|
<button
|
|
onClick={() => {
|
|
scrollToBottom();
|
|
setShowNewMsg(false);
|
|
}}
|
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-accent-solid hover:bg-accent-solid-hover 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" />
|
|
{t("conversation.newMessages")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|