From b27dd7512b48c4278d8479ff7ee101d80c270c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 03:49:13 +0000 Subject: [PATCH] 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 --- .../Screens/Dashboard/ChatHistory/index.tsx | 9 +++- src/servers/api/chat/chat.ts | 19 ++++++- .../Chat/EmbeddableChat/useEmbeddableChat.ts | 35 +++++++++++-- .../src/apps/Chat/components/MessageList.tsx | 48 ++++++++++++++--- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 20 +++++++- .../officerdev/src/hooks/useChat.ts | 51 ++++++++++++++++++- src/workspaces/state/src/useClaudeSessions.ts | 9 +++- 7 files changed, 173 insertions(+), 18 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index ea7897c6..1d0bc823 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -12,6 +12,9 @@ import { defaultLayout } from './defaultLayout'; // Allowed panel app types for the /chat screen const ALLOWED_APP_TYPES = new Set(['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/ (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(`/chat/sessions/${sessionId}`); + const detail = await client.get(`/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['initialMessages'], + total: detail.total, + initialOffset: detail.offset, }); } catch { if (!cancelled) setSelected({ id: sessionId }); diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 75f2877a..ceddc0e2 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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. diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 4e024d1f..2e3eeaf7 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -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(null); const bottomRef = useRef(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, }; } diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index 95868b6b..e8cdf15d 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -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(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 (
+ {isLoadingOlder && ( +
+ +
+ )}
{messages.length === 0 && !isGenerating && (
@@ -29,10 +68,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
)} {messages.length > 0 && ( -
+
{virtualizer.getVirtualItems().map((virtualRow) => { const msg = messages[virtualRow.index]!; return ( diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 28a2abc6..b241506a 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -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 (
- + { resumeSummary={selected.resumeSummary} resumeSessionId={selected.resumeSessionId} initialMessages={selected.initialMessages} + total={selected.total} + initialOffset={selected.initialOffset} /> ); }; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index f08901f8..27031b53 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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(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(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, }; } diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 8f83b8c5..2248e5cd 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -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 };