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:
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user