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="/settings/apps" element={<Dashboard.AppsSettings />} />
|
||||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
<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="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
<Route path="/plans" element={<Dashboard.Plans />} />
|
||||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
<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 { useParams, useNavigate } from 'react-router';
|
||||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||||
import type { ChatMessage } from 'officerdev';
|
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
|
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
export { ChatHistory as ChatHistoryApp } from './Widget';
|
|
||||||
|
|
||||||
// Allowed panel app types for the /chat screen
|
// Allowed panel app types for the /chat screen
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
|
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) => {
|
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 [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resumeSession } = useSavedSessions();
|
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
||||||
const loadedSavedIdRef = useRef<number | null>(null);
|
|
||||||
const savedId = savedIdParam ? Number(savedIdParam) : null;
|
|
||||||
const mobilePanelId = isMobile && (sessionId || isNew || savedId) ? 'chat-detail' : undefined;
|
|
||||||
|
|
||||||
// Normalize synchronously so the wrong panel never renders
|
// Normalize synchronously so the wrong panel never renders
|
||||||
const workspace = useMemo(() => {
|
const workspace = useMemo(() => {
|
||||||
@@ -64,38 +57,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
setSelected({ id: `new:${Date.now()}` });
|
setSelected({ id: `new:${Date.now()}` });
|
||||||
return;
|
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;
|
if (!sessionId) return;
|
||||||
setSelected({ id: sessionId });
|
setSelected({ id: sessionId });
|
||||||
}, [sessionId, isNew, savedId]);
|
}, [sessionId, isNew]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useSavedSessions } from 'state/useSavedSessions';
|
|
||||||
import { usePlans } from 'state/usePlans';
|
import { usePlans } from 'state/usePlans';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
import { usePiModels } from 'state/useModels';
|
import { usePiModels } from 'state/useModels';
|
||||||
@@ -6,12 +5,11 @@ import { useAccessPolicy } from 'state/useAccessPolicy';
|
|||||||
import { useColorModeSync } from './useThemeSync';
|
import { useColorModeSync } from './useThemeSync';
|
||||||
|
|
||||||
export const useInitialData = () => {
|
export const useInitialData = () => {
|
||||||
const { sessions } = useSavedSessions();
|
|
||||||
const { plans } = usePlans();
|
const { plans } = usePlans();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
usePiModels();
|
usePiModels();
|
||||||
useAccessPolicy();
|
useAccessPolicy();
|
||||||
useColorModeSync();
|
useColorModeSync();
|
||||||
|
|
||||||
return { sessions, plans, settings };
|
return { plans, settings };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,13 +64,6 @@ export {
|
|||||||
upsertDefaults,
|
upsertDefaults,
|
||||||
} from './queries/dashboards';
|
} from './queries/dashboards';
|
||||||
|
|
||||||
export {
|
|
||||||
listSavedSessions,
|
|
||||||
getSavedSession,
|
|
||||||
createSavedSession,
|
|
||||||
updateSavedSessionMessages,
|
|
||||||
deleteSavedSession,
|
|
||||||
} from './queries/saved-sessions';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createPipelineJob,
|
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 './auth';
|
||||||
export * from './user-data';
|
export * from './user-data';
|
||||||
export * from './chat';
|
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './operations';
|
export * from './operations';
|
||||||
export * from './server';
|
export * from './server';
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
|
|||||||
|
|
||||||
// ── Saved Sessions ──
|
// ── Saved Sessions ──
|
||||||
|
|
||||||
export type SavedSessionSelect = typeof Schema.savedSessions.$inferSelect;
|
|
||||||
export type SavedSessionInsert = typeof Schema.savedSessions.$inferInsert;
|
|
||||||
|
|
||||||
// ── Dashboards ──
|
// ── Dashboards ──
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import * as storage from './storage';
|
|
||||||
import { readSttConfig } from '../server-settings/stt';
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
import { listPiModels } from './list-models';
|
import { listPiModels } from './list-models';
|
||||||
import { getHomeDirForRole } from '../../data-path';
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { transcribeAudio } from '../stt/transcribe';
|
import { transcribeAudio } from '../stt/transcribe';
|
||||||
import { getUserSettings } from 'officerdb';
|
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
|
* 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 { randomUUID } from 'crypto';
|
||||||
import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
|
import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
|
||||||
import { sessionManager } from './session-manager';
|
import { sessionManager } from './session-manager';
|
||||||
import * as storage from './storage';
|
|
||||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||||
import { ensureClaudeSessionsCwd } from '../chat/claude-sessions';
|
import { ensureClaudeSessionsCwd } from '../chat/claude-sessions';
|
||||||
import * as sidecar from '@@/sidecar-registry';
|
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> => {
|
return async (event: PiEvent): Promise<void> => {
|
||||||
const session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
if (!session) return;
|
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.outputTokens += event.cost.outputTokens;
|
||||||
session.meta.cost.totalUSD += event.cost.totalUSD;
|
session.meta.cost.totalUSD += event.cost.totalUSD;
|
||||||
session.meta.updatedAt = Date.now();
|
session.meta.updatedAt = Date.now();
|
||||||
|
// No disk persistence — Claude's transcript is the record.
|
||||||
// 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) });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +307,6 @@ async function handleClaudeCodeChat(
|
|||||||
effectivePrompt: string,
|
effectivePrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
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,
|
// 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.
|
// 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;
|
session.isGenerating = true;
|
||||||
|
|
||||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const handle = await sendClaudeCodeStreaming({
|
const handle = await sendClaudeCodeStreaming({
|
||||||
@@ -390,28 +381,15 @@ async function handleResume(
|
|||||||
ws: ServerWebSocket<WSData>,
|
ws: ServerWebSocket<WSData>,
|
||||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
|
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email } = ws.data;
|
|
||||||
const { sessionId } = msg;
|
const { sessionId } = msg;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const homeDir = getHomeDirForRole(email, ws.data.role);
|
// 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' });
|
||||||
try {
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionManager.attachWs(sessionId, ws);
|
sessionManager.attachWs(sessionId, ws);
|
||||||
|
|||||||
@@ -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 { desktopRouter } from './api/desktop/rest';
|
||||||
import { appsRouter, appServeRouter } from './api/apps';
|
import { appsRouter, appServeRouter } from './api/apps';
|
||||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||||
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
|
|
||||||
import { chatRouter } from './api/chat/chat';
|
import { chatRouter } from './api/chat/chat';
|
||||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||||
@@ -103,7 +102,6 @@ protectedRouter.route('/channels', channelsRouter);
|
|||||||
protectedRouter.route('/browser', browserRouter);
|
protectedRouter.route('/browser', browserRouter);
|
||||||
protectedRouter.route('/apps', appsRouter);
|
protectedRouter.route('/apps', appsRouter);
|
||||||
protectedRouter.route('/bug-report', bugReportRouter);
|
protectedRouter.route('/bug-report', bugReportRouter);
|
||||||
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
|
||||||
protectedRouter.route('/chat', chatRouter);
|
protectedRouter.route('/chat', chatRouter);
|
||||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||||
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
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 } from 'lucide-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 type { ChatMessage } from './types';
|
import type { ChatMessage } from './types';
|
||||||
|
|
||||||
export type ChatSessionSelection = {
|
export type ChatSessionSelection = {
|
||||||
@@ -13,163 +9,11 @@ export type ChatSessionSelection = {
|
|||||||
initialMessages?: ChatMessage[];
|
initialMessages?: ChatMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatHeader = () => {
|
// Chat panel header — just the label now that saved-sessions are retired (Claude's transcript store
|
||||||
const { sessions, saveSession, deleteSavedSession, resumeSession } = useSavedSessions();
|
// is the source of truth; the /chat screen is where you browse/resume sessions).
|
||||||
const [, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
|
export const ChatHeader = () => (
|
||||||
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
|
<>
|
||||||
const [savedId, setSavedId] = usePanelChannel<number | null>('chat:saved-id', null);
|
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
const [open, setOpen] = useState(false);
|
<span className="text-xs font-medium truncate flex-1">Chat</span>
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<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 { useWorkspace } from '../../components/Workspace';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
|
|
||||||
import { useChat } from '../../hooks/useChat';
|
import { useChat } from '../../hooks/useChat';
|
||||||
import { EmbeddableChat } from './EmbeddableChat';
|
import { EmbeddableChat } from './EmbeddableChat';
|
||||||
|
|
||||||
import type { ChatSessionSelection } from './ChatHeader';
|
|
||||||
import type { ChatMessage } from './types';
|
|
||||||
|
|
||||||
type ChatPanelInnerProps = {
|
type ChatPanelInnerProps = {
|
||||||
sessionId?: string;
|
|
||||||
model?: string;
|
|
||||||
resumeSummary?: string;
|
|
||||||
initialMessages?: ChatMessage[];
|
|
||||||
savedId?: number;
|
|
||||||
scoped: boolean;
|
scoped: boolean;
|
||||||
sandboxed: boolean;
|
sandboxed: boolean;
|
||||||
cwdParam?: { root?: string; path: string };
|
cwdParam?: { root?: string; path: string };
|
||||||
@@ -23,48 +14,13 @@ type ChatPanelInnerProps = {
|
|||||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatPanelInner = ({
|
const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => {
|
||||||
sessionId,
|
const chat = useChat(undefined, undefined, {
|
||||||
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, {
|
|
||||||
replaceUrl: false,
|
replaceUrl: false,
|
||||||
projectScoped: scoped,
|
projectScoped: scoped,
|
||||||
onTurnComplete: handleTurnComplete,
|
onTurnComplete,
|
||||||
resumeSummary,
|
|
||||||
initialMessages,
|
|
||||||
...chatContext,
|
...chatContext,
|
||||||
});
|
});
|
||||||
chatSessionRef.current = chat.sessionId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveSession(chat.sessionId);
|
setActiveSession(chat.sessionId);
|
||||||
@@ -74,8 +30,6 @@ const ChatPanelInner = ({
|
|||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
className="h-full"
|
className="h-full"
|
||||||
chat={chat}
|
chat={chat}
|
||||||
sessionId={sessionId}
|
|
||||||
initialModel={model}
|
|
||||||
cwd={cwdParam}
|
cwd={cwdParam}
|
||||||
sandboxed={sandboxed}
|
sandboxed={sandboxed}
|
||||||
replaceUrl={false}
|
replaceUrl={false}
|
||||||
@@ -100,53 +54,7 @@ export const ChatPanelWrapper = () => {
|
|||||||
? { context: 'dashboard' as const, contextId: dashboardId }
|
? { 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);
|
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 [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
||||||
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
||||||
|
|
||||||
@@ -162,20 +70,8 @@ export const ChatPanelWrapper = () => {
|
|||||||
|
|
||||||
const cwdParam = scoped ? { root, path: cwd } : undefined;
|
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 (
|
return (
|
||||||
<ChatPanelInner
|
<ChatPanelInner
|
||||||
key={resumeSummary ? `resume-${resumeKey}` : (sessionId ?? 'new')}
|
|
||||||
sessionId={sessionId}
|
|
||||||
model={model}
|
|
||||||
resumeSummary={resumeSummary}
|
|
||||||
initialMessages={initialMessages}
|
|
||||||
savedId={savedId ?? undefined}
|
|
||||||
scoped={scoped}
|
scoped={scoped}
|
||||||
sandboxed={sandboxed}
|
sandboxed={sandboxed}
|
||||||
cwdParam={cwdParam}
|
cwdParam={cwdParam}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export { AttachButton } from './components/AttachButton';
|
|||||||
export { WebpageDialog } from './components/WebpageDialog';
|
export { WebpageDialog } from './components/WebpageDialog';
|
||||||
export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
|
export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
|
||||||
export { useChat, type UseChatType } from '../../hooks/useChat';
|
export { useChat, type UseChatType } from '../../hooks/useChat';
|
||||||
export { ChatList } from './ChatList';
|
|
||||||
export { useSlashCommands } from './useSlashCommands';
|
export { useSlashCommands } from './useSlashCommands';
|
||||||
export { useAttachments, type UseAttachmentsType } from './useAttachments';
|
export { useAttachments, type UseAttachmentsType } from './useAttachments';
|
||||||
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
|
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 { useLocation } from 'react-router';
|
||||||
import { Save, Loader2 } from 'lucide-react';
|
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useSavedSessions } from 'state/useSavedSessions';
|
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import { useChat, EmbeddableChat } from '../Chat';
|
import { useChat, EmbeddableChat } from '../Chat';
|
||||||
import type { ChatMessage } from '../Chat/types';
|
import type { ChatMessage } from '../Chat/types';
|
||||||
@@ -17,7 +15,6 @@ export type SelectedSession = {
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
const CHANNEL = 'chat:selected-session';
|
const CHANNEL = 'chat:selected-session';
|
||||||
const SAVED_ID_CHANNEL = 'chat:saved-id';
|
|
||||||
|
|
||||||
type ChatLocationState = {
|
type ChatLocationState = {
|
||||||
initialMessage?: string;
|
initialMessage?: string;
|
||||||
@@ -32,36 +29,11 @@ type DetailBarProps = {
|
|||||||
sessionTitle: string | undefined;
|
sessionTitle: string | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isGenerating: 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 (
|
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="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">
|
<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'}
|
{sessionTitle ?? 'New chat'}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,51 +55,23 @@ type NewChatProps = {
|
|||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
initialMessages?: ChatMessage[];
|
||||||
savedId?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: initialSavedId }: NewChatProps) {
|
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isSuperAdmin = user?.role === 'Super Admin';
|
const isSuperAdmin = user?.role === 'Super Admin';
|
||||||
const { saveSession, updateSessionMessages } = useSavedSessions();
|
|
||||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
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);
|
|
||||||
|
|
||||||
|
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
||||||
const onTurnComplete = useCallback(() => {
|
const onTurnComplete = useCallback(() => {
|
||||||
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
|
||||||
invalidateClaudeSessions();
|
invalidateClaudeSessions();
|
||||||
const id = savedIdRef.current;
|
}, [invalidateClaudeSessions]);
|
||||||
const sid = chatSessionRef.current;
|
|
||||||
if (id != null && sid) {
|
|
||||||
const count = resumedMessageCountRef.current;
|
|
||||||
updateSessionMessages(id, sid, count > 0 ? count : undefined).catch(() => {});
|
|
||||||
}
|
|
||||||
}, [updateSessionMessages, invalidateClaudeSessions]);
|
|
||||||
|
|
||||||
// context 'chat' tells the backend to run this session from the dedicated claude_sessions cwd, so
|
// 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).
|
// 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' });
|
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;
|
const sandboxed = !isSuperAdmin;
|
||||||
// Run the session in the pwd chosen in the Sessions panel; null → backend default (claude_sessions).
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<DetailBar
|
<DetailBar sessionTitle={undefined} isConnected={chat.isConnected} isGenerating={chat.isGenerating} />
|
||||||
sessionTitle={undefined}
|
|
||||||
isConnected={chat.isConnected}
|
|
||||||
isGenerating={chat.isGenerating}
|
|
||||||
sessionId={chat.sessionId}
|
|
||||||
isSaved={isSaved}
|
|
||||||
onSave={handleSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={chat}
|
chat={chat}
|
||||||
sessionId={undefined}
|
sessionId={undefined}
|
||||||
@@ -179,15 +115,12 @@ export const ChatDetailPanel = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedId = selected.id.startsWith('saved:') ? Number(selected.id.replace('saved:', '')) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NewChat
|
<NewChat
|
||||||
key={selected.id}
|
key={selected.id}
|
||||||
resumeSummary={selected.resumeSummary}
|
resumeSummary={selected.resumeSummary}
|
||||||
resumeSessionId={selected.resumeSessionId}
|
resumeSessionId={selected.resumeSessionId}
|
||||||
initialMessages={selected.initialMessages}
|
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 });
|
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
|
// Clean up RAF on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export {
|
|||||||
WebpageDialog,
|
WebpageDialog,
|
||||||
EmbeddableChat,
|
EmbeddableChat,
|
||||||
useChat,
|
useChat,
|
||||||
ChatList,
|
|
||||||
useSlashCommands,
|
useSlashCommands,
|
||||||
useAttachments,
|
useAttachments,
|
||||||
useAudioRecording,
|
useAudioRecording,
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ export { useRecentModels } from './useRecentModels';
|
|||||||
export { usePlans } from './usePlans';
|
export { usePlans } from './usePlans';
|
||||||
export { useLandingPage } from './useLandingPage';
|
export { useLandingPage } from './useLandingPage';
|
||||||
export { useServerSettings } from './useServerSettings';
|
export { useServerSettings } from './useServerSettings';
|
||||||
export { useSavedSessions } from './useSavedSessions';
|
|
||||||
export type { UseSavedSessionsType, SavedSessionEntry } from './useSavedSessions';
|
|
||||||
export { useUserApps } from './useUserApps';
|
export { useUserApps } from './useUserApps';
|
||||||
export type { AppManifest } from './useUserApps';
|
export type { AppManifest } from './useUserApps';
|
||||||
export { useServerEnvironment } from './useServerEnvironment';
|
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