chat: retire the old saved-session store (Stage 4a)
Deletes all session persistence that isn't Claude's native transcript store, per the "only harness-native session management survives" rule. Backend: delete api/pi/storage.ts (meta.json+messages.json file store), the api/saved-sessions router (+ unmount), the /pi/sessions REST endpoints, and the storage.save/loadSession calls in the chat WS handler (in-memory session-manager stays for live turns; no disk persistence — Claude's transcript is the record). Also drops the Postgres saved_sessions layer: schema/chat.ts, queries/saved-sessions.ts, its types and re-exports. Frontend: delete state/useSavedSessions, ChatList, and the ChatHistory Widget (all pure saved-session UI); slim ChatHeader to a label; strip the auto-load-latest + Save wiring from ChatPanelWrapper and ChatDetailPanel; drop the old resume path from useChat and SessionListPage; remove the /chat/saved/:id route and the useInitialData prefetch. Behavior removed (intended): the Save-session button, email/project panels auto-resuming the last chat, and /chat/saved/:id. /chat itself is unchanged — already fully on Claude transcripts. The orphaned saved_sessions Postgres table is dropped on the next `bun db:push`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,4 @@
|
||||
import { useState } from '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 { MessageSquare } from 'lucide-react';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
export type ChatSessionSelection = {
|
||||
@@ -13,163 +9,11 @@ export type ChatSessionSelection = {
|
||||
initialMessages?: ChatMessage[];
|
||||
};
|
||||
|
||||
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 [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 = savedId != null;
|
||||
|
||||
const selectSession = (selection: ChatSessionSelection) => {
|
||||
setSelection(selection);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!activeSessionId || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await saveSession(activeSessionId);
|
||||
setSavedId(result.id);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async (id: number, model: string) => {
|
||||
setIsResuming(id);
|
||||
try {
|
||||
const result = await resumeSession(id);
|
||||
const rawMessages = result.rawMessages ?? [];
|
||||
const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => {
|
||||
if (m.role === 'user') return { role: 'user' as const, text: m.text || '' };
|
||||
if (m.role === 'assistant') return { role: 'assistant' as const, id: m.id, text: m.text || '' };
|
||||
if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
toolName: m.toolName || '',
|
||||
toolInput: m.toolInput || {},
|
||||
toolCallId: m.toolCallId || '',
|
||||
output: m.output,
|
||||
isError: m.isError,
|
||||
};
|
||||
}
|
||||
return { role: 'assistant' as const, text: '' };
|
||||
});
|
||||
const transcript = messagesToTranscript(rawMessages);
|
||||
setSavedId(id);
|
||||
selectSession({
|
||||
sessionId: null,
|
||||
model: result.model ?? model,
|
||||
resumeSummary: transcript,
|
||||
initialMessages: chatMessages,
|
||||
});
|
||||
} catch {
|
||||
// Failed to resume
|
||||
} finally {
|
||||
setIsResuming(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: number) => {
|
||||
e.stopPropagation();
|
||||
await deleteSavedSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">Chat</span>
|
||||
{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
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Saved sessions"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-0 max-h-80 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span className="text-xs font-medium">Saved Sessions</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectSession({ sessionId: null })}
|
||||
className="flex items-center gap-1 text-xs text-duck-teal hover:text-duck-teal/80 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">No saved sessions</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleResume(session.id, session.provider)}
|
||||
disabled={isResuming === session.id}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/50 last:border-0 group/item"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium truncate flex-1">
|
||||
{isResuming === session.id ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
session.title
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
className="p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0 ml-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
// Chat panel header — just the label now that saved-sessions are retired (Claude's transcript store
|
||||
// is the source of truth; the /chat screen is where you browse/resume sessions).
|
||||
export const ChatHeader = () => (
|
||||
<>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">Chat</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useSavedSessions } from 'state/useSavedSessions';
|
||||
|
||||
export const ChatList = () => {
|
||||
const { sessions } = useSavedSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
No saved sessions yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 p-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="block p-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 hover:bg-duck-teal/5 dark:hover:bg-duck-teal/10 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
|
||||
import { useChat } from '../../hooks/useChat';
|
||||
import { EmbeddableChat } from './EmbeddableChat';
|
||||
|
||||
import type { ChatSessionSelection } from './ChatHeader';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ChatPanelInnerProps = {
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
resumeSummary?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
savedId?: number;
|
||||
scoped: boolean;
|
||||
sandboxed: boolean;
|
||||
cwdParam?: { root?: string; path: string };
|
||||
@@ -23,48 +14,13 @@ type ChatPanelInnerProps = {
|
||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||
};
|
||||
|
||||
const ChatPanelInner = ({
|
||||
sessionId,
|
||||
model,
|
||||
resumeSummary,
|
||||
initialMessages,
|
||||
savedId,
|
||||
scoped,
|
||||
sandboxed,
|
||||
cwdParam,
|
||||
promptPrefix,
|
||||
chatContext,
|
||||
setActiveSession,
|
||||
onTurnComplete,
|
||||
}: ChatPanelInnerProps) => {
|
||||
const { updateSessionMessages } = useSavedSessions();
|
||||
const savedIdRef = useRef(savedId);
|
||||
savedIdRef.current = savedId;
|
||||
const chatSessionRef = useRef<string | null>(null);
|
||||
const resumedMessageCountRef = useRef(initialMessages?.length ?? 0);
|
||||
|
||||
const handleTurnComplete = useCallback(
|
||||
(hadToolCalls: boolean) => {
|
||||
onTurnComplete?.(hadToolCalls);
|
||||
const id = savedIdRef.current;
|
||||
const sid = chatSessionRef.current;
|
||||
if (id != null && sid) {
|
||||
const count = resumedMessageCountRef.current;
|
||||
updateSessionMessages(id, sid, count > 0 ? count : undefined).catch(() => {});
|
||||
}
|
||||
},
|
||||
[onTurnComplete, updateSessionMessages],
|
||||
);
|
||||
|
||||
const chat = useChat(sessionId, model, {
|
||||
const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => {
|
||||
const chat = useChat(undefined, undefined, {
|
||||
replaceUrl: false,
|
||||
projectScoped: scoped,
|
||||
onTurnComplete: handleTurnComplete,
|
||||
resumeSummary,
|
||||
initialMessages,
|
||||
onTurnComplete,
|
||||
...chatContext,
|
||||
});
|
||||
chatSessionRef.current = chat.sessionId;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSession(chat.sessionId);
|
||||
@@ -74,8 +30,6 @@ const ChatPanelInner = ({
|
||||
<EmbeddableChat
|
||||
className="h-full"
|
||||
chat={chat}
|
||||
sessionId={sessionId}
|
||||
initialModel={model}
|
||||
cwd={cwdParam}
|
||||
sandboxed={sandboxed}
|
||||
replaceUrl={false}
|
||||
@@ -100,53 +54,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);
|
||||
if (selection !== prevSelectionRef.current) {
|
||||
prevSelectionRef.current = selection;
|
||||
if (selection?.resumeSummary) setResumeKey((k) => k + 1);
|
||||
}
|
||||
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
||||
|
||||
// Auto-load the latest saved session for this dashboard/project context
|
||||
const { sessions, resumeSession } = useSavedSessions();
|
||||
const autoLoadedRef = useRef<string | null>(null);
|
||||
const contextId = 'contextId' in chatContext ? chatContext.contextId : undefined;
|
||||
useEffect(() => {
|
||||
if (selection || !contextId || autoLoadedRef.current === contextId) return;
|
||||
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) => {
|
||||
if (m.role === 'user') return { role: 'user' as const, text: m.text || '' };
|
||||
if (m.role === 'assistant') return { role: 'assistant' as const, id: m.id, text: m.text || '' };
|
||||
if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
toolName: m.toolName || '',
|
||||
toolInput: m.toolInput || {},
|
||||
toolCallId: m.toolCallId || '',
|
||||
output: m.output,
|
||||
isError: m.isError,
|
||||
};
|
||||
}
|
||||
return { role: 'assistant' as const, text: '' };
|
||||
});
|
||||
const transcript = messagesToTranscript(rawMessages);
|
||||
setSelection({
|
||||
sessionId: null,
|
||||
model: result.model,
|
||||
dashboardId: dashboardId ?? undefined,
|
||||
resumeSummary: transcript,
|
||||
initialMessages: chatMessages,
|
||||
});
|
||||
});
|
||||
}, [selection, contextId, sessions]);
|
||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
||||
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
||||
|
||||
@@ -162,20 +70,8 @@ export const ChatPanelWrapper = () => {
|
||||
|
||||
const cwdParam = scoped ? { root, path: cwd } : undefined;
|
||||
|
||||
const isCurrentWorkspace = !selection?.dashboardId || selection.dashboardId === dashboardId;
|
||||
const sessionId = isCurrentWorkspace ? (selection?.sessionId ?? undefined) : undefined;
|
||||
const model = isCurrentWorkspace ? (selection?.model ?? undefined) : undefined;
|
||||
const resumeSummary = isCurrentWorkspace ? selection?.resumeSummary : undefined;
|
||||
const initialMessages = isCurrentWorkspace ? selection?.initialMessages : undefined;
|
||||
|
||||
return (
|
||||
<ChatPanelInner
|
||||
key={resumeSummary ? `resume-${resumeKey}` : (sessionId ?? 'new')}
|
||||
sessionId={sessionId}
|
||||
model={model}
|
||||
resumeSummary={resumeSummary}
|
||||
initialMessages={initialMessages}
|
||||
savedId={savedId ?? undefined}
|
||||
scoped={scoped}
|
||||
sandboxed={sandboxed}
|
||||
cwdParam={cwdParam}
|
||||
|
||||
@@ -15,7 +15,6 @@ export { AttachButton } from './components/AttachButton';
|
||||
export { WebpageDialog } from './components/WebpageDialog';
|
||||
export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
|
||||
export { useChat, type UseChatType } from '../../hooks/useChat';
|
||||
export { ChatList } from './ChatList';
|
||||
export { useSlashCommands } from './useSlashCommands';
|
||||
export { useAttachments, type UseAttachmentsType } from './useAttachments';
|
||||
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Save, Loader2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useSavedSessions } from 'state/useSavedSessions';
|
||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { useChat, EmbeddableChat } from '../Chat';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
@@ -17,7 +15,6 @@ export type SelectedSession = {
|
||||
} | null;
|
||||
|
||||
const CHANNEL = 'chat:selected-session';
|
||||
const SAVED_ID_CHANNEL = 'chat:saved-id';
|
||||
|
||||
type ChatLocationState = {
|
||||
initialMessage?: string;
|
||||
@@ -32,36 +29,11 @@ type DetailBarProps = {
|
||||
sessionTitle: string | undefined;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
sessionId: string | null;
|
||||
isSaved: boolean;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved, onSave, isSaving }: DetailBarProps) {
|
||||
function DetailBar({ sessionTitle, isConnected, isGenerating }: DetailBarProps) {
|
||||
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">
|
||||
{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'}
|
||||
</div>
|
||||
@@ -83,51 +55,23 @@ type NewChatProps = {
|
||||
resumeSummary?: string;
|
||||
resumeSessionId?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
savedId?: number;
|
||||
};
|
||||
|
||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: initialSavedId }: NewChatProps) {
|
||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const { saveSession, updateSessionMessages } = useSavedSessions();
|
||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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 resumedMessageCountRef = useRef(initialMessages?.length ?? 0);
|
||||
|
||||
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
||||
const onTurnComplete = useCallback(() => {
|
||||
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
||||
invalidateClaudeSessions();
|
||||
const id = savedIdRef.current;
|
||||
const sid = chatSessionRef.current;
|
||||
if (id != null && sid) {
|
||||
const count = resumedMessageCountRef.current;
|
||||
updateSessionMessages(id, sid, count > 0 ? count : undefined).catch(() => {});
|
||||
}
|
||||
}, [updateSessionMessages, invalidateClaudeSessions]);
|
||||
}, [invalidateClaudeSessions]);
|
||||
|
||||
// context 'chat' tells the backend to run this session from the dedicated claude_sessions cwd, so
|
||||
// its transcript lands in Claude's own store as an isolated project group (source of truth).
|
||||
const chat = useChat(undefined, locationState?.model, { resumeSummary, resumeSessionId, initialMessages, onTurnComplete, context: 'chat' });
|
||||
chatSessionRef.current = chat.sessionId;
|
||||
|
||||
const isSaved = savedId != null;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!chat.sessionId || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await saveSession(chat.sessionId);
|
||||
setSavedId(result.id);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sandboxed = !isSuperAdmin;
|
||||
// Run the session in the pwd chosen in the Sessions panel; null → backend default (claude_sessions).
|
||||
@@ -145,15 +89,7 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
sessionTitle={undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
sessionId={chat.sessionId}
|
||||
isSaved={isSaved}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<DetailBar sessionTitle={undefined} isConnected={chat.isConnected} isGenerating={chat.isGenerating} />
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
sessionId={undefined}
|
||||
@@ -179,15 +115,12 @@ export const ChatDetailPanel = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const savedId = selected.id.startsWith('saved:') ? Number(selected.id.replace('saved:', '')) : undefined;
|
||||
|
||||
return (
|
||||
<NewChat
|
||||
key={selected.id}
|
||||
resumeSummary={selected.resumeSummary}
|
||||
resumeSessionId={selected.resumeSessionId}
|
||||
initialMessages={selected.initialMessages}
|
||||
savedId={savedId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -207,79 +207,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
|
||||
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
|
||||
|
||||
// Load messages from server on mount when resuming a session
|
||||
useEffect(() => {
|
||||
if (storage) {
|
||||
storage
|
||||
.load()
|
||||
.then(({ sessionId: sid, messages: msgs }) => {
|
||||
if (sid) {
|
||||
sessionIdRef.current = sid;
|
||||
setSessionId(sid);
|
||||
}
|
||||
if (msgs.length > 0) {
|
||||
setMessages(msgs);
|
||||
setHasStarted(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!initialSessionId) return;
|
||||
client
|
||||
.get<{ session: { messages: Message[] } }>(`/pi/sessions/${initialSessionId}`)
|
||||
.then((data) => {
|
||||
if (data.session?.messages && data.session.messages.length > 0) {
|
||||
// Convert backend Message[] to ChatMessage[]
|
||||
const chatMessages = data.session.messages.map((m: Message): ChatMessage => {
|
||||
if (m.role === 'user') {
|
||||
return { role: 'user', text: m.text || '' };
|
||||
} else if (m.role === 'assistant') {
|
||||
return { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' };
|
||||
} else if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool',
|
||||
toolName: m.toolName || '',
|
||||
toolInput: m.toolInput || {},
|
||||
toolCallId: m.toolCallId || '',
|
||||
output: m.output,
|
||||
isError: m.isError,
|
||||
};
|
||||
}
|
||||
return { role: 'assistant', id: crypto.randomUUID(), text: '' }; // Fallback
|
||||
});
|
||||
setMessages(chatMessages);
|
||||
setHasStarted(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [initialSessionId]);
|
||||
|
||||
// Debounced save messages to server
|
||||
useEffect(() => {
|
||||
if (!sessionIdRef.current || messages.length === 0) return;
|
||||
|
||||
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||
|
||||
const sid = sessionIdRef.current;
|
||||
const snapshot = messages;
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
if (storage) {
|
||||
storage.save(sid, snapshot).catch(() => {});
|
||||
} else {
|
||||
client.put(`/pi/sessions/${sid}/messages`, snapshot).catch(() => {});
|
||||
}
|
||||
saveTimerRef.current = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
// Clean up RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ export {
|
||||
WebpageDialog,
|
||||
EmbeddableChat,
|
||||
useChat,
|
||||
ChatList,
|
||||
useSlashCommands,
|
||||
useAttachments,
|
||||
useAudioRecording,
|
||||
|
||||
@@ -11,8 +11,6 @@ export { useRecentModels } from './useRecentModels';
|
||||
export { usePlans } from './usePlans';
|
||||
export { useLandingPage } from './useLandingPage';
|
||||
export { useServerSettings } from './useServerSettings';
|
||||
export { useSavedSessions } from './useSavedSessions';
|
||||
export type { UseSavedSessionsType, SavedSessionEntry } from './useSavedSessions';
|
||||
export { useUserApps } from './useUserApps';
|
||||
export type { AppManifest } from './useUserApps';
|
||||
export { useServerEnvironment } from './useServerEnvironment';
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export type SavedSessionEntry = {
|
||||
id: number;
|
||||
provider: string;
|
||||
context: string | null;
|
||||
contextId: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
cwd: string;
|
||||
cost: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type RawMessage = {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
text?: string;
|
||||
model?: string;
|
||||
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type ResumeResult = {
|
||||
context: string | null;
|
||||
cwd: string;
|
||||
model: string;
|
||||
cost: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
rawMessages: RawMessage[];
|
||||
};
|
||||
|
||||
export type { RawMessage };
|
||||
|
||||
export function messagesToTranscript(messages: RawMessage[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'user' && msg.text) {
|
||||
lines.push(`User: ${msg.text}`);
|
||||
} else if (msg.role === 'assistant' && msg.text) {
|
||||
lines.push(`Assistant: ${msg.text}`);
|
||||
} else if (msg.role === 'tool') {
|
||||
const outcome = msg.isError ? `Error: ${msg.output}` : msg.output ? 'Success' : 'Pending';
|
||||
lines.push(`Tool [${msg.toolName}]: ${outcome}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
export function useSavedSessions() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [], isLoading } = useQuery<SavedSessionEntry[]>({
|
||||
queryKey: ['SAVED_SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<SavedSessionEntry[]>('/saved-sessions'),
|
||||
});
|
||||
|
||||
async function saveSession(sessionId: string) {
|
||||
try {
|
||||
const result = await client.post<SavedSessionEntry>('/saved-sessions', { sessionId });
|
||||
queryClient.invalidateQueries({ queryKey: ['SAVED_SESSIONS'] });
|
||||
toast.success('Session saved');
|
||||
return result;
|
||||
} catch (err) {
|
||||
toast.error('Failed to save session');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSavedSession(id: number) {
|
||||
await client.delete(`/saved-sessions/${id}`);
|
||||
queryClient.setQueryData<SavedSessionEntry[]>(['SAVED_SESSIONS'], (prev) => prev?.filter((s) => s.id !== id) ?? []);
|
||||
}
|
||||
|
||||
async function resumeSession(id: number) {
|
||||
return client.post<ResumeResult>(`/saved-sessions/${id}/resume`);
|
||||
}
|
||||
|
||||
async function updateSessionMessages(savedId: number, sessionId: string, resumedMessageCount?: number) {
|
||||
await client.put(`/saved-sessions/${savedId}`, { sessionId, resumedMessageCount });
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
isLoading,
|
||||
saveSession,
|
||||
deleteSavedSession,
|
||||
resumeSession,
|
||||
updateSessionMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseSavedSessionsType = ReturnType<typeof useSavedSessions>;
|
||||
Reference in New Issue
Block a user