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