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:
2026-07-24 15:29:26 +00:00
co-authored by Claude Opus 4.8
parent 2e3c45ddfb
commit 70555c8d71
23 changed files with 31 additions and 1813 deletions
-1
View File
@@ -52,7 +52,6 @@ export function App() {
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
<Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/saved/:id" element={<Dashboard.SessionListPage />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
@@ -1,47 +0,0 @@
import { MessageSquare, Trash2 } from 'lucide-react';
import { Widget } from 'widgets/Widget';
import { useSavedSessions } from 'state/useSavedSessions';
export const ChatHistory = () => {
const { sessions, deleteSavedSession } = useSavedSessions();
return (
<Widget title="Saved Sessions">
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No saved sessions yet</p>
) : (
<ul className="space-y-0.5">
{sessions.map((session) => (
<li
key={session.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark dark:text-foreground truncate block">{session.title}</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate block">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<button
onClick={() => deleteSavedSession(session.id)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
</Widget>
);
};
@@ -1,16 +1,12 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import type { LayoutNode, SelectedSession } from 'officerdev';
import type { ChatMessage } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useIsMobile } from 'hooks/useIsMobile';
import { useDashboardState } from 'state/useDashboardState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
import { defaultLayout } from './defaultLayout';
export { ChatHistory as ChatHistoryApp } from './Widget';
// Allowed panel app types for the /chat screen
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
@@ -35,15 +31,12 @@ type SessionListPageProps = {
};
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId, id: savedIdParam } = useParams<{ sessionId: string; id: string }>();
const { sessionId } = useParams<{ sessionId: string }>();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
const isMobile = useIsMobile();
const navigate = useNavigate();
const { resumeSession } = useSavedSessions();
const loadedSavedIdRef = useRef<number | null>(null);
const savedId = savedIdParam ? Number(savedIdParam) : null;
const mobilePanelId = isMobile && (sessionId || isNew || savedId) ? 'chat-detail' : undefined;
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
// Normalize synchronously so the wrong panel never renders
const workspace = useMemo(() => {
@@ -64,38 +57,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
setSelected({ id: `new:${Date.now()}` });
return;
}
if (savedId && !Number.isNaN(savedId) && loadedSavedIdRef.current !== savedId) {
loadedSavedIdRef.current = savedId;
resumeSession(savedId).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);
setSelected({
id: `saved:${savedId}`,
model: result.model,
resumeSummary: transcript,
initialMessages: chatMessages,
});
});
return;
}
if (!sessionId) return;
setSelected({ id: sessionId });
}, [sessionId, isNew, savedId]);
}, [sessionId, isNew]);
return (
<div className="h-full w-full pt-2">
+1 -3
View File
@@ -1,4 +1,3 @@
import { useSavedSessions } from 'state/useSavedSessions';
import { usePlans } from 'state/usePlans';
import { useSettings } from 'state/useSettings';
import { usePiModels } from 'state/useModels';
@@ -6,12 +5,11 @@ import { useAccessPolicy } from 'state/useAccessPolicy';
import { useColorModeSync } from './useThemeSync';
export const useInitialData = () => {
const { sessions } = useSavedSessions();
const { plans } = usePlans();
const { settings } = useSettings();
usePiModels();
useAccessPolicy();
useColorModeSync();
return { sessions, plans, settings };
return { plans, settings };
};
-7
View File
@@ -64,13 +64,6 @@ export {
upsertDefaults,
} from './queries/dashboards';
export {
listSavedSessions,
getSavedSession,
createSavedSession,
updateSavedSessionMessages,
deleteSavedSession,
} from './queries/saved-sessions';
export {
createPipelineJob,
@@ -1,59 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
import { pgTable, serial, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
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)],
);
@@ -1,6 +1,5 @@
export * from './auth';
export * from './user-data';
export * from './chat';
export * from './dashboards';
export * from './operations';
export * from './server';
-2
View File
@@ -36,8 +36,6 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
// ── Saved Sessions ──
export type SavedSessionSelect = typeof Schema.savedSessions.$inferSelect;
export type SavedSessionInsert = typeof Schema.savedSessions.$inferInsert;
// ── Dashboards ──
-27
View File
@@ -1,9 +1,7 @@
import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as storage from './storage';
import { readSttConfig } from '../server-settings/stt';
import { listPiModels } from './list-models';
import { getHomeDirForRole } from '../../data-path';
import { logger } from './logger';
import { transcribeAudio } from '../stt/transcribe';
import { getUserSettings } from 'officerdb';
@@ -24,31 +22,6 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
}
});
/**
* GET /api/pi/sessions/:sessionId
* Get session detail with full message history (used by active chat to load messages)
*/
piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const sessionId = ctx.req.param('sessionId');
if (!sessionId) {
return ctx.json({ error: 'Session ID required' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
const { meta, messages } = await storage.loadSession(userHome, sessionId);
return ctx.json({ session: { ...meta, messages } });
} catch {
return ctx.json({ error: 'Session not found' }, 404);
}
});
/**
* POST /api/pi/stt
*/
-832
View File
@@ -1,832 +0,0 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { randomUUID } from 'crypto';
import type {
SessionMeta,
Message,
GroupMeta,
MessageCost,
JnlSessionHeader,
JnlEntry,
JnlMessageEntry,
JnlSessionInfoEntry,
JnlTextContent,
JnlToolCall,
SessionIndex,
SessionIndexEntry,
} from './types';
// ── Path helpers ───────────────────────────────────────────────────────
const SESSIONS_ROOT = '.pi/agent/sessions';
const OFFICER_DIR = '.officer';
const INDEX_FILE = 'index.json';
const GROUPS_DIR = 'groups';
function getSessionsDir(baseCwd: string): string {
return path.join(baseCwd, SESSIONS_ROOT);
}
function getOfficerDir(baseCwd: string): string {
return path.join(baseCwd, SESSIONS_ROOT, OFFICER_DIR);
}
function getIndexPath(baseCwd: string): string {
return path.join(getOfficerDir(baseCwd), INDEX_FILE);
}
function getGroupsDir(baseCwd: string): string {
return path.join(getOfficerDir(baseCwd), GROUPS_DIR);
}
function getGroupPath(baseCwd: string, groupSlug: string): string {
return path.join(getGroupsDir(baseCwd), `${groupSlug}.json`);
}
export function encodeCwdDir(cwd: string): string {
return cwd.replace(/\//g, '-');
}
function buildJnlFilename(createdAt: number, sessionId: string): string {
return `${createdAt}_${sessionId}.jsonl`;
}
function buildRelativePath(cwd: string, createdAt: number, sessionId: string): string {
return path.join(encodeCwdDir(cwd), buildJnlFilename(createdAt, sessionId));
}
// ── Hex ID generator ───────────────────────────────────────────────────
export function generateHexId(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ── JSONL serialization ────────────────────────────────────────────────
function serializeJnlFile(header: JnlSessionHeader, entries: JnlEntry[]): string {
const lines = [JSON.stringify(header)];
for (const entry of entries) {
lines.push(JSON.stringify(entry));
}
return lines.join('\n') + '\n';
}
function parseJnlFile(content: string): { header: JnlSessionHeader; entries: JnlEntry[] } {
const lines = content.trim().split('\n');
if (lines.length === 0) {
throw new Error('Empty JSONL file');
}
const header = JSON.parse(lines[0]!) as JnlSessionHeader;
const entries: JnlEntry[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i]!.trim();
if (!line) continue;
entries.push(JSON.parse(line) as JnlEntry);
}
return { header, entries };
}
// ── Index management ───────────────────────────────────────────────────
async function loadIndex(baseCwd: string): Promise<SessionIndex> {
try {
const indexPath = getIndexPath(baseCwd);
const content = await fs.readFile(indexPath, 'utf-8');
return JSON.parse(content) as SessionIndex;
} catch {
return {};
}
}
async function saveIndex(baseCwd: string, index: SessionIndex): Promise<void> {
const officerDir = getOfficerDir(baseCwd);
await fs.mkdir(officerDir, { recursive: true });
const indexPath = getIndexPath(baseCwd);
await fs.writeFile(indexPath, JSON.stringify(index, null, 2));
}
// ── Message ↔ JSONL conversion ─────────────────────────────────────────
export function messagesToJnlEntries(messages: Message[], meta: SessionMeta): JnlEntry[] {
const entries: JnlEntry[] = [];
let prevId: string | undefined;
let i = 0;
while (i < messages.length) {
const msg = messages[i]!;
if (msg.role === 'user') {
const id = generateHexId();
const entry: JnlMessageEntry = {
type: 'message',
id,
parentId: prevId,
timestamp: new Date(msg.timestamp).toISOString(),
message: { role: 'user', content: [{ type: 'text' as const, text: msg.text ?? '' }] },
};
entries.push(entry);
prevId = id;
i++;
} else if (msg.role === 'assistant') {
const content: Array<JnlTextContent | JnlToolCall> = [];
if (msg.text) {
content.push({ type: 'text', text: msg.text });
}
// Collect following tool messages
const toolMessages: Message[] = [];
let j = i + 1;
while (j < messages.length && messages[j]!.role === 'tool') {
const toolMsg = messages[j]!;
content.push({
type: 'tool_use',
id: toolMsg.toolCallId ?? generateHexId(),
name: toolMsg.toolName ?? 'unknown',
input: toolMsg.toolInput ?? {},
});
toolMessages.push(toolMsg);
j++;
}
const assistId = generateHexId();
const assistEntry: JnlMessageEntry = {
type: 'message',
id: assistId,
parentId: prevId,
timestamp: new Date(msg.timestamp).toISOString(),
message: { role: 'assistant', content },
};
entries.push(assistEntry);
prevId = assistId;
// Emit toolResult entries for tools with output
for (const toolMsg of toolMessages) {
if (toolMsg.output !== undefined) {
const trId = generateHexId();
const trEntry: JnlMessageEntry = {
type: 'message',
id: trId,
parentId: prevId,
timestamp: new Date(toolMsg.timestamp).toISOString(),
message: {
role: 'toolResult',
toolCallId: toolMsg.toolCallId ?? '',
toolName: toolMsg.toolName ?? 'unknown',
content: [{ type: 'text', text: toolMsg.output }],
isError: toolMsg.isError,
},
};
entries.push(trEntry);
prevId = trId;
}
}
i = j;
} else if (msg.role === 'tool') {
// Standalone tool without preceding assistant (edge case)
const content: Array<JnlTextContent | JnlToolCall> = [
{
type: 'tool_use',
id: msg.toolCallId ?? generateHexId(),
name: msg.toolName ?? 'unknown',
input: msg.toolInput ?? {},
},
];
const assistId = generateHexId();
const assistEntry: JnlMessageEntry = {
type: 'message',
id: assistId,
parentId: prevId,
timestamp: new Date(msg.timestamp).toISOString(),
message: { role: 'assistant', content },
};
entries.push(assistEntry);
prevId = assistId;
if (msg.output !== undefined) {
const trId = generateHexId();
const trEntry: JnlMessageEntry = {
type: 'message',
id: trId,
parentId: prevId,
timestamp: new Date(msg.timestamp).toISOString(),
message: {
role: 'toolResult',
toolCallId: msg.toolCallId ?? '',
toolName: msg.toolName ?? 'unknown',
content: [{ type: 'text', text: msg.output }],
isError: msg.isError,
},
};
entries.push(trEntry);
prevId = trId;
}
i++;
} else {
i++;
}
}
// Append session_info entry
const infoId = generateHexId();
const infoEntry: JnlSessionInfoEntry = {
type: 'session_info',
id: infoId,
parentId: prevId,
timestamp: new Date(meta.updatedAt).toISOString(),
name: meta.title,
officer: {
cost: meta.cost,
model: meta.model,
groupSlug: meta.groupSlug ?? null,
messageCount: meta.messageCount,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
context: meta.context,
contextId: meta.contextId,
},
};
entries.push(infoEntry);
return entries;
}
type ParsedSession = {
messages: Message[];
sessionInfo: JnlSessionInfoEntry | null;
};
export function jnlEntriesToMessages(entries: JnlEntry[]): ParsedSession {
const messages: Message[] = [];
let sessionInfo: JnlSessionInfoEntry | null = null;
for (const entry of entries) {
if (entry.type === 'session_info') {
sessionInfo = entry as JnlSessionInfoEntry;
continue;
}
if (entry.type !== 'message') continue;
const msgEntry = entry as JnlMessageEntry;
const ts = new Date(entry.timestamp).getTime();
if (msgEntry.message.role === 'user') {
const rawContent = msgEntry.message.content;
const text =
typeof rawContent === 'string'
? rawContent
: (rawContent as Array<JnlTextContent>).map((c) => c.text).join('\n');
messages.push({
id: entry.id,
timestamp: ts,
role: 'user',
text,
});
} else if (msgEntry.message.role === 'assistant') {
const contentBlocks = msgEntry.message.content as Array<JnlTextContent | JnlToolCall>;
let text = '';
const toolCalls: JnlToolCall[] = [];
for (const block of contentBlocks) {
if (block.type === 'text') {
text += (text ? '\n' : '') + block.text;
} else if (block.type === 'tool_use') {
toolCalls.push(block);
}
}
if (text) {
messages.push({
id: entry.id,
timestamp: ts,
role: 'assistant',
text,
});
}
// Create tool messages from tool_use blocks
for (const tc of toolCalls) {
messages.push({
id: randomUUID(),
timestamp: ts,
role: 'tool',
toolCallId: tc.id,
toolName: tc.name,
toolInput: tc.input,
});
}
} else if (msgEntry.message.role === 'toolResult') {
const trMsg = msgEntry.message as {
role: 'toolResult';
toolCallId: string;
toolName: string;
content: Array<JnlTextContent>;
isError?: boolean;
};
const outputText = trMsg.content.map((c) => c.text).join('\n');
// Find matching tool message and update it with output
for (let k = messages.length - 1; k >= 0; k--) {
const m = messages[k]!;
if (m.role === 'tool' && m.toolCallId === trMsg.toolCallId) {
m.output = outputText;
m.isError = trMsg.isError;
break;
}
}
}
}
return { messages, sessionInfo };
}
// ── Helper: build SessionMeta from index entry ─────────────────────────
function indexEntryToMeta(sessionId: string, entry: SessionIndexEntry): SessionMeta {
return {
id: sessionId,
title: entry.title,
model: entry.model,
cwd: entry.cwd,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
messageCount: entry.messageCount,
cost: entry.cost,
groupSlug: entry.groupSlug ?? null,
context: entry.context,
contextId: entry.contextId,
};
}
function metaToIndexEntry(meta: SessionMeta, relFile: string): SessionIndexEntry {
return {
file: relFile,
title: meta.title,
model: meta.model,
cwd: meta.cwd,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
messageCount: meta.messageCount,
cost: meta.cost,
groupSlug: meta.groupSlug ?? null,
context: meta.context,
contextId: meta.contextId,
};
}
// ── Path resolution ─────────────────────────────────────────────────────
export async function getSessionFilePath(baseCwd: string, sessionId: string): Promise<string | null> {
const index = await loadIndex(baseCwd);
const entry = index[sessionId];
if (!entry) return null;
return path.join(getSessionsDir(baseCwd), entry.file);
}
// ── Session CRUD ───────────────────────────────────────────────────────
export async function saveSession(
baseCwd: string,
sessionId: string,
meta: SessionMeta,
messages: Message[],
): Promise<void> {
const index = await loadIndex(baseCwd);
// Reuse existing file path or create new one
const existing = index[sessionId];
const relFile = existing?.file ?? buildRelativePath(meta.cwd, meta.createdAt, sessionId);
const absFile = path.join(getSessionsDir(baseCwd), relFile);
await fs.mkdir(path.dirname(absFile), { recursive: true });
const header: JnlSessionHeader = {
type: 'session',
version: 3,
id: sessionId,
timestamp: new Date(meta.createdAt).toISOString(),
cwd: meta.cwd,
};
const entries = messagesToJnlEntries(messages, meta);
const content = serializeJnlFile(header, entries);
await fs.writeFile(absFile, content);
// Update index
index[sessionId] = metaToIndexEntry(meta, relFile);
await saveIndex(baseCwd, index);
}
export async function loadSession(
baseCwd: string,
sessionId: string,
_groupSlug?: string | null,
): Promise<{ meta: SessionMeta; messages: Message[] }> {
const index = await loadIndex(baseCwd);
const entry = index[sessionId];
if (!entry) {
throw new Error(`Session not found: ${sessionId}`);
}
const absFile = path.join(getSessionsDir(baseCwd), entry.file);
const raw = await fs.readFile(absFile, 'utf-8');
const { entries } = parseJnlFile(raw);
const { messages, sessionInfo } = jnlEntriesToMessages(entries);
// Reconstruct meta from index entry (authoritative) enriched by session_info
const meta: SessionMeta = indexEntryToMeta(sessionId, entry);
// If session_info has officer data, prefer those for fields that may differ
if (sessionInfo?.officer) {
meta.cost = sessionInfo.officer.cost;
meta.messageCount = sessionInfo.officer.messageCount;
}
if (sessionInfo?.name) {
meta.title = sessionInfo.name;
}
return { meta, messages };
}
export async function sessionExists(
baseCwd: string,
sessionId: string,
_groupSlug?: string | null,
): Promise<boolean> {
const index = await loadIndex(baseCwd);
return sessionId in index;
}
export async function updateSessionMeta(
baseCwd: string,
sessionId: string,
updates: Partial<SessionMeta>,
_groupSlug?: string | null,
): Promise<SessionMeta> {
const { meta, messages } = await loadSession(baseCwd, sessionId);
const updatedMeta: SessionMeta = {
...meta,
...updates,
updatedAt: Date.now(),
};
await saveSession(baseCwd, sessionId, updatedMeta, messages);
return updatedMeta;
}
export async function deleteSession(
baseCwd: string,
sessionId: string,
_groupSlug?: string | null,
): Promise<void> {
const index = await loadIndex(baseCwd);
const entry = index[sessionId];
if (entry) {
const absFile = path.join(getSessionsDir(baseCwd), entry.file);
await fs.rm(absFile, { force: true });
// Clean up empty cwd directory
try {
const cwdDir = path.dirname(absFile);
const remaining = await fs.readdir(cwdDir);
if (remaining.length === 0) {
await fs.rmdir(cwdDir);
}
} catch {
// Ignore cleanup errors
}
}
delete index[sessionId];
await saveIndex(baseCwd, index);
}
type SessionFilter = {
context?: string;
contextId?: string;
};
export async function listUserSessions(baseCwd: string, filter?: SessionFilter): Promise<SessionMeta[]> {
let index = await loadIndex(baseCwd);
if (Object.keys(index).length === 0) {
index = await rebuildIndex(baseCwd);
}
let sessions: SessionMeta[] = Object.entries(index).map(([id, entry]) => indexEntryToMeta(id, entry));
if (filter?.context) {
if (filter.context === 'chat') {
// 'chat' matches sessions with no context or context='chat'
sessions = sessions.filter((s) => !s.context || s.context === 'chat');
} else if (filter.context === 'dashboard') {
// 'dashboard' matches both old 'workspace' and new 'dashboard' context
sessions = sessions.filter((s) => (s.context === 'dashboard' || s.context === 'workspace') && s.contextId === filter.contextId);
} else {
sessions = sessions.filter((s) => s.context === filter.context && s.contextId === filter.contextId);
}
}
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
return sessions;
}
export async function searchSessions(
baseCwd: string,
query: string,
): Promise<Array<SessionMeta & { preview?: string; relevance?: number }>> {
const index = await loadIndex(baseCwd);
const lowerQuery = query.toLowerCase();
const results: Array<SessionMeta & { preview?: string; relevance?: number }> = [];
for (const [sessionId, entry] of Object.entries(index)) {
let relevance = 0;
let preview = '';
// Check title (from index — fast)
if (entry.title.toLowerCase().includes(lowerQuery)) {
relevance += 1.0;
preview = entry.title;
}
// Check group membership — boost relevance if group matches
if (entry.groupSlug) {
try {
const group = await loadGroup(baseCwd, entry.groupSlug);
if (group.name.toLowerCase().includes(lowerQuery)) {
relevance += 2.0;
}
if (group.description?.toLowerCase().includes(lowerQuery)) {
relevance += 1.5;
}
} catch {
// Group file missing — skip boost
}
}
// Lazy content search — only read JSONL if title didn't match
if (relevance === 0 || !preview) {
try {
const absFile = path.join(getSessionsDir(baseCwd), entry.file);
const raw = await fs.readFile(absFile, 'utf-8');
const { entries } = parseJnlFile(raw);
const { messages } = jnlEntriesToMessages(entries);
for (const msg of messages) {
if (msg.text && msg.text.toLowerCase().includes(lowerQuery)) {
relevance += 0.5;
if (!preview) {
const idx = msg.text.toLowerCase().indexOf(lowerQuery);
const start = Math.max(0, idx - 50);
const end = Math.min(msg.text.length, idx + query.length + 50);
preview = '...' + msg.text.slice(start, end) + '...';
}
}
}
} catch {
// File unreadable — skip
}
}
if (relevance > 0) {
const meta = indexEntryToMeta(sessionId, entry);
results.push({ ...meta, preview, relevance });
}
}
results.sort((a, b) => (b.relevance ?? 0) - (a.relevance ?? 0));
return results;
}
// ── Group management ───────────────────────────────────────────────────
export async function saveGroup(baseCwd: string, groupMeta: GroupMeta): Promise<void> {
const groupsDir = getGroupsDir(baseCwd);
await fs.mkdir(groupsDir, { recursive: true });
const groupPath = getGroupPath(baseCwd, groupMeta.slug);
await fs.writeFile(groupPath, JSON.stringify(groupMeta, null, 2));
}
export async function loadGroup(baseCwd: string, groupSlug: string): Promise<GroupMeta> {
const groupPath = getGroupPath(baseCwd, groupSlug);
const content = await fs.readFile(groupPath, 'utf-8');
return JSON.parse(content) as GroupMeta;
}
export async function groupExists(baseCwd: string, groupSlug: string): Promise<boolean> {
try {
const groupPath = getGroupPath(baseCwd, groupSlug);
await fs.access(groupPath);
return true;
} catch {
return false;
}
}
export async function listGroups(baseCwd: string): Promise<GroupMeta[]> {
const groupsDir = getGroupsDir(baseCwd);
try {
await fs.access(groupsDir);
} catch {
return [];
}
const files = await fs.readdir(groupsDir);
const groups: GroupMeta[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filePath = path.join(groupsDir, file);
const content = await fs.readFile(filePath, 'utf-8');
groups.push(JSON.parse(content) as GroupMeta);
} catch {
continue;
}
}
groups.sort((a, b) => b.updatedAt - a.updatedAt);
return groups;
}
export async function updateGroupMeta(
baseCwd: string,
groupSlug: string,
updates: Partial<GroupMeta>,
): Promise<GroupMeta> {
const groupMeta = await loadGroup(baseCwd, groupSlug);
const updated: GroupMeta = {
...groupMeta,
...updates,
slug: groupMeta.slug,
updatedAt: Date.now(),
};
await saveGroup(baseCwd, updated);
return updated;
}
export async function deleteGroup(baseCwd: string, groupSlug: string): Promise<void> {
const index = await loadIndex(baseCwd);
// Remove groupSlug from all sessions in this group
let changed = false;
for (const entry of Object.values(index)) {
if (entry.groupSlug === groupSlug) {
entry.groupSlug = null;
changed = true;
}
}
if (changed) {
await saveIndex(baseCwd, index);
}
// Delete group file
const groupPath = getGroupPath(baseCwd, groupSlug);
await fs.rm(groupPath, { force: true });
}
export async function moveSession(
baseCwd: string,
sessionId: string,
fromGroupSlug: string | null,
toGroupSlug: string | null,
): Promise<SessionMeta> {
const index = await loadIndex(baseCwd);
const entry = index[sessionId];
if (!entry) {
throw new Error(`Session not found: ${sessionId}`);
}
entry.groupSlug = toGroupSlug;
entry.updatedAt = Date.now();
await saveIndex(baseCwd, index);
// Update group session counts
if (fromGroupSlug) {
try {
const fromGroup = await loadGroup(baseCwd, fromGroupSlug);
fromGroup.sessionCount = Math.max(0, fromGroup.sessionCount - 1);
fromGroup.updatedAt = Date.now();
await saveGroup(baseCwd, fromGroup);
} catch {
// Group might not exist
}
}
if (toGroupSlug) {
try {
const toGroup = await loadGroup(baseCwd, toGroupSlug);
toGroup.sessionCount += 1;
toGroup.updatedAt = Date.now();
await saveGroup(baseCwd, toGroup);
} catch {
// Group might not exist
}
}
return indexEntryToMeta(sessionId, entry);
}
// ── Index recovery ─────────────────────────────────────────────────────
export async function rebuildIndex(baseCwd: string): Promise<SessionIndex> {
const sessionsDir = getSessionsDir(baseCwd);
const index: SessionIndex = {};
try {
await fs.access(sessionsDir);
} catch {
return index;
}
const cwdDirs = await fs.readdir(sessionsDir);
for (const dir of cwdDirs) {
if (dir === OFFICER_DIR) continue;
const dirPath = path.join(sessionsDir, dir);
const stat = await fs.stat(dirPath);
if (!stat.isDirectory()) continue;
const files = await fs.readdir(dirPath);
for (const file of files) {
if (!file.endsWith('.jsonl')) continue;
try {
const filePath = path.join(dirPath, file);
const raw = await fs.readFile(filePath, 'utf-8');
const { header, entries } = parseJnlFile(raw);
const relFile = path.join(dir, file);
let title = '';
let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
let model = '';
let messageCount = 0;
let createdAt = new Date(header.timestamp).getTime();
let updatedAt = createdAt;
let groupSlug: string | null = null;
let context: string | undefined;
let contextId: string | undefined;
// Count message entries and find session_info
for (const entry of entries) {
if (entry.type === 'message') {
messageCount++;
} else if (entry.type === 'session_info') {
const info = entry as JnlSessionInfoEntry;
title = info.name;
if (info.officer) {
cost = info.officer.cost;
model = info.officer.model;
messageCount = info.officer.messageCount;
createdAt = info.officer.createdAt;
updatedAt = info.officer.updatedAt;
groupSlug = info.officer.groupSlug ?? null;
context = info.officer.context;
contextId = info.officer.contextId;
}
}
}
index[header.id] = {
file: relFile,
title,
model,
cwd: header.cwd,
createdAt,
updatedAt,
messageCount,
cost,
groupSlug,
context,
contextId,
};
} catch {
continue;
}
}
}
await saveIndex(baseCwd, index);
return index;
}
+5 -27
View File
@@ -2,7 +2,6 @@ import type { ServerWebSocket } from 'bun';
import { randomUUID } from 'crypto';
import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
import { sessionManager } from './session-manager';
import * as storage from './storage';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { ensureClaudeSessionsCwd } from '../chat/claude-sessions';
import * as sidecar from '@@/sidecar-registry';
@@ -113,7 +112,7 @@ export function close(ws: ServerWebSocket<WSData>): void {
}
}
function createEventHandler(sessionId: string, model: string, cwd: string, storageDir: string) {
function createEventHandler(sessionId: string, model: string, cwd: string) {
return async (event: PiEvent): Promise<void> => {
const session = sessionManager.getSession(sessionId);
if (!session) return;
@@ -229,14 +228,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
session.meta.cost.outputTokens += event.cost.outputTokens;
session.meta.cost.totalUSD += event.cost.totalUSD;
session.meta.updatedAt = Date.now();
// Save session to disk
try {
await storage.saveSession(storageDir, sessionId, session.meta, session.messages);
logger.info('Session saved to disk', { sessionId, messageCount: session.messages.length });
} catch (err) {
logger.error('Failed to save session', { sessionId, error: String(err) });
}
// No disk persistence — Claude's transcript is the record.
break;
}
@@ -315,7 +307,6 @@ async function handleClaudeCodeChat(
effectivePrompt: string,
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDirForRole(email, ws.data.role);
// The standalone /chat route runs from a chosen working directory (the pwd selector) or, by default,
// a dedicated `claude_sessions` dir — so transcripts form their own Claude "project" group per cwd.
@@ -360,7 +351,7 @@ async function handleClaudeCodeChat(
session.isGenerating = true;
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
const onEvent = createEventHandler(sessionId, model, cwd);
try {
const handle = await sendClaudeCodeStreaming({
@@ -390,29 +381,16 @@ async function handleResume(
ws: ServerWebSocket<WSData>,
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
): Promise<void> {
const { email } = ws.data;
const { sessionId } = msg;
try {
let session = sessionManager.getSession(sessionId);
const session = sessionManager.getSession(sessionId);
if (!session) {
const homeDir = getHomeDirForRole(email, ws.data.role);
try {
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
session = sessionManager.getOrCreate(sessionId, email, meta.cwd, meta.model);
session.messages = messages;
session.meta = meta;
logger.info('Loaded session from disk', { sessionId, messageCount: messages.length });
} catch (err) {
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
// Sessions live in memory for the connection's lifetime; there's no disk store to reload from.
sendToClient(ws, { type: 'error', message: 'Session not found', errorCode: 'SESSION_NOT_FOUND' });
return;
}
}
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
@@ -1,171 +0,0 @@
import { createRouter } from '../../create-router';
import {
listSavedSessions,
getSavedSession,
createSavedSession,
updateSavedSessionMessages,
deleteSavedSession,
} from 'officerdb';
import * as storage from '../pi/storage';
import { getHomeDirForRole } from '../../data-path';
import type { Message, MessageCost } from '../pi/types';
export const savedSessionsRouter = createRouter();
// POST /saved-sessions — save session instantly (no LLM summary)
savedSessionsRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const { sessionId } = ctx.get('body') as { sessionId: string };
if (!sessionId) {
return ctx.json({ error: 'sessionId is required' }, 400);
}
const homeDir = getHomeDirForRole(user.email, user.role);
let meta;
let messages: Message[];
try {
const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta;
messages = loaded.messages;
} catch {
return ctx.json({ error: 'Session not found on disk' }, 404);
}
try {
const saved = await createSavedSession({
userId: user.id,
provider: meta.model,
context: meta.context ?? null,
contextId: meta.contextId ?? null,
title: meta.title,
summary: meta.title,
rawMessages: messages,
cwd: meta.cwd,
cost: meta.cost as unknown as Record<string, unknown>,
});
return ctx.json(saved, 201);
} catch (err) {
console.error('Failed to create saved session:', err);
return ctx.json({ error: 'Failed to save session' }, 500);
}
});
// GET /saved-sessions — list (no rawMessages)
savedSessionsRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const rows = await listSavedSessions(user.id);
return ctx.json(rows);
});
// GET /saved-sessions/:id — full record
savedSessionsRouter.get('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const session = await getSavedSession(id, user.id);
if (!session) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json(session);
});
// PUT /saved-sessions/:id — update messages from disk (auto-save after each turn)
savedSessionsRouter.put('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const { sessionId, resumedMessageCount } = ctx.get('body') as { sessionId: string; resumedMessageCount?: number };
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
if (!sessionId) {
return ctx.json({ error: 'sessionId is required' }, 400);
}
const homeDir = getHomeDirForRole(user.email, user.role);
let meta;
let newMessages: Message[];
try {
const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta;
newMessages = loaded.messages;
} catch {
return ctx.json({ error: 'Session not found on disk' }, 404);
}
// If resuming, merge original pre-resume messages with new disk messages
let finalMessages = newMessages;
if (resumedMessageCount && resumedMessageCount > 0) {
const existing = await getSavedSession(id, user.id);
if (!existing) {
return ctx.json({ error: 'Not found' }, 404);
}
const oldMessages = ((existing.rawMessages ?? []) as Message[]).slice(0, resumedMessageCount);
finalMessages = [...oldMessages, ...newMessages];
}
const updated = await updateSavedSessionMessages(
id,
user.id,
finalMessages,
meta.cost as unknown as Record<string, unknown>,
);
if (!updated) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json({ ok: true });
});
// DELETE /saved-sessions/:id
savedSessionsRouter.delete('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const deleted = await deleteSavedSession(id, user.id);
if (!deleted) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json({ ok: true });
});
// POST /saved-sessions/:id/resume — get full session for resuming
savedSessionsRouter.post('/:id/resume', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const session = await getSavedSession(id, user.id);
if (!session) {
return ctx.json({ error: 'Not found' }, 404);
}
const cost = session.cost as unknown as MessageCost;
return ctx.json({
context: session.context,
cwd: session.cwd,
model: session.provider,
cost,
rawMessages: session.rawMessages,
});
});
-2
View File
@@ -30,7 +30,6 @@ import { browserRouter } from './api/browser/router';
import { desktopRouter } from './api/desktop/rest';
import { appsRouter, appServeRouter } from './api/apps';
import { bugReportRouter } from './api/bug-report/bug-report';
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { broadcastPanelRefresh } from './api/terminal/websocket';
@@ -103,7 +102,6 @@ protectedRouter.route('/channels', channelsRouter);
protectedRouter.route('/browser', browserRouter);
protectedRouter.route('/apps', appsRouter);
protectedRouter.route('/bug-report', bugReportRouter);
protectedRouter.route('/saved-sessions', savedSessionsRouter);
protectedRouter.route('/chat', chatRouter);
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
@@ -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 (
// 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>
{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>
</>
);
};
);
@@ -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);
const onTurnComplete = useCallback(() => {
// Refresh the /chat list — Claude has just written/appended this session's transcript.
const onTurnComplete = useCallback(() => {
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 () => {
-1
View File
@@ -17,7 +17,6 @@ export {
WebpageDialog,
EmbeddableChat,
useChat,
ChatList,
useSlashCommands,
useAttachments,
useAudioRecording,
-2
View File
@@ -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>;