From 70555c8d717716fe8536b52d529b065a1f375ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 24 Jul 2026 15:29:26 +0000 Subject: [PATCH] chat: retire the old saved-session store (Stage 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/apps/officer-web/App.tsx | 1 - .../Screens/Dashboard/ChatHistory/Widget.tsx | 47 - .../Screens/Dashboard/ChatHistory/index.tsx | 44 +- src/apps/officer-web/state/useInitialData.ts | 4 +- src/databases/officer_db/src/index.ts | 7 - .../officer_db/src/queries/saved-sessions.ts | 59 -- src/databases/officer_db/src/schema/chat.ts | 22 - src/databases/officer_db/src/schema/index.ts | 1 - src/databases/officer_db/src/types.ts | 2 - src/servers/api/pi/rest.ts | 27 - src/servers/api/pi/storage.ts | 832 ------------------ src/servers/api/pi/websocket.ts | 36 +- .../api/saved-sessions/saved-sessions.ts | 171 ---- src/servers/hono.ts | 2 - .../officerdev/src/apps/Chat/ChatHeader.tsx | 174 +--- .../src/apps/Chat/ChatList/index.tsx | 42 - .../src/apps/Chat/ChatPanelWrapper.tsx | 112 +-- .../officerdev/src/apps/Chat/index.ts | 1 - .../src/apps/ChatHistory/ChatDetailPanel.tsx | 79 +- .../officerdev/src/hooks/useChat.ts | 73 -- src/workspaces/officerdev/src/index.ts | 1 - src/workspaces/state/src/index.ts | 2 - src/workspaces/state/src/useSavedSessions.ts | 105 --- 23 files changed, 31 insertions(+), 1813 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx delete mode 100644 src/databases/officer_db/src/queries/saved-sessions.ts delete mode 100644 src/databases/officer_db/src/schema/chat.ts delete mode 100644 src/servers/api/pi/storage.ts delete mode 100644 src/servers/api/saved-sessions/saved-sessions.ts delete mode 100644 src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx delete mode 100644 src/workspaces/state/src/useSavedSessions.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 64439afa..3a65be7d 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -52,7 +52,6 @@ export function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx deleted file mode 100644 index a29e7e19..00000000 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx +++ /dev/null @@ -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 ( - -
- {sessions.length === 0 ? ( -

No saved sessions yet

- ) : ( -
    - {sessions.map((session) => ( -
  • -
    - -
    - {session.title} - - {new Date(session.createdAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} - -
    -
    - -
  • - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 8a1b0e0c..e55cf7b2 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -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(['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('chat:selected-session', null); const rawWorkspace = useDashboardState('screens/chat', defaultLayout); const isMobile = useIsMobile(); const navigate = useNavigate(); - const { resumeSession } = useSavedSessions(); - const loadedSavedIdRef = useRef(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 (
diff --git a/src/apps/officer-web/state/useInitialData.ts b/src/apps/officer-web/state/useInitialData.ts index da3d565a..47f58923 100644 --- a/src/apps/officer-web/state/useInitialData.ts +++ b/src/apps/officer-web/state/useInitialData.ts @@ -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 }; }; diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index b17802a9..e38e6ac3 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -64,13 +64,6 @@ export { upsertDefaults, } from './queries/dashboards'; -export { - listSavedSessions, - getSavedSession, - createSavedSession, - updateSavedSessionMessages, - deleteSavedSession, -} from './queries/saved-sessions'; export { createPipelineJob, diff --git a/src/databases/officer_db/src/queries/saved-sessions.ts b/src/databases/officer_db/src/queries/saved-sessions.ts deleted file mode 100644 index c29e375b..00000000 --- a/src/databases/officer_db/src/queries/saved-sessions.ts +++ /dev/null @@ -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[]> { - 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 { - 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 { - 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 { - 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 { - const result = await db - .delete(savedSessions) - .where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId))) - .returning({ id: savedSessions.id }); - return result.length > 0; -} diff --git a/src/databases/officer_db/src/schema/chat.ts b/src/databases/officer_db/src/schema/chat.ts deleted file mode 100644 index bfadb9e0..00000000 --- a/src/databases/officer_db/src/schema/chat.ts +++ /dev/null @@ -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)], -); diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index bce2a211..4de6b4ab 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,6 +1,5 @@ export * from './auth'; export * from './user-data'; -export * from './chat'; export * from './dashboards'; export * from './operations'; export * from './server'; diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index 45dc0520..72102474 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -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 ── diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index ece6ca55..d728d849 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -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 */ diff --git a/src/servers/api/pi/storage.ts b/src/servers/api/pi/storage.ts deleted file mode 100644 index 83ed58c6..00000000 --- a/src/servers/api/pi/storage.ts +++ /dev/null @@ -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 { - 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 { - 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 = []; - 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 = [ - { - 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).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; - 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; - 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 { - 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 { - 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 { - const index = await loadIndex(baseCwd); - return sessionId in index; -} - -export async function updateSessionMeta( - baseCwd: string, - sessionId: string, - updates: Partial, - _groupSlug?: string | null, -): Promise { - 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 { - 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 { - 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> { - const index = await loadIndex(baseCwd); - const lowerQuery = query.toLowerCase(); - const results: Array = []; - - 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 { - 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 { - 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 { - try { - const groupPath = getGroupPath(baseCwd, groupSlug); - await fs.access(groupPath); - return true; - } catch { - return false; - } -} - -export async function listGroups(baseCwd: string): Promise { - 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, -): Promise { - 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 { - 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 { - 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 { - 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; -} diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 9183c639..f2626ded 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -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): void { } } -function createEventHandler(sessionId: string, model: string, cwd: string, storageDir: string) { +function createEventHandler(sessionId: string, model: string, cwd: string) { return async (event: PiEvent): Promise => { 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 { 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,28 +381,15 @@ async function handleResume( ws: ServerWebSocket, msg: { sessionId: string; cwd?: string; cwdRoot?: string }, ): Promise { - 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) }); - sendToClient(ws, { type: 'error', message: 'Session not found', errorCode: 'SESSION_NOT_FOUND' }); - return; - } + // 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); diff --git a/src/servers/api/saved-sessions/saved-sessions.ts b/src/servers/api/saved-sessions/saved-sessions.ts deleted file mode 100644 index 78d0a339..00000000 --- a/src/servers/api/saved-sessions/saved-sessions.ts +++ /dev/null @@ -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, - }); - - 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, - ); - - 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, - }); -}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index aa044052..b1236e33 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index ad68fe91..7ab62bdb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -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('chat:panel-session', null); - const [activeSessionId] = usePanelChannel('chat:active-session', null); - const [savedId, setSavedId] = usePanelChannel('chat:saved-id', null); - const [open, setOpen] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [isResuming, setIsResuming] = useState(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 - {isSaving ? ( - - - Saving… - - ) : isSaved ? ( - - - - ) : activeSessionId ? ( - - ) : null} - - - - - -
- Saved Sessions - -
-
- {sessions.length === 0 ? ( -
No saved sessions
- ) : ( - sessions.map((session) => ( - -
-
- {new Date(session.createdAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} -
- - )) - )} -
- - - - ); -}; +// 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 = () => ( + <> + + Chat + +); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx deleted file mode 100644 index 1d05e5f2..00000000 --- a/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx +++ /dev/null @@ -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 ( -
- No saved sessions yet. -
- ); - } - - return ( -
- {sessions.map((session) => ( -
-
- -
-
- {session.title} -
-
- {new Date(session.createdAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} -
-
-
-
- ))} -
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index cbfe32a2..b37b6772 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -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(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 = ({ { ? { context: 'dashboard' as const, contextId: dashboardId } : {}; - const [savedId, setSavedId] = usePanelChannel('chat:saved-id', null); - const [selection, setSelection] = usePanelChannel('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('chat:active-session', null); - - // Auto-load the latest saved session for this dashboard/project context - const { sessions, resumeSession } = useSavedSessions(); - const autoLoadedRef = useRef(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('preview:refresh', 0); const [, setFilesRefresh] = usePanelChannel('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 ( void; - isSaving: boolean; }; -function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved, onSave, isSaving }: DetailBarProps) { +function DetailBar({ sessionTitle, isConnected, isGenerating }: DetailBarProps) { return (
-
- {isSaving ? ( - - - Saving… - - ) : isSaved ? ( - - - Saved - - ) : sessionId ? ( - - ) : null} -
{sessionTitle ?? 'New chat'}
@@ -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(SAVED_ID_CHANNEL, initialSavedId ?? null); - const savedIdRef = useRef(savedId); - savedIdRef.current = savedId; - const chatSessionRef = useRef(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 (
- + { ); } - const savedId = selected.id.startsWith('saved:') ? Number(selected.id.replace('saved:', '')) : undefined; - return ( ); }; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 75454413..573ac52e 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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 () => { diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 0eb2a1de..66a1a493 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -17,7 +17,6 @@ export { WebpageDialog, EmbeddableChat, useChat, - ChatList, useSlashCommands, useAttachments, useAudioRecording, diff --git a/src/workspaces/state/src/index.ts b/src/workspaces/state/src/index.ts index b266f4dd..deba72a5 100644 --- a/src/workspaces/state/src/index.ts +++ b/src/workspaces/state/src/index.ts @@ -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'; diff --git a/src/workspaces/state/src/useSavedSessions.ts b/src/workspaces/state/src/useSavedSessions.ts deleted file mode 100644 index e8c11286..00000000 --- a/src/workspaces/state/src/useSavedSessions.ts +++ /dev/null @@ -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; - 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({ - queryKey: ['SAVED_SESSIONS'], - enabled: isAuthenticated, - queryFn: () => client.get('/saved-sessions'), - }); - - async function saveSession(sessionId: string) { - try { - const result = await client.post('/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(['SAVED_SESSIONS'], (prev) => prev?.filter((s) => s.id !== id) ?? []); - } - - async function resumeSession(id: number) { - return client.post(`/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;