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:
@@ -93,20 +93,30 @@ savedSessionsRouter.put('/:id', async (ctx) => {
|
||||
|
||||
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 messages: Message[];
|
||||
let newMessages: Message[];
|
||||
try {
|
||||
const loaded = await storage.loadSession(homeDir, sessionId);
|
||||
meta = loaded.meta;
|
||||
messages = loaded.messages;
|
||||
newMessages = loaded.messages;
|
||||
} catch {
|
||||
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(
|
||||
id,
|
||||
user.id,
|
||||
messages,
|
||||
mergedMessages,
|
||||
meta.cost as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { ChatMessage } from './types';
|
||||
import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel';
|
||||
|
||||
export type ChatSessionSelection = {
|
||||
sessionId: string | null;
|
||||
@@ -18,12 +17,12 @@ export const ChatHeader = () => {
|
||||
const { sessions, saveSession, deleteSavedSession, resumeSession } = useSavedSessions();
|
||||
const [, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-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 [isSaving, setIsSaving] = useState(false);
|
||||
const [isResuming, setIsResuming] = useState<number | null>(null);
|
||||
|
||||
const isSaved = !!autoSave && autoSave.sessionId === activeSessionId;
|
||||
const isSaved = savedId != null;
|
||||
|
||||
const selectSession = (selection: ChatSessionSelection) => {
|
||||
setSelection(selection);
|
||||
@@ -35,7 +34,7 @@ export const ChatHeader = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await saveSession(activeSessionId);
|
||||
setAutoSave({ sessionId: activeSessionId, savedId: result.id });
|
||||
setSavedId(result.id);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -62,6 +61,7 @@ export const ChatHeader = () => {
|
||||
return { role: 'assistant' as const, text: '' };
|
||||
});
|
||||
const transcript = messagesToTranscript(rawMessages);
|
||||
setSavedId(id);
|
||||
selectSession({
|
||||
sessionId: null,
|
||||
model: result.model ?? model,
|
||||
@@ -84,26 +84,25 @@ export const ChatHeader = () => {
|
||||
<>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">Chat</span>
|
||||
{activeSessionId &&
|
||||
(isSaving ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Saving…
|
||||
</span>
|
||||
) : isSaved ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-duck-teal/60 shrink-0">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Save session"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{isSaving ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Saving…
|
||||
</span>
|
||||
) : isSaved ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-duck-teal shrink-0">
|
||||
<Save className="h-3 w-3" />
|
||||
</span>
|
||||
) : activeSessionId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Save session"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -6,8 +6,6 @@ import { usePiChat } from '../../hooks/usePiChat';
|
||||
import { EmbeddableChat } from './EmbeddableChat';
|
||||
|
||||
import type { ChatSessionSelection } from './ChatHeader';
|
||||
import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel';
|
||||
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ChatPanelInnerProps = {
|
||||
@@ -15,6 +13,7 @@ type ChatPanelInnerProps = {
|
||||
model?: string;
|
||||
resumeSummary?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
savedId?: number;
|
||||
scoped: boolean;
|
||||
sandboxed: boolean;
|
||||
cwdParam?: { root?: string; path: string };
|
||||
@@ -29,6 +28,7 @@ const ChatPanelInner = ({
|
||||
model,
|
||||
resumeSummary,
|
||||
initialMessages,
|
||||
savedId,
|
||||
scoped,
|
||||
sandboxed,
|
||||
cwdParam,
|
||||
@@ -38,16 +38,17 @@ const ChatPanelInner = ({
|
||||
onTurnComplete,
|
||||
}: ChatPanelInnerProps) => {
|
||||
const { updateSessionMessages } = useSavedSessions();
|
||||
const [autoSave] = usePanelChannel<AutoSaveMapping>('chat:auto-save', null);
|
||||
const autoSaveRef = useRef(autoSave);
|
||||
autoSaveRef.current = autoSave;
|
||||
const savedIdRef = useRef(savedId);
|
||||
savedIdRef.current = savedId;
|
||||
const chatSessionRef = useRef<string | null>(null);
|
||||
|
||||
const handleTurnComplete = useCallback(
|
||||
(hadToolCalls: boolean) => {
|
||||
onTurnComplete?.(hadToolCalls);
|
||||
const mapping = autoSaveRef.current;
|
||||
if (mapping) {
|
||||
updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {});
|
||||
const id = savedIdRef.current;
|
||||
const sid = chatSessionRef.current;
|
||||
if (id != null && sid) {
|
||||
updateSessionMessages(id, sid).catch(() => {});
|
||||
}
|
||||
},
|
||||
[onTurnComplete, updateSessionMessages],
|
||||
@@ -61,6 +62,7 @@ const ChatPanelInner = ({
|
||||
initialMessages,
|
||||
...chatContext,
|
||||
});
|
||||
chatSessionRef.current = chat.sessionId;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSession(chat.sessionId);
|
||||
@@ -96,6 +98,7 @@ export const ChatPanelWrapper = () => {
|
||||
? { 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 [resumeKey, setResumeKey] = useState(0);
|
||||
const prevSelectionRef = useRef(selection);
|
||||
@@ -114,6 +117,7 @@ export const ChatPanelWrapper = () => {
|
||||
const latest = sessions.find((s) => s.contextId === contextId);
|
||||
if (!latest) return;
|
||||
autoLoadedRef.current = contextId;
|
||||
setSavedId(latest.id);
|
||||
resumeSession(latest.id).then((result) => {
|
||||
const rawMessages = result.rawMessages ?? [];
|
||||
const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => {
|
||||
@@ -169,6 +173,7 @@ export const ChatPanelWrapper = () => {
|
||||
model={model}
|
||||
resumeSummary={resumeSummary}
|
||||
initialMessages={initialMessages}
|
||||
savedId={savedId ?? undefined}
|
||||
scoped={scoped}
|
||||
sandboxed={sandboxed}
|
||||
cwdParam={cwdParam}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Save, Loader2, Check } from 'lucide-react';
|
||||
import { Save, Loader2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { getHostHome } from 'state/useModels';
|
||||
@@ -15,10 +15,8 @@ export type SelectedSession = {
|
||||
initialMessages?: ChatMessage[];
|
||||
} | null;
|
||||
|
||||
export type AutoSaveMapping = { sessionId: string; savedId: number } | null;
|
||||
|
||||
const CHANNEL = 'chat:selected-session';
|
||||
const AUTO_SAVE_CHANNEL = 'chat:auto-save';
|
||||
const SAVED_ID_CHANNEL = 'chat:saved-id';
|
||||
|
||||
type ChatLocationState = {
|
||||
initialMessage?: string;
|
||||
@@ -43,26 +41,25 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved
|
||||
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="flex items-center gap-1">
|
||||
{sessionId &&
|
||||
(isSaving ? (
|
||||
<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" />
|
||||
Saving…
|
||||
</span>
|
||||
) : isSaved ? (
|
||||
<span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal/60">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
|
||||
title="Save session"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
{isSaving ? (
|
||||
<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" />
|
||||
Saving…
|
||||
</span>
|
||||
) : isSaved ? (
|
||||
<span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
) : sessionId ? (
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
|
||||
title="Save session"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</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">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
@@ -84,36 +81,40 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved
|
||||
type NewChatProps = {
|
||||
resumeSummary?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
savedId?: number;
|
||||
};
|
||||
|
||||
function NewChat({ resumeSummary, initialMessages }: NewChatProps) {
|
||||
function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: NewChatProps) {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const { saveSession, updateSessionMessages } = useSavedSessions();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [autoSave, setAutoSave] = usePanelChannel<AutoSaveMapping>(AUTO_SAVE_CHANNEL, null);
|
||||
const autoSaveRef = useRef(autoSave);
|
||||
autoSaveRef.current = autoSave;
|
||||
const [savedId, setSavedId] = usePanelChannel<number | null>(SAVED_ID_CHANNEL, initialSavedId ?? null);
|
||||
const savedIdRef = useRef(savedId);
|
||||
savedIdRef.current = savedId;
|
||||
const chatSessionRef = useRef<string | null>(null);
|
||||
|
||||
const onTurnComplete = useCallback(() => {
|
||||
const mapping = autoSaveRef.current;
|
||||
if (mapping) {
|
||||
updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {});
|
||||
const id = savedIdRef.current;
|
||||
const sid = chatSessionRef.current;
|
||||
if (id != null && sid) {
|
||||
updateSessionMessages(id, sid).catch(() => {});
|
||||
}
|
||||
}, [updateSessionMessages]);
|
||||
|
||||
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 () => {
|
||||
if (!chat.sessionId || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await saveSession(chat.sessionId);
|
||||
setAutoSave({ sessionId: chat.sessionId, savedId: result.id });
|
||||
setSavedId(result.id);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -167,7 +168,14 @@ export const ChatDetailPanel = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const savedId = selected.id.startsWith('saved:') ? Number(selected.id.replace('saved:', '')) : undefined;
|
||||
|
||||
return (
|
||||
<NewChat key={selected.id} resumeSummary={selected.resumeSummary} initialMessages={selected.initialMessages} />
|
||||
<NewChat
|
||||
key={selected.id}
|
||||
resumeSummary={selected.resumeSummary}
|
||||
initialMessages={selected.initialMessages}
|
||||
savedId={savedId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user