Skip to content

Commit 6de60f1

Browse files
committed
Fast switch chats
1 parent dbfd7dd commit 6de60f1

4 files changed

Lines changed: 525 additions & 16 deletions

File tree

frontend/src/components/SplitView.tsx

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import { useChatState } from "../hooks/chat/useChatState";
2121
import { usePermissions } from "../hooks/chat/usePermissions";
2222
import { usePermissionMode } from "../hooks/chat/usePermissionMode";
2323
import { useAbortController } from "../hooks/chat/useAbortController";
24-
import { useAutoHistoryLoader } from "../hooks/useHistoryLoader";
24+
import { useAutoCachedHistoryLoader } from "../hooks/useCachedHistoryLoader";
25+
import { useSessionCache } from "../hooks/useSessionCache";
2526
import { normalizeWindowsPath } from "../utils/pathUtils";
2627
import type { StreamingContext } from "../hooks/streaming/useMessageProcessor";
2728
import { SettingsButton } from "./SettingsButton";
@@ -95,20 +96,36 @@ export function SplitView() {
9596
encodedName: string,
9697
) => {
9798
const updatedProjects = [...projects];
98-
updatedProjects[projectIndex].loadingSessions = true;
99+
const project = updatedProjects[projectIndex];
100+
project.loadingSessions = true;
99101
setProjects(updatedProjects);
100102

101103
try {
102104
const response = await fetch(getHistoriesUrl(encodedName));
103105
if (response.ok) {
104106
const data = await response.json();
105-
updatedProjects[projectIndex].sessions = data.conversations || [];
107+
const sessions = data.conversations || [];
108+
project.sessions = sessions;
109+
110+
// Preload the most recent 3 sessions for faster switching
111+
const recentSessions = sessions.slice(0, 3);
112+
for (const session of recentSessions) {
113+
try {
114+
await preloadSession(project.path, session.sessionId, encodedName);
115+
} catch (error) {
116+
console.warn(
117+
"Failed to preload session:",
118+
session.sessionId,
119+
error,
120+
);
121+
}
122+
}
106123
}
107124
} catch (error) {
108125
console.error("Failed to load sessions:", error);
109-
updatedProjects[projectIndex].sessions = [];
126+
project.sessions = [];
110127
} finally {
111-
updatedProjects[projectIndex].loadingSessions = false;
128+
project.loadingSessions = false;
112129
setProjects([...updatedProjects]);
113130
}
114131
};
@@ -188,14 +205,19 @@ export function SplitView() {
188205
const { processStreamLine } = useClaudeStreaming();
189206
const { abortRequest, createAbortHandler } = useAbortController();
190207
const { permissionMode, setPermissionMode } = usePermissionMode();
208+
const { preloadSession, setCachedSession, updateScrollPosition } =
209+
useSessionCache();
191210

192211
// Load conversation history if sessionId is provided
193212
const {
194213
messages: historyMessages,
195214
loading: historyLoading,
196215
error: historyError,
197216
sessionId: loadedSessionId,
198-
} = useAutoHistoryLoader(
217+
fromCache: historyFromCache,
218+
scrollPosition: historyScrollPosition,
219+
} = useAutoCachedHistoryLoader(
220+
selectedProject || undefined,
199221
getEncodedName() || undefined,
200222
sessionId || undefined,
201223
);
@@ -294,9 +316,38 @@ export function SplitView() {
294316
const streamingContext: StreamingContext = {
295317
currentAssistantMessage,
296318
setCurrentAssistantMessage,
297-
addMessage,
298-
updateLastMessage,
299-
onSessionId: setCurrentSessionId,
319+
addMessage: (msg: ChatMessage) => {
320+
addMessage(msg);
321+
// Update cache when new messages are added during streaming
322+
if (currentSessionId && selectedProject) {
323+
const updatedMessages = [...messages, msg];
324+
setCachedSession(
325+
selectedProject,
326+
currentSessionId,
327+
updatedMessages,
328+
);
329+
}
330+
},
331+
updateLastMessage: (msg: ChatMessage) => {
332+
updateLastMessage(msg);
333+
// Update cache when messages are updated during streaming
334+
if (currentSessionId && selectedProject) {
335+
const updatedMessages = [...messages];
336+
updatedMessages[updatedMessages.length - 1] = msg;
337+
setCachedSession(
338+
selectedProject,
339+
currentSessionId,
340+
updatedMessages,
341+
);
342+
}
343+
},
344+
onSessionId: (sessionId: string) => {
345+
setCurrentSessionId(sessionId);
346+
// Cache the conversation when we get a session ID
347+
if (selectedProject && messages.length > 0) {
348+
setCachedSession(selectedProject, sessionId, messages);
349+
}
350+
},
300351
shouldShowInitMessage: () => !hasShownInitMessage,
301352
onInitMessageShown: () => setHasShownInitMessage(true),
302353
get hasReceivedInit() {
@@ -407,6 +458,16 @@ export function SplitView() {
407458
closePermissionRequest();
408459
}, [closePermissionRequest]);
409460

461+
// Handle scroll position changes to update cache
462+
const handleScrollPositionChange = useCallback(
463+
(position: number) => {
464+
if (selectedProject && currentSessionId) {
465+
updateScrollPosition(selectedProject, currentSessionId, position);
466+
}
467+
},
468+
[selectedProject, currentSessionId, updateScrollPosition],
469+
);
470+
410471
// Create permission data for inline permission interface
411472
const permissionData = permissionRequest
412473
? {
@@ -603,8 +664,15 @@ export function SplitView() {
603664
{getProjectDisplayName(selectedProject)}
604665
</h1>
605666
{currentSessionId && (
606-
<div className="text-sm text-slate-500 dark:text-slate-400 font-mono">
607-
Session: {currentSessionId.substring(0, 8)}...
667+
<div className="text-sm text-slate-500 dark:text-slate-400 font-mono flex items-center gap-2">
668+
<span>
669+
Session: {currentSessionId.substring(0, 8)}...
670+
</span>
671+
{historyFromCache && (
672+
<span className="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 px-2 py-0.5 rounded-full">
673+
cached
674+
</span>
675+
)}
608676
</div>
609677
)}
610678
</div>
@@ -648,7 +716,15 @@ export function SplitView() {
648716
</div>
649717
) : (
650718
<>
651-
<ChatMessages messages={messages} isLoading={isLoading} />
719+
<ChatMessages
720+
messages={messages}
721+
isLoading={isLoading}
722+
restoreScrollPosition={historyScrollPosition}
723+
onScrollPositionChange={handleScrollPositionChange}
724+
shouldAutoScroll={
725+
!historyFromCache || historyScrollPosition === undefined
726+
}
727+
/>
652728
<ChatInput
653729
input={input}
654730
isLoading={isLoading}

frontend/src/components/chat/ChatMessages.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,22 @@ import { MessageSquare } from "lucide-react";
2525
interface ChatMessagesProps {
2626
messages: AllMessage[];
2727
isLoading: boolean;
28+
restoreScrollPosition?: number;
29+
onScrollPositionChange?: (position: number) => void;
30+
shouldAutoScroll?: boolean;
2831
}
2932

30-
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
33+
export function ChatMessages({
34+
messages,
35+
isLoading,
36+
restoreScrollPosition,
37+
onScrollPositionChange,
38+
shouldAutoScroll = true,
39+
}: ChatMessagesProps) {
3140
const messagesEndRef = useRef<HTMLDivElement>(null);
3241
const messagesContainerRef = useRef<HTMLDivElement>(null);
42+
const isRestoringScroll = useRef(false);
43+
const scrollTimeout = useRef<NodeJS.Timeout>();
3344

3445
// Auto-scroll to bottom
3546
const scrollToBottom = () => {
@@ -38,6 +49,40 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
3849
}
3950
};
4051

52+
// Restore scroll position
53+
const restoreScroll = (position: number) => {
54+
if (messagesContainerRef.current) {
55+
isRestoringScroll.current = true;
56+
messagesContainerRef.current.scrollTop = position;
57+
// Clear the flag after a brief delay to allow for scroll event handling
58+
setTimeout(() => {
59+
isRestoringScroll.current = false;
60+
}, 100);
61+
}
62+
};
63+
64+
// Handle scroll position changes for caching
65+
const handleScroll = () => {
66+
if (
67+
isRestoringScroll.current ||
68+
!messagesContainerRef.current ||
69+
!onScrollPositionChange
70+
) {
71+
return;
72+
}
73+
74+
// Debounce scroll position updates
75+
if (scrollTimeout.current) {
76+
clearTimeout(scrollTimeout.current);
77+
}
78+
79+
scrollTimeout.current = setTimeout(() => {
80+
if (messagesContainerRef.current) {
81+
onScrollPositionChange(messagesContainerRef.current.scrollTop);
82+
}
83+
}, 150);
84+
};
85+
4186
// Check if user is near bottom of messages (unused but kept for future use)
4287
// const isNearBottom = () => {
4388
// const container = messagesContainerRef.current;
@@ -50,10 +95,19 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
5095
// );
5196
// };
5297

53-
// Auto-scroll when messages change
98+
// Handle scroll restoration when restoreScrollPosition changes
99+
useEffect(() => {
100+
if (restoreScrollPosition !== undefined) {
101+
restoreScroll(restoreScrollPosition);
102+
}
103+
}, [restoreScrollPosition]);
104+
105+
// Auto-scroll when messages change (only if shouldAutoScroll is true and not restoring)
54106
useEffect(() => {
55-
scrollToBottom();
56-
}, [messages]);
107+
if (shouldAutoScroll && restoreScrollPosition === undefined) {
108+
scrollToBottom();
109+
}
110+
}, [messages, shouldAutoScroll, restoreScrollPosition]);
57111

58112
const renderMessage = (message: AllMessage, index: number) => {
59113
// Use timestamp as key for stable rendering, fallback to index if needed
@@ -80,6 +134,7 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
80134
return (
81135
<div
82136
ref={messagesContainerRef}
137+
onScroll={handleScroll}
83138
className="flex-1 overflow-y-auto bg-white/70 dark:bg-slate-800/70 border border-slate-200/60 dark:border-slate-700/60 p-3 sm:p-6 mb-3 sm:mb-6 rounded-2xl shadow-sm backdrop-blur-sm flex flex-col"
84139
>
85140
{messages.length === 0 ? (

0 commit comments

Comments
 (0)