fix save indicator timing and auto-save history loss on resume

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 21:27:31 +00:00
co-authored by Claude Opus 4.6
parent 0e0be3327e
commit 60230ccfec
4 changed files with 92 additions and 70 deletions
@@ -93,20 +93,30 @@ savedSessionsRouter.put('/:id', async (ctx) => {
const homeDir = getHomeDirForRole(user.email, user.role); const homeDir = getHomeDirForRole(user.email, user.role);
// Load existing saved session to get previous messages
const existing = await getSavedSession(id, user.id);
if (!existing) {
return ctx.json({ error: 'Not found' }, 404);
}
let meta; let meta;
let messages: Message[]; let newMessages: Message[];
try { try {
const loaded = await storage.loadSession(homeDir, sessionId); const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta; meta = loaded.meta;
messages = loaded.messages; newMessages = loaded.messages;
} catch { } catch {
return ctx.json({ error: 'Session not found on disk' }, 404); return ctx.json({ error: 'Session not found on disk' }, 404);
} }
// Merge: old DB messages (from before resume) + new disk messages (from this session)
const oldMessages = (existing.rawMessages ?? []) as Message[];
const mergedMessages = [...oldMessages, ...newMessages];
const updated = await updateSavedSessionMessages( const updated = await updateSavedSessionMessages(
id, id,
user.id, user.id,
messages, mergedMessages,
meta.cost as unknown as Record<string, unknown>, meta.cost as unknown as Record<string, unknown>,
); );
@@ -1,10 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { MessageSquare, History, Plus, Save, Check, Trash2, Loader2 } from 'lucide-react'; import { MessageSquare, History, Plus, Save, Trash2, Loader2 } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions'; import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import type { ChatMessage } from './types'; import type { ChatMessage } from './types';
import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel';
export type ChatSessionSelection = { export type ChatSessionSelection = {
sessionId: string | null; sessionId: string | null;
@@ -18,12 +17,12 @@ export const ChatHeader = () => {
const { sessions, saveSession, deleteSavedSession, resumeSession } = useSavedSessions(); const { sessions, saveSession, deleteSavedSession, resumeSession } = useSavedSessions();
const [, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null); const [, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null); const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
const [autoSave, setAutoSave] = usePanelChannel<AutoSaveMapping>('chat:auto-save', null); const [savedId, setSavedId] = usePanelChannel<number | null>('chat:saved-id', null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isResuming, setIsResuming] = useState<number | null>(null); const [isResuming, setIsResuming] = useState<number | null>(null);
const isSaved = !!autoSave && autoSave.sessionId === activeSessionId; const isSaved = savedId != null;
const selectSession = (selection: ChatSessionSelection) => { const selectSession = (selection: ChatSessionSelection) => {
setSelection(selection); setSelection(selection);
@@ -35,7 +34,7 @@ export const ChatHeader = () => {
setIsSaving(true); setIsSaving(true);
try { try {
const result = await saveSession(activeSessionId); const result = await saveSession(activeSessionId);
setAutoSave({ sessionId: activeSessionId, savedId: result.id }); setSavedId(result.id);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -62,6 +61,7 @@ export const ChatHeader = () => {
return { role: 'assistant' as const, text: '' }; return { role: 'assistant' as const, text: '' };
}); });
const transcript = messagesToTranscript(rawMessages); const transcript = messagesToTranscript(rawMessages);
setSavedId(id);
selectSession({ selectSession({
sessionId: null, sessionId: null,
model: result.model ?? model, model: result.model ?? model,
@@ -84,26 +84,25 @@ export const ChatHeader = () => {
<> <>
<MessageSquare className="h-3.5 w-3.5 shrink-0" /> <MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">Chat</span> <span className="text-xs font-medium truncate flex-1">Chat</span>
{activeSessionId && {isSaving ? (
(isSaving ? ( <span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0"> <Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin" /> Saving
Saving </span>
</span> ) : isSaved ? (
) : isSaved ? ( <span className="flex items-center gap-1 text-[10px] text-duck-teal shrink-0">
<span className="flex items-center gap-1 text-[10px] text-duck-teal/60 shrink-0"> <Save className="h-3 w-3" />
<Check className="h-3 w-3" /> </span>
</span> ) : activeSessionId ? (
) : ( <button
<button type="button"
type="button" onClick={handleSave}
onClick={handleSave} className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0" title="Save session"
title="Save session" >
> <Save className="h-3.5 w-3.5" />
<Save className="h-3.5 w-3.5" /> </button>
</button> ) : null}
))}
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
@@ -6,8 +6,6 @@ import { usePiChat } from '../../hooks/usePiChat';
import { EmbeddableChat } from './EmbeddableChat'; import { EmbeddableChat } from './EmbeddableChat';
import type { ChatSessionSelection } from './ChatHeader'; import type { ChatSessionSelection } from './ChatHeader';
import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel';
import type { ChatMessage } from './types'; import type { ChatMessage } from './types';
type ChatPanelInnerProps = { type ChatPanelInnerProps = {
@@ -15,6 +13,7 @@ type ChatPanelInnerProps = {
model?: string; model?: string;
resumeSummary?: string; resumeSummary?: string;
initialMessages?: ChatMessage[]; initialMessages?: ChatMessage[];
savedId?: number;
scoped: boolean; scoped: boolean;
sandboxed: boolean; sandboxed: boolean;
cwdParam?: { root?: string; path: string }; cwdParam?: { root?: string; path: string };
@@ -29,6 +28,7 @@ const ChatPanelInner = ({
model, model,
resumeSummary, resumeSummary,
initialMessages, initialMessages,
savedId,
scoped, scoped,
sandboxed, sandboxed,
cwdParam, cwdParam,
@@ -38,16 +38,17 @@ const ChatPanelInner = ({
onTurnComplete, onTurnComplete,
}: ChatPanelInnerProps) => { }: ChatPanelInnerProps) => {
const { updateSessionMessages } = useSavedSessions(); const { updateSessionMessages } = useSavedSessions();
const [autoSave] = usePanelChannel<AutoSaveMapping>('chat:auto-save', null); const savedIdRef = useRef(savedId);
const autoSaveRef = useRef(autoSave); savedIdRef.current = savedId;
autoSaveRef.current = autoSave; const chatSessionRef = useRef<string | null>(null);
const handleTurnComplete = useCallback( const handleTurnComplete = useCallback(
(hadToolCalls: boolean) => { (hadToolCalls: boolean) => {
onTurnComplete?.(hadToolCalls); onTurnComplete?.(hadToolCalls);
const mapping = autoSaveRef.current; const id = savedIdRef.current;
if (mapping) { const sid = chatSessionRef.current;
updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {}); if (id != null && sid) {
updateSessionMessages(id, sid).catch(() => {});
} }
}, },
[onTurnComplete, updateSessionMessages], [onTurnComplete, updateSessionMessages],
@@ -61,6 +62,7 @@ const ChatPanelInner = ({
initialMessages, initialMessages,
...chatContext, ...chatContext,
}); });
chatSessionRef.current = chat.sessionId;
useEffect(() => { useEffect(() => {
setActiveSession(chat.sessionId); setActiveSession(chat.sessionId);
@@ -96,6 +98,7 @@ export const ChatPanelWrapper = () => {
? { context: 'dashboard' as const, contextId: dashboardId } ? { context: 'dashboard' as const, contextId: dashboardId }
: {}; : {};
const [savedId, setSavedId] = usePanelChannel<number | null>('chat:saved-id', null);
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null); const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [resumeKey, setResumeKey] = useState(0); const [resumeKey, setResumeKey] = useState(0);
const prevSelectionRef = useRef(selection); const prevSelectionRef = useRef(selection);
@@ -114,6 +117,7 @@ export const ChatPanelWrapper = () => {
const latest = sessions.find((s) => s.contextId === contextId); const latest = sessions.find((s) => s.contextId === contextId);
if (!latest) return; if (!latest) return;
autoLoadedRef.current = contextId; autoLoadedRef.current = contextId;
setSavedId(latest.id);
resumeSession(latest.id).then((result) => { resumeSession(latest.id).then((result) => {
const rawMessages = result.rawMessages ?? []; const rawMessages = result.rawMessages ?? [];
const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => { const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => {
@@ -169,6 +173,7 @@ export const ChatPanelWrapper = () => {
model={model} model={model}
resumeSummary={resumeSummary} resumeSummary={resumeSummary}
initialMessages={initialMessages} initialMessages={initialMessages}
savedId={savedId ?? undefined}
scoped={scoped} scoped={scoped}
sandboxed={sandboxed} sandboxed={sandboxed}
cwdParam={cwdParam} cwdParam={cwdParam}
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { Save, Loader2, Check } from 'lucide-react'; import { Save, Loader2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { getHostHome } from 'state/useModels'; import { getHostHome } from 'state/useModels';
@@ -15,10 +15,8 @@ export type SelectedSession = {
initialMessages?: ChatMessage[]; initialMessages?: ChatMessage[];
} | null; } | null;
export type AutoSaveMapping = { sessionId: string; savedId: number } | null;
const CHANNEL = 'chat:selected-session'; const CHANNEL = 'chat:selected-session';
const AUTO_SAVE_CHANNEL = 'chat:auto-save'; const SAVED_ID_CHANNEL = 'chat:saved-id';
type ChatLocationState = { type ChatLocationState = {
initialMessage?: string; initialMessage?: string;
@@ -43,26 +41,25 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved
return ( return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60"> <div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{sessionId && {isSaving ? (
(isSaving ? ( <span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal">
<span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal"> <Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving
Saving </span>
</span> ) : isSaved ? (
) : isSaved ? ( <span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal">
<span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal/60"> <Save className="h-3.5 w-3.5" />
<Check className="h-3.5 w-3.5" /> Saved
Saved </span>
</span> ) : sessionId ? (
) : ( <button
<button onClick={onSave}
onClick={onSave} className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer" title="Save session"
title="Save session" >
> <Save className="h-4 w-4" />
<Save className="h-4 w-4" /> </button>
</button> ) : null}
))}
</div> </div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3"> <div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'} {sessionTitle ?? 'New chat'}
@@ -84,36 +81,40 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved
type NewChatProps = { type NewChatProps = {
resumeSummary?: string; resumeSummary?: string;
initialMessages?: ChatMessage[]; initialMessages?: ChatMessage[];
savedId?: number;
}; };
function NewChat({ resumeSummary, initialMessages }: NewChatProps) { function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: NewChatProps) {
const location = useLocation(); const location = useLocation();
const locationState = location.state as ChatLocationState; const locationState = location.state as ChatLocationState;
const { user } = useAuth(); const { user } = useAuth();
const isSuperAdmin = user?.role === 'Super Admin'; const isSuperAdmin = user?.role === 'Super Admin';
const { saveSession, updateSessionMessages } = useSavedSessions(); const { saveSession, updateSessionMessages } = useSavedSessions();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [autoSave, setAutoSave] = usePanelChannel<AutoSaveMapping>(AUTO_SAVE_CHANNEL, null); const [savedId, setSavedId] = usePanelChannel<number | null>(SAVED_ID_CHANNEL, initialSavedId ?? null);
const autoSaveRef = useRef(autoSave); const savedIdRef = useRef(savedId);
autoSaveRef.current = autoSave; savedIdRef.current = savedId;
const chatSessionRef = useRef<string | null>(null);
const onTurnComplete = useCallback(() => { const onTurnComplete = useCallback(() => {
const mapping = autoSaveRef.current; const id = savedIdRef.current;
if (mapping) { const sid = chatSessionRef.current;
updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {}); if (id != null && sid) {
updateSessionMessages(id, sid).catch(() => {});
} }
}, [updateSessionMessages]); }, [updateSessionMessages]);
const chat = usePiChat(undefined, locationState?.model, { resumeSummary, initialMessages, onTurnComplete }); const chat = usePiChat(undefined, locationState?.model, { resumeSummary, initialMessages, onTurnComplete });
chatSessionRef.current = chat.sessionId;
const isSaved = !!autoSave && autoSave.sessionId === chat.sessionId; const isSaved = savedId != null;
const handleSave = async () => { const handleSave = async () => {
if (!chat.sessionId || isSaving) return; if (!chat.sessionId || isSaving) return;
setIsSaving(true); setIsSaving(true);
try { try {
const result = await saveSession(chat.sessionId); const result = await saveSession(chat.sessionId);
setAutoSave({ sessionId: chat.sessionId, savedId: result.id }); setSavedId(result.id);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -167,7 +168,14 @@ export const ChatDetailPanel = () => {
); );
} }
const savedId = selected.id.startsWith('saved:') ? Number(selected.id.replace('saved:', '')) : undefined;
return ( return (
<NewChat key={selected.id} resumeSummary={selected.resumeSummary} initialMessages={selected.initialMessages} /> <NewChat
key={selected.id}
resumeSummary={selected.resumeSummary}
initialMessages={selected.initialMessages}
savedId={savedId}
/>
); );
}; };