Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/renderer/components/TranscriptPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import React, { useEffect, useLayoutEffect, useState, useMemo, useRef, useCallback } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { useTerminalStore } from '../state/terminal-store';
Expand Down Expand Up @@ -87,6 +87,10 @@ const TranscriptPanel: React.FC = () => {
const panelRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const sigRef = useRef<string>('');
// Tracks which session id has had its initial scroll-to-bottom done. We jump
// to the latest message the first time a session's messages land in the DOM,
// so opening the transcript on a long chat doesn't dump you at the very top.
const initialScrolledForRef = useRef<string | null>(null);
const close = useCallback(() => useTerminalStore.setState({ transcriptOpen: false }), []);

// ── Search state ──────────────────────────────────────────────────
Expand Down Expand Up @@ -116,6 +120,7 @@ const TranscriptPanel: React.FC = () => {
if (!open || !aiSessionId) { setMsgs(null); return; }
let cancelled = false;
sigRef.current = '';
initialScrolledForRef.current = null;
setMsgs(null);

const fetchOnce = (initial: boolean) => {
Expand All @@ -137,12 +142,14 @@ const TranscriptPanel: React.FC = () => {
return;
}
}
const wasAtBottom = initial || atBottom();
const wasAtBottom = atBottom();
sigRef.current = nextSig;
setMsgs(next);
// Don't fight an active search: if the user is parked on a match,
// leave the scroll position where the search put it.
if (wasAtBottom && !searchActiveRef.current) scrollToBottom();
// On live updates, keep glueing to the bottom if the user was already
// there. The *initial* scroll-to-bottom is handled by the layout
// effect below, because at this point the new bubbles haven't been
// laid out yet and scrollHeight is still ~0 (which lands at the top).
if (!initial && wasAtBottom && !searchActiveRef.current) scrollToBottom();
})
.catch(() => { if (!cancelled && initial) setMsgs([]); });
};
Expand All @@ -152,6 +159,18 @@ const TranscriptPanel: React.FC = () => {
return () => { cancelled = true; clearInterval(timer); };
}, [open, aiSessionId, provider]);

// First-render jump: when a session's messages first hit the DOM, scroll the
// body to the bottom so opening the transcript on a long conversation lands
// on the most recent message instead of the very first one.
useLayoutEffect(() => {
if (!open || !aiSessionId || !msgs || msgs.length === 0) return;
if (initialScrolledForRef.current === aiSessionId) return;
const body = bodyRef.current;
if (!body) return;
body.scrollTop = body.scrollHeight;
initialScrolledForRef.current = aiSessionId;
}, [open, aiSessionId, msgs]);

// Escape: close the search bar first if it's open, otherwise close the panel.
useEffect(() => {
if (!open) return;
Expand Down
Loading