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:
@@ -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,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 };
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ export {
|
||||
WebpageDialog,
|
||||
EmbeddableChat,
|
||||
useChat,
|
||||
ChatList,
|
||||
useSlashCommands,
|
||||
useAttachments,
|
||||
useAudioRecording,
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user