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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user