load chat transcripts as a tail window and lazy-load older on scroll-up

extremely long transcripts made bottom-anchoring the virtualized list unreliable
(thousands of unmeasured variable-height items = a huge estimate the scroll never
lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed
slice plus total+offset. the chat opens on the last 20 messages, anchors to the
bottom instantly, and scrolling near the top pages in the next older window,
prepending it and pinning the previously-top message so the view stays put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 03:49:13 +00:00
co-authored by Claude Opus 4.8
parent 3682269936
commit b27dd7512b
7 changed files with 173 additions and 18 deletions
@@ -12,6 +12,9 @@ import { defaultLayout } from './defaultLayout';
// Allowed panel app types for the /chat screen
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
const CHAT_TAIL = 20;
/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
@@ -61,6 +64,8 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// Fresh /chat/<id> (deep-link or refresh): all we have is the id. Resolve the session by id — the
// backend scans project groups — so the cwd picker lands on its real dir AND the chat resumes, exactly
// as clicking it from the list would. Guarded so it never clobbers an already-loaded selection.
// Transcripts get extremely long, so we load only the tail window (?limit) and anchor to the bottom;
// scrolling up lazy-loads older messages. `total`/`offset` tell the chat where the window sits.
useEffect(() => {
if (isNew) {
setSelected({ id: `new:${Date.now()}` });
@@ -71,7 +76,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
let cancelled = false;
(async () => {
try {
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}`);
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
if (cancelled) return;
setActiveCwd(detail.cwd || null);
setSelected({
@@ -79,6 +84,8 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
model: detail.model,
resumeSessionId: sessionId,
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
total: detail.total,
initialOffset: detail.offset,
});
} catch {
if (!cancelled) setSelected({ id: sessionId });
+17 -2
View File
@@ -46,7 +46,12 @@ chatRouter.get('/sessions', async (ctx) => {
return ctx.json({ sessions });
});
// GET /chat/sessions/:id[?cwd=] — one conversation's transcript. Routes by harness (ses_ = OpenCode).
// GET /chat/sessions/:id[?cwd=][&limit=&before=] — one conversation's transcript. Routes by harness
// (ses_ = OpenCode). Transcripts get extremely long, so the client opens at the tail and pages upward:
// `limit` caps how many messages come back and `before` (absolute index into the full transcript,
// exclusive) selects the window's upper bound. Absent params return the whole transcript (legacy). The
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
// where the window sits and whether older messages remain above it.
chatRouter.get('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email;
const id = ctx.req.param('id');
@@ -57,7 +62,17 @@ chatRouter.get('/sessions/:id', async (ctx) => {
? await loadOpenCodeSession(id)
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id));
if (!detail) return ctx.text('Not found', 404);
return ctx.json(detail);
const total = detail.messages.length;
const limitRaw = ctx.req.query('limit');
const beforeRaw = ctx.req.query('before');
const limit = limitRaw != null ? Math.max(0, parseInt(limitRaw, 10) || 0) : null;
const end = beforeRaw != null ? Math.min(total, Math.max(0, parseInt(beforeRaw, 10) || 0)) : total;
const start = limit != null ? Math.max(0, end - limit) : 0;
const windowed = limit != null || beforeRaw != null;
const messages = windowed ? detail.messages.slice(start, end) : detail.messages;
return ctx.json({ ...detail, messages, total, offset: windowed ? start : 0 });
});
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store.
@@ -48,6 +48,9 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
setThinkingLevel,
sendPrompt,
stopGeneration,
loadOlder,
hasMoreOlder,
isLoadingOlder,
} = chat;
const availableModels = useUserVisibleModels();
@@ -72,6 +75,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const didInitialScrollRef = useRef(false);
// Latest values for the (once-attached) scroll listener, without re-subscribing on every page load.
const loadOlderRef = useRef(loadOlder);
loadOlderRef.current = loadOlder;
const hasMoreOlderRef = useRef(hasMoreOlder);
hasMoreOlderRef.current = hasMoreOlder;
const isLoadingOlderRef = useRef(isLoadingOlder);
isLoadingOlderRef.current = isLoadingOlder;
const handleSend = async () => {
const text = input.trim();
@@ -158,14 +170,23 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
};
}, []);
// Auto-scroll to bottom on new messages
// Keep the view pinned to the bottom as messages/stream grow — but never yank it down when the user
// has scrolled up (e.g. to read history or to trigger lazy-loading of older messages). The first pass
// is an instant jump (repeated on the next frame so it lands cleanly once the tail window measures);
// later passes animate. Prepending older messages doesn't fire this — the user has scrolled up by then.
useEffect(() => {
if (!userScrolledRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
if (userScrolledRef.current) return;
const toBottom = (behavior: ScrollBehavior) => bottomRef.current?.scrollIntoView({ behavior });
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true;
toBottom('auto');
requestAnimationFrame(() => toBottom('auto'));
} else {
toBottom('smooth');
}
}, [messages, streamingText]);
// Detect user scrolling up
// Detect user scrolling up; near the top, lazy-load the next older window (reverse infinite scroll).
useEffect(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return;
@@ -175,6 +196,10 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
userScrolledRef.current = !atBottom;
setShowJumpToBottom(!atBottom);
// Only after the initial bottom-anchor has run, so the mount's scrollTop=0 doesn't trigger a fetch.
if (didInitialScrollRef.current && hasMoreOlderRef.current && !isLoadingOlderRef.current && scrollTop < 200) {
loadOlderRef.current();
}
};
viewport.addEventListener('scroll', handleScroll);
@@ -241,6 +266,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
textareaRef,
scrollViewportRef,
bottomRef,
hasMoreOlder,
isLoadingOlder,
};
}
@@ -1,16 +1,37 @@
import { ArrowDown } from 'lucide-react';
import { useLayoutEffect, useRef } from 'react';
import { ArrowDown, Loader2 } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import type { ChatMessage } from '../types';
import { MessageBubble, StreamingBubble } from './MessageBubble';
const OVERSCAN = 5;
// A cheap identity for the first message, to tell a top-prepend (lazy-loaded older window) apart from a
// bottom-append (new turn). Only messages[0] changing while the list grows means older were prepended.
function firstMessageKey(m: ChatMessage | undefined): string {
if (!m) return '';
if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`;
if (m.role === 'tool') return `t:${m.toolCallId}`;
return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`;
}
type MessageListProps = {
manager: UseEmbeddableChatType;
};
export const MessageList = ({ manager }: MessageListProps) => {
const { messages, streamingText, isGenerating, showJumpToBottom, jumpToBottom, sendPrompt, scrollViewportRef, bottomRef } = manager;
const {
messages,
streamingText,
isGenerating,
showJumpToBottom,
jumpToBottom,
sendPrompt,
scrollViewportRef,
bottomRef,
isLoadingOlder,
} = manager;
const virtualizer = useVirtualizer({
count: messages.length,
@@ -20,8 +41,26 @@ export const MessageList = ({ manager }: MessageListProps) => {
measureElement: (element) => element.getBoundingClientRect().height,
});
// Keep the viewport anchored when older messages are prepended: the message that used to be first
// (now shifted down by the number prepended) is pinned back to the top, so the content doesn't jump.
const prevLenRef = useRef(messages.length);
const prevFirstKeyRef = useRef<string | null>(null);
useLayoutEffect(() => {
const grew = messages.length - prevLenRef.current;
const firstKey = firstMessageKey(messages[0]);
const prepended = grew > 0 && prevFirstKeyRef.current !== null && firstKey !== prevFirstKeyRef.current;
if (prepended) virtualizer.scrollToIndex(grew, { align: 'start' });
prevLenRef.current = messages.length;
prevFirstKeyRef.current = firstKey;
}, [messages, virtualizer]);
return (
<div className="flex-1 min-h-0 relative">
{isLoadingOlder && (
<div className="absolute top-1 left-1/2 -translate-x-1/2 z-10 rounded-full bg-background/90 shadow p-1.5">
<Loader2 className="h-4 w-4 animate-spin text-duck-teal" />
</div>
)}
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
{messages.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm">
@@ -29,10 +68,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
</div>
)}
{messages.length > 0 && (
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const msg = messages[virtualRow.index]!;
return (
@@ -13,6 +13,8 @@ export type SelectedSession = {
resumeSummary?: string;
resumeSessionId?: string;
initialMessages?: ChatMessage[];
total?: number; // full transcript length — initialMessages is only the tail window
initialOffset?: number; // absolute index of initialMessages[0]; older remain above when > 0
} | null;
const CHANNEL = 'chat:selected-session';
@@ -67,9 +69,11 @@ type NewChatProps = {
resumeSummary?: string;
resumeSessionId?: string;
initialMessages?: ChatMessage[];
total?: number;
initialOffset?: number;
};
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initialOffset }: NewChatProps) {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
@@ -87,6 +91,11 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
initialMessages,
onTurnComplete,
context: 'chat',
// Only the tail is loaded up front — let the chat page older messages upward on scroll.
paginate:
resumeSessionId && typeof total === 'number'
? { sessionId: resumeSessionId, total, initialOffset: initialOffset ?? 0 }
: undefined,
});
// Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions).
@@ -104,7 +113,12 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
return (
<div className="flex flex-col h-full">
<DetailBar sessionTitle={undefined} isConnected={chat.isConnected} isGenerating={chat.isGenerating} onDisconnect={chat.disconnectSession} />
<DetailBar
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
<EmbeddableChat
chat={chat}
sessionId={undefined}
@@ -135,6 +149,8 @@ export const ChatDetailPanel = () => {
resumeSummary={selected.resumeSummary}
resumeSessionId={selected.resumeSessionId}
initialMessages={selected.initialMessages}
total={selected.total}
initialOffset={selected.initialOffset}
/>
);
};
+49 -2
View File
@@ -5,6 +5,7 @@ import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
const SAVE_DEBOUNCE_MS = 1000;
const OLDER_PAGE_SIZE = 20; // messages fetched per scroll-up (matches the initial tail window)
type ResourceChatStorage = {
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
@@ -22,6 +23,8 @@ type UsePiChatOptions = {
resumeSummary?: string;
resumeSessionId?: string;
initialMessages?: ChatMessage[];
// When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in.
paginate?: { sessionId: string; total: number; initialOffset: number };
onTurnComplete?: (hadToolCalls: boolean) => void;
};
@@ -37,9 +40,14 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
resumeSummary: initialResumeSummary,
resumeSessionId,
initialMessages: preloadedMessages,
paginate,
onTurnComplete,
} = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []);
// Reverse pagination: absolute index of the oldest loaded message (0 = we have the whole transcript).
const [oldestOffset, setOldestOffset] = useState(paginate?.initialOffset ?? 0);
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const loadingOlderRef = useRef(false);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
@@ -84,6 +92,32 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const client = useClient();
// Fetch the next older window and prepend it. The scroll container restores its position from the
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
const loadOlder = useCallback(async () => {
if (!paginate || loadingOlderRef.current || oldestOffset <= 0) return;
loadingOlderRef.current = true;
setIsLoadingOlder(true);
try {
const res = await client.get<{ messages: ChatMessage[]; offset: number }>(
`/chat/sessions/${paginate.sessionId}?before=${oldestOffset}&limit=${OLDER_PAGE_SIZE}`,
);
if (res.messages.length) {
setMessages((prev) => [...res.messages, ...prev]);
setOldestOffset(res.offset);
} else {
setOldestOffset(0);
}
} catch {
// Leave the offset unchanged; scrolling near the top again will retry.
} finally {
loadingOlderRef.current = false;
setIsLoadingOlder(false);
}
}, [client, paginate, oldestOffset]);
const hasMoreOlder = !!paginate && oldestOffset > 0;
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`;
@@ -221,13 +255,23 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
case 'task:started':
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: `⏳ Background task started — ${msg.description}` }]);
setMessages((prev) => [
...prev,
{ role: 'assistant', id: crypto.randomUUID(), text: `⏳ Background task started — ${msg.description}` },
]);
break;
case 'task:notification': {
// The fix in action: a background task's completion arriving after the turn ended.
const icon = msg.status === 'completed' ? '✅' : msg.status === 'failed' ? '❌' : '⏹️';
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: `${icon} Background task ${msg.status}${msg.summary}` }]);
setMessages((prev) => [
...prev,
{
role: 'assistant',
id: crypto.randomUUID(),
text: `${icon} Background task ${msg.status}${msg.summary}`,
},
]);
break;
}
}
@@ -331,6 +375,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
sendPrompt,
stopGeneration,
disconnectSession,
loadOlder,
hasMoreOlder,
isLoadingOlder,
};
}
@@ -28,7 +28,14 @@ export type ClaudeSessionMessage =
isError?: boolean;
};
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeSessionMessage[] };
export type ClaudeSessionDetail = {
id: string;
model: string;
cwd: string;
messages: ClaudeSessionMessage[];
total: number; // full transcript length (the loaded messages may be a tail window)
offset: number; // absolute index of messages[0]; older messages remain above when > 0
};
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };