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
|
// Allowed panel app types for the /chat screen
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
|
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) */
|
/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||||
if (node.type === 'panel') {
|
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
|
// 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
|
// 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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setSelected({ id: `new:${Date.now()}` });
|
setSelected({ id: `new:${Date.now()}` });
|
||||||
@@ -71,7 +76,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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;
|
if (cancelled) return;
|
||||||
setActiveCwd(detail.cwd || null);
|
setActiveCwd(detail.cwd || null);
|
||||||
setSelected({
|
setSelected({
|
||||||
@@ -79,6 +84,8 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
model: detail.model,
|
model: detail.model,
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
|
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
|
||||||
|
total: detail.total,
|
||||||
|
initialOffset: detail.offset,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setSelected({ id: sessionId });
|
if (!cancelled) setSelected({ id: sessionId });
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
return ctx.json({ sessions });
|
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) => {
|
chatRouter.get('/sessions/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
@@ -57,7 +62,17 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
? await loadOpenCodeSession(id)
|
? await loadOpenCodeSession(id)
|
||||||
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id));
|
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id));
|
||||||
if (!detail) return ctx.text('Not found', 404);
|
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.
|
// 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,
|
setThinkingLevel,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
|
loadOlder,
|
||||||
|
hasMoreOlder,
|
||||||
|
isLoadingOlder,
|
||||||
} = chat;
|
} = chat;
|
||||||
|
|
||||||
const availableModels = useUserVisibleModels();
|
const availableModels = useUserVisibleModels();
|
||||||
@@ -72,6 +75,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
const userScrolledRef = useRef(false);
|
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 handleSend = async () => {
|
||||||
const text = input.trim();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!userScrolledRef.current) {
|
if (userScrolledRef.current) return;
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
const toBottom = (behavior: ScrollBehavior) => bottomRef.current?.scrollIntoView({ behavior });
|
||||||
|
if (!didInitialScrollRef.current) {
|
||||||
|
didInitialScrollRef.current = true;
|
||||||
|
toBottom('auto');
|
||||||
|
requestAnimationFrame(() => toBottom('auto'));
|
||||||
|
} else {
|
||||||
|
toBottom('smooth');
|
||||||
}
|
}
|
||||||
}, [messages, streamingText]);
|
}, [messages, streamingText]);
|
||||||
|
|
||||||
// Detect user scrolling up
|
// Detect user scrolling up; near the top, lazy-load the next older window (reverse infinite scroll).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = scrollViewportRef.current;
|
const viewport = scrollViewportRef.current;
|
||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
@@ -175,6 +196,10 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
|
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
|
||||||
userScrolledRef.current = !atBottom;
|
userScrolledRef.current = !atBottom;
|
||||||
setShowJumpToBottom(!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);
|
viewport.addEventListener('scroll', handleScroll);
|
||||||
@@ -241,6 +266,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
scrollViewportRef,
|
scrollViewportRef,
|
||||||
bottomRef,
|
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 { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||||
|
import type { ChatMessage } from '../types';
|
||||||
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||||
|
|
||||||
const OVERSCAN = 5;
|
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 = {
|
type MessageListProps = {
|
||||||
manager: UseEmbeddableChatType;
|
manager: UseEmbeddableChatType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageList = ({ manager }: MessageListProps) => {
|
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({
|
const virtualizer = useVirtualizer({
|
||||||
count: messages.length,
|
count: messages.length,
|
||||||
@@ -20,8 +41,26 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
|||||||
measureElement: (element) => element.getBoundingClientRect().height,
|
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 (
|
return (
|
||||||
<div className="flex-1 min-h-0 relative">
|
<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">
|
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
|
||||||
{messages.length === 0 && !isGenerating && (
|
{messages.length === 0 && !isGenerating && (
|
||||||
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<div
|
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||||
className="relative w-full"
|
|
||||||
style={{ height: virtualizer.getTotalSize() }}
|
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const msg = messages[virtualRow.index]!;
|
const msg = messages[virtualRow.index]!;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type SelectedSession = {
|
|||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
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;
|
} | null;
|
||||||
|
|
||||||
const CHANNEL = 'chat:selected-session';
|
const CHANNEL = 'chat:selected-session';
|
||||||
@@ -67,9 +69,11 @@ type NewChatProps = {
|
|||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
initialMessages?: ChatMessage[];
|
||||||
|
total?: number;
|
||||||
|
initialOffset?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
|
function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initialOffset }: NewChatProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||||
@@ -87,6 +91,11 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
|
|||||||
initialMessages,
|
initialMessages,
|
||||||
onTurnComplete,
|
onTurnComplete,
|
||||||
context: 'chat',
|
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).
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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
|
<EmbeddableChat
|
||||||
chat={chat}
|
chat={chat}
|
||||||
sessionId={undefined}
|
sessionId={undefined}
|
||||||
@@ -135,6 +149,8 @@ export const ChatDetailPanel = () => {
|
|||||||
resumeSummary={selected.resumeSummary}
|
resumeSummary={selected.resumeSummary}
|
||||||
resumeSessionId={selected.resumeSessionId}
|
resumeSessionId={selected.resumeSessionId}
|
||||||
initialMessages={selected.initialMessages}
|
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';
|
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 1000;
|
const SAVE_DEBOUNCE_MS = 1000;
|
||||||
|
const OLDER_PAGE_SIZE = 20; // messages fetched per scroll-up (matches the initial tail window)
|
||||||
|
|
||||||
type ResourceChatStorage = {
|
type ResourceChatStorage = {
|
||||||
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
|
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
|
||||||
@@ -22,6 +23,8 @@ type UsePiChatOptions = {
|
|||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
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;
|
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,9 +40,14 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
resumeSummary: initialResumeSummary,
|
resumeSummary: initialResumeSummary,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
initialMessages: preloadedMessages,
|
initialMessages: preloadedMessages,
|
||||||
|
paginate,
|
||||||
onTurnComplete,
|
onTurnComplete,
|
||||||
} = options ?? {};
|
} = options ?? {};
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []);
|
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 [streamingText, setStreamingText] = useState('');
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||||
@@ -84,6 +92,32 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
|
|
||||||
const client = useClient();
|
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 token = localStorage.getItem('BEARER_TOKEN');
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`;
|
const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`;
|
||||||
@@ -221,13 +255,23 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'task:started':
|
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;
|
break;
|
||||||
|
|
||||||
case 'task:notification': {
|
case 'task:notification': {
|
||||||
// The fix in action: a background task's completion arriving after the turn ended.
|
// The fix in action: a background task's completion arriving after the turn ended.
|
||||||
const icon = msg.status === 'completed' ? '✅' : msg.status === 'failed' ? '❌' : '⏹️';
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +375,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
sendPrompt,
|
sendPrompt,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
disconnectSession,
|
disconnectSession,
|
||||||
|
loadOlder,
|
||||||
|
hasMoreOlder,
|
||||||
|
isLoadingOlder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ export type ClaudeSessionMessage =
|
|||||||
isError?: boolean;
|
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 };
|
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user