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
@@ -0,0 +1,19 @@
CREATE TABLE "saved_sessions" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"context" text,
"context_id" text,
"title" text NOT NULL,
"summary" text NOT NULL,
"raw_messages" jsonb NOT NULL,
"cwd" text NOT NULL,
"cost" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP TABLE "chat_groups" CASCADE;--> statement-breakpoint
DROP TABLE "chat_messages" CASCADE;--> statement-breakpoint
DROP TABLE "chat_sessions" CASCADE;--> statement-breakpoint
ALTER TABLE "saved_sessions" ADD CONSTRAINT "saved_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_saved_sessions_user_created" ON "saved_sessions" USING btree ("user_id","created_at");
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1772795711164,
"tag": "0000_futuristic_ink",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772799835768,
"tag": "0001_freezing_carmella_unuscione",
"breakpoints": true
}
]
}
+8
View File
@@ -64,5 +64,13 @@ export {
upsertDefaults,
} from './queries/dashboards';
export {
listSavedSessions,
getSavedSession,
createSavedSession,
updateSavedSessionMessages,
deleteSavedSession,
} from './queries/saved-sessions';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,59 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { savedSessions } from '../schema';
import type { SavedSessionInsert, SavedSessionSelect } from '../types';
export async function listSavedSessions(userId: number): Promise<Omit<SavedSessionSelect, 'rawMessages'>[]> {
const rows = await db
.select({
id: savedSessions.id,
userId: savedSessions.userId,
provider: savedSessions.provider,
context: savedSessions.context,
contextId: savedSessions.contextId,
title: savedSessions.title,
summary: savedSessions.summary,
cwd: savedSessions.cwd,
cost: savedSessions.cost,
createdAt: savedSessions.createdAt,
})
.from(savedSessions)
.where(eq(savedSessions.userId, userId))
.orderBy(desc(savedSessions.createdAt));
return rows;
}
export async function getSavedSession(id: number, userId: number): Promise<SavedSessionSelect | undefined> {
const rows = await db
.select()
.from(savedSessions)
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)));
return rows[0];
}
export async function createSavedSession(data: SavedSessionInsert): Promise<SavedSessionSelect> {
const rows = await db.insert(savedSessions).values(data).returning();
return rows[0]!;
}
export async function updateSavedSessionMessages(
id: number,
userId: number,
rawMessages: unknown,
cost: unknown,
): Promise<boolean> {
const result = await db
.update(savedSessions)
.set({ rawMessages, cost })
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)))
.returning({ id: savedSessions.id });
return result.length > 0;
}
export async function deleteSavedSession(id: number, userId: number): Promise<boolean> {
const result = await db
.delete(savedSessions)
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)))
.returning({ id: savedSessions.id });
return result.length > 0;
}
+20 -54
View File
@@ -1,56 +1,22 @@
import { pgTable, text, integer, boolean, timestamp, jsonb, index, numeric } from 'drizzle-orm/pg-core';
import { pgTable, serial, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const chatSessions = pgTable('chat_sessions', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
context: text('context').notNull().default('chat'),
contextId: text('context_id'),
title: text('title').notNull().default('New Chat'),
model: text('model'),
cwd: text('cwd'),
thinking: text('thinking'),
archived: boolean('archived').notNull().default(false),
groupSlug: text('group_slug'),
messageCount: integer('message_count').notNull().default(0),
costInputTokens: integer('cost_input_tokens').notNull().default(0),
costOutputTokens: integer('cost_output_tokens').notNull().default(0),
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_sessions_user_context').on(table.userId, table.context),
index('idx_sessions_user_created').on(table.userId, table.createdAt),
index('idx_sessions_user_group').on(table.userId, table.groupSlug),
]);
export const chatMessages = pgTable('chat_messages', {
id: text('id').primaryKey(),
sessionId: text('session_id').notNull().references(() => chatSessions.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
text: text('text'),
model: text('model'),
toolName: text('tool_name'),
toolInput: jsonb('tool_input'),
toolCallId: text('tool_call_id'),
output: text('output'),
isError: boolean('is_error').default(false),
costInputTokens: integer('cost_input_tokens'),
costOutputTokens: integer('cost_output_tokens'),
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }),
sortOrder: integer('sort_order').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_messages_session_order').on(table.sessionId, table.sortOrder),
]);
export const chatGroups = pgTable('chat_groups', {
slug: text('slug').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_chat_groups_user').on(table.userId),
]);
export const savedSessions = pgTable(
'saved_sessions',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').notNull(),
context: text('context'),
contextId: text('context_id'),
title: text('title').notNull(),
summary: text('summary').notNull(),
rawMessages: jsonb('raw_messages').notNull(),
cwd: text('cwd').notNull(),
cost: jsonb('cost').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_saved_sessions_user_created').on(table.userId, table.createdAt)],
);
+3 -12
View File
@@ -34,19 +34,10 @@ export type UserIntegrationInsert = typeof Schema.userIntegrations.$inferInsert;
export type DockConfigSelect = typeof Schema.dockConfigs.$inferSelect;
export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
// ── Chat ──
// ── Saved Sessions ──
export type ChatSessionSelect = typeof Schema.chatSessions.$inferSelect;
export type ChatSessionInsert = typeof Schema.chatSessions.$inferInsert;
export type ChatSession = ChatSessionSelect & {
messages?: ChatMessageSelect[];
};
export type ChatMessageSelect = typeof Schema.chatMessages.$inferSelect;
export type ChatMessageInsert = typeof Schema.chatMessages.$inferInsert;
export type ChatGroupSelect = typeof Schema.chatGroups.$inferSelect;
export type ChatGroupInsert = typeof Schema.chatGroups.$inferInsert;
export type SavedSessionSelect = typeof Schema.savedSessions.$inferSelect;
export type SavedSessionInsert = typeof Schema.savedSessions.$inferInsert;
// ── Dashboards ──