saved sessions: replace transient history with persistent DB storage and auto-save

- Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete)
- Save is instant (no LLM summarization), stores exact conversation with tool calls
- Resume loads full message history into chat UI, sends transcript to agent on first message
- Auto-save updates DB after every agent response once a session is saved
- Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups)
- Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:36:25 +00:00
co-authored by Claude Opus 4.6
parent d88fe3cac7
commit 2a8df0def1
39 changed files with 3272 additions and 2257 deletions
+2 -3
View File
@@ -9,9 +9,8 @@ export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings';
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
export { useSavedSessions } from './useSavedSessions';
export type { UseSavedSessionsType, SavedSessionEntry } from './useSavedSessions';
export { useUserApps } from './useUserApps';
export type { AppManifest } from './useUserApps';
export { useServerEnvironment } from './useServerEnvironment';
-53
View File
@@ -1,53 +0,0 @@
import type { GroupEntry } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export function useChatGroups() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: groups = [] } = useQuery<GroupEntry[]>({
queryKey: ['PI_GROUPS'],
enabled: isAuthenticated,
queryFn: () => client.get<{ groups: GroupEntry[] }>('/pi/groups').then((r) => r.groups),
});
async function createGroup(name: string, slug: string, description?: string, sessionIds?: string[]) {
const result = await client.post<{ group: GroupEntry }>('/pi/groups', {
name,
slug,
description,
sessionIds,
});
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
return result.group;
}
async function updateGroup(slug: string, updates: { name?: string; description?: string }) {
await client.patch(`/pi/groups/${slug}`, updates);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
}
async function deleteGroup(slug: string) {
await client.delete(`/pi/groups/${slug}`);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function moveSession(sessionId: string, groupSlug: string | null) {
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
return {
groups,
createGroup,
updateGroup,
deleteGroup,
moveSession,
};
}
-108
View File
@@ -1,108 +0,0 @@
import type { SessionEntry, ChatMessage, Message } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type ChatSessionsFilter = {
context?: string;
contextId?: string;
};
export function useChatSessions(filter?: ChatSessionsFilter) {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [] } = useQuery<SessionEntry[]>({
queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId],
enabled: isAuthenticated,
queryFn: () =>
client
.post<{ sessions: SessionEntry[] }>('/pi/sessions', {
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
})
.then((r) => r.sessions),
});
function getSession(sessionId: string) {
return client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
}
function saveMessages(sessionId: string, messages: ChatMessage[]) {
return client.put(`/pi/sessions/${sessionId}/messages`, messages);
}
async function renameSession(sessionId: string, title: string) {
await client.patch(`/pi/sessions/${sessionId}`, { title });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId] });
}
async function deleteSession(sessionId: string) {
await client.delete(`/pi/sessions/${sessionId}`);
queryClient.setQueryData<SessionEntry[]>(
['PI_SESSIONS', filter?.context, filter?.contextId],
(prev) => prev?.filter((s) => s.id !== sessionId) ?? [],
);
}
async function deleteSessions(sessionIds: string[]) {
await client.delete('/pi/sessions', {
sessionIds,
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
});
const idSet = new Set(sessionIds);
queryClient.setQueryData<SessionEntry[]>(
['PI_SESSIONS', filter?.context, filter?.contextId],
(prev) => prev?.filter((s) => !idSet.has(s.id)) ?? [],
);
}
async function clearSessions() {
await client.delete('/pi/sessions', {
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
});
queryClient.setQueryData<SessionEntry[]>(['PI_SESSIONS', filter?.context, filter?.contextId], []);
}
function searchSessions(query: string) {
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
}
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId] });
}
return {
sessions,
getSession,
saveMessages,
renameSession,
deleteSession,
deleteSessions,
clearSessions,
searchSessions,
invalidate,
};
}
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
type SessionWithMessages = {
id: string;
title: string;
model: string;
cwd: string;
groupSlug?: string | null;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
messages: Message[];
};
@@ -0,0 +1,105 @@
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) {
await client.put(`/saved-sessions/${savedId}`, { sessionId });
}
return {
sessions,
isLoading,
saveSession,
deleteSavedSession,
resumeSession,
updateSessionMessages,
};
}
export type UseSavedSessionsType = ReturnType<typeof useSavedSessions>;