From 7fe7260f51ab530950257391f32642ff73f2de84 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 22 Feb 2026 20:52:19 +0000 Subject: [PATCH] better chat ui --- .../Screens/Dashboard/ChatHistory/index.tsx | 2 +- src/servers/api/pi/storage.ts | 919 ++++++++++++------ src/servers/api/pi/types.ts | 89 ++ .../src/apps/ChatHistory/ChatDetailPanel.tsx | 26 +- .../src/apps/ChatHistory/SessionList.tsx | 2 +- .../officerdev/src/hooks/usePiChat.ts | 10 +- src/workspaces/state/src/useChatSessions.ts | 5 + 7 files changed, 731 insertions(+), 322 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 852f0912..3f5abe93 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -22,7 +22,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { useEffect(() => { if (isNew) { - setSelected({ id: 'new' }); + setSelected({ id: `new:${Date.now()}` }); return; } if (!sessionId) return; diff --git a/src/servers/api/pi/storage.ts b/src/servers/api/pi/storage.ts index 888aacbb..43900e63 100644 --- a/src/servers/api/pi/storage.ts +++ b/src/servers/api/pi/storage.ts @@ -1,353 +1,621 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import type { SessionMeta, Message, GroupMeta } from "./types"; +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'; -const PI_SESSIONS_DIR = ".pi-sessions"; -const GROUP_PREFIX = "@"; -const GROUP_META_FILE = ".group-meta.json"; +// ── Path helpers ─────────────────────────────────────────────────────── -function getSessionDir(cwd: string, sessionId: string, groupSlug?: string | null): string { - if (groupSlug) { - return path.join(cwd, PI_SESSIONS_DIR, `${GROUP_PREFIX}${groupSlug}`, sessionId); +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 path.join(cwd, PI_SESSIONS_DIR, sessionId); + return lines.join('\n') + '\n'; } -function getMetaPath(cwd: string, sessionId: string, groupSlug?: string | null): string { - return path.join(getSessionDir(cwd, sessionId, groupSlug), "meta.json"); +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 }; } -function getMessagesPath(cwd: string, sessionId: string, groupSlug?: string | null): string { - return path.join(getSessionDir(cwd, sessionId, groupSlug), "messages.json"); -} +// ── Index management ─────────────────────────────────────────────────── -function getGroupDir(cwd: string, groupSlug: string): string { - return path.join(cwd, PI_SESSIONS_DIR, `${GROUP_PREFIX}${groupSlug}`); -} - -function getGroupMetaPath(cwd: string, groupSlug: string): string { - return path.join(getGroupDir(cwd, groupSlug), GROUP_META_FILE); -} - -async function isGroupDirectory(dirPath: string): Promise { +async function loadIndex(baseCwd: string): Promise { try { - const groupMetaPath = path.join(dirPath, GROUP_META_FILE); - await fs.access(groupMetaPath); - return true; + const indexPath = getIndexPath(baseCwd); + const content = await fs.readFile(indexPath, 'utf-8'); + return JSON.parse(content) as SessionIndex; } catch { - return false; + return {}; } } +async function saveIndex(baseCwd: string, index: SessionIndex): Promise { + const officerDir = getOfficerDir(baseCwd); + await fs.mkdir(officerDir, { recursive: true }); + const indexPath = getIndexPath(baseCwd); + await fs.writeFile(indexPath, JSON.stringify(index, null, 2)); +} + +// ── Message ↔ JSONL conversion ───────────────────────────────────────── + +export function messagesToJnlEntries(messages: Message[], meta: SessionMeta): JnlEntry[] { + const entries: JnlEntry[] = []; + let prevId: string | undefined; + let i = 0; + + while (i < messages.length) { + const msg = messages[i]!; + + if (msg.role === 'user') { + const id = generateHexId(); + const entry: JnlMessageEntry = { + type: 'message', + id, + parentId: prevId, + timestamp: new Date(msg.timestamp).toISOString(), + message: { role: 'user', content: msg.text ?? '' }, + }; + entries.push(entry); + prevId = id; + i++; + } else if (msg.role === 'assistant') { + const content: Array = []; + if (msg.text) { + content.push({ type: 'text', text: msg.text }); + } + + // Collect following tool messages + const toolMessages: Message[] = []; + let j = i + 1; + while (j < messages.length && messages[j]!.role === 'tool') { + const toolMsg = messages[j]!; + content.push({ + type: 'tool_use', + id: toolMsg.toolCallId ?? generateHexId(), + name: toolMsg.toolName ?? 'unknown', + input: toolMsg.toolInput ?? {}, + }); + toolMessages.push(toolMsg); + j++; + } + + const assistId = generateHexId(); + const assistEntry: JnlMessageEntry = { + type: 'message', + id: assistId, + parentId: prevId, + timestamp: new Date(msg.timestamp).toISOString(), + message: { role: 'assistant', content }, + }; + entries.push(assistEntry); + prevId = assistId; + + // Emit toolResult entries for tools with output + for (const toolMsg of toolMessages) { + if (toolMsg.output !== undefined) { + const trId = generateHexId(); + const trEntry: JnlMessageEntry = { + type: 'message', + id: trId, + parentId: prevId, + timestamp: new Date(toolMsg.timestamp).toISOString(), + message: { + role: 'toolResult', + toolCallId: toolMsg.toolCallId ?? '', + toolName: toolMsg.toolName ?? 'unknown', + content: [{ type: 'text', text: toolMsg.output }], + isError: toolMsg.isError, + }, + }; + entries.push(trEntry); + prevId = trId; + } + } + + i = j; + } else if (msg.role === 'tool') { + // Standalone tool without preceding assistant (edge case) + const content: Array = [ + { + type: 'tool_use', + id: msg.toolCallId ?? generateHexId(), + name: msg.toolName ?? 'unknown', + input: msg.toolInput ?? {}, + }, + ]; + + const assistId = generateHexId(); + const assistEntry: JnlMessageEntry = { + type: 'message', + id: assistId, + parentId: prevId, + timestamp: new Date(msg.timestamp).toISOString(), + message: { role: 'assistant', content }, + }; + entries.push(assistEntry); + prevId = assistId; + + if (msg.output !== undefined) { + const trId = generateHexId(); + const trEntry: JnlMessageEntry = { + type: 'message', + id: trId, + parentId: prevId, + timestamp: new Date(msg.timestamp).toISOString(), + message: { + role: 'toolResult', + toolCallId: msg.toolCallId ?? '', + toolName: msg.toolName ?? 'unknown', + content: [{ type: 'text', text: msg.output }], + isError: msg.isError, + }, + }; + entries.push(trEntry); + prevId = trId; + } + + i++; + } else { + i++; + } + } + + // Append session_info entry + const infoId = generateHexId(); + const infoEntry: JnlSessionInfoEntry = { + type: 'session_info', + id: infoId, + parentId: prevId, + timestamp: new Date(meta.updatedAt).toISOString(), + name: meta.title, + officer: { + cost: meta.cost, + model: meta.model, + groupSlug: meta.groupSlug ?? null, + messageCount: meta.messageCount, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + }, + }; + 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') { + messages.push({ + id: entry.id, + timestamp: ts, + role: 'user', + text: msgEntry.message.content as string, + }); + } else if (msgEntry.message.role === 'assistant') { + const contentBlocks = msgEntry.message.content as Array; + let text = ''; + const toolCalls: JnlToolCall[] = []; + + for (const block of contentBlocks) { + if (block.type === 'text') { + text += (text ? '\n' : '') + block.text; + } else if (block.type === 'tool_use') { + toolCalls.push(block); + } + } + + if (text) { + messages.push({ + id: entry.id, + timestamp: ts, + role: 'assistant', + text, + }); + } + + // Create tool messages from tool_use blocks + for (const tc of toolCalls) { + messages.push({ + id: randomUUID(), + timestamp: ts, + role: 'tool', + toolCallId: tc.id, + toolName: tc.name, + toolInput: tc.input, + }); + } + } else if (msgEntry.message.role === 'toolResult') { + const trMsg = msgEntry.message as { + role: 'toolResult'; + toolCallId: string; + toolName: string; + content: Array; + isError?: boolean; + }; + + const outputText = trMsg.content.map((c) => c.text).join('\n'); + + // Find matching tool message and update it with output + for (let k = messages.length - 1; k >= 0; k--) { + const m = messages[k]!; + if (m.role === 'tool' && m.toolCallId === trMsg.toolCallId) { + m.output = outputText; + m.isError = trMsg.isError; + break; + } + } + } + } + + return { messages, sessionInfo }; +} + +// ── Helper: build SessionMeta from index entry ───────────────────────── + +function indexEntryToMeta(sessionId: string, entry: SessionIndexEntry): SessionMeta { + return { + id: sessionId, + title: entry.title, + model: entry.model, + cwd: entry.cwd, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + messageCount: entry.messageCount, + cost: entry.cost, + groupSlug: entry.groupSlug ?? null, + }; +} + +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, + }; +} + +// ── Session CRUD ─────────────────────────────────────────────────────── + export async function saveSession( - cwd: string, + baseCwd: string, sessionId: string, meta: SessionMeta, - messages: Message[] + messages: Message[], ): Promise { - const sessionDir = getSessionDir(cwd, sessionId, meta.groupSlug); - await fs.mkdir(sessionDir, { recursive: true }); + const index = await loadIndex(baseCwd); - const metaPath = getMetaPath(cwd, sessionId, meta.groupSlug); - const messagesPath = getMessagesPath(cwd, sessionId, meta.groupSlug); + // Reuse existing file path or create new one + const existing = index[sessionId]; + const relFile = existing?.file ?? buildRelativePath(meta.cwd, meta.createdAt, sessionId); - await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); - await fs.writeFile( - messagesPath, - JSON.stringify({ messages }, null, 2) - ); + 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( - cwd: string, + baseCwd: string, sessionId: string, - groupSlug?: string | null + _groupSlug?: string | null, ): Promise<{ meta: SessionMeta; messages: Message[] }> { - const metaPath = getMetaPath(cwd, sessionId, groupSlug); - const messagesPath = getMessagesPath(cwd, sessionId, groupSlug); + const index = await loadIndex(baseCwd); + const entry = index[sessionId]; - const metaContent = await fs.readFile(metaPath, "utf-8"); - const messagesContent = await fs.readFile(messagesPath, "utf-8"); + if (!entry) { + throw new Error(`Session not found: ${sessionId}`); + } - const meta: SessionMeta = JSON.parse(metaContent); - const { messages } = JSON.parse(messagesContent) as { - messages: Message[]; - }; + 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( - cwd: string, + baseCwd: string, sessionId: string, - groupSlug?: string | null + _groupSlug?: string | null, ): Promise { - try { - const sessionDir = getSessionDir(cwd, sessionId, groupSlug); - await fs.access(sessionDir); - return true; - } catch { - return false; - } + const index = await loadIndex(baseCwd); + return sessionId in index; } export async function updateSessionMeta( - cwd: string, + baseCwd: string, sessionId: string, updates: Partial, - groupSlug?: string | null + _groupSlug?: string | null, ): Promise { - const { meta, messages } = await loadSession(cwd, sessionId, groupSlug); - + const { meta, messages } = await loadSession(baseCwd, sessionId); + const updatedMeta: SessionMeta = { ...meta, ...updates, updatedAt: Date.now(), }; - await saveSession(cwd, sessionId, updatedMeta, messages); + await saveSession(baseCwd, sessionId, updatedMeta, messages); return updatedMeta; } export async function deleteSession( - cwd: string, + baseCwd: string, sessionId: string, - groupSlug?: string | null + _groupSlug?: string | null, ): Promise { - const sessionDir = getSessionDir(cwd, sessionId, groupSlug); - await fs.rm(sessionDir, { recursive: true, force: true }); -} + const index = await loadIndex(baseCwd); + const entry = index[sessionId]; -export async function listUserSessions( - baseCwd: string -): Promise { - const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR); + if (entry) { + const absFile = path.join(getSessionsDir(baseCwd), entry.file); + await fs.rm(absFile, { force: true }); - try { - await fs.access(sessionsDir); - } catch { - return []; - } - - const entries = await fs.readdir(sessionsDir); - const sessions: SessionMeta[] = []; - - for (const entry of entries) { - const entryPath = path.join(sessionsDir, entry); - const stats = await fs.stat(entryPath); - - if (!stats.isDirectory()) continue; - - // Check if it's a group (starts with @) - if (entry.startsWith(GROUP_PREFIX)) { - const groupSlug = entry.slice(GROUP_PREFIX.length); - const groupSessions = await fs.readdir(entryPath); - - for (const sessionId of groupSessions) { - if (sessionId === GROUP_META_FILE) continue; - - try { - const metaPath = getMetaPath(baseCwd, sessionId, groupSlug); - const metaContent = await fs.readFile(metaPath, "utf-8"); - const meta: SessionMeta = JSON.parse(metaContent); - sessions.push(meta); - } catch { - continue; - } - } - } else { - // Regular ungrouped session - try { - const metaPath = getMetaPath(baseCwd, entry); - const metaContent = await fs.readFile(metaPath, "utf-8"); - const meta: SessionMeta = JSON.parse(metaContent); - sessions.push(meta); - } catch { - continue; + // 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); +} + +export async function listUserSessions(baseCwd: string): Promise { + const index = await loadIndex(baseCwd); + + const sessions: SessionMeta[] = Object.entries(index).map(([id, entry]) => indexEntryToMeta(id, entry)); + sessions.sort((a, b) => b.updatedAt - a.updatedAt); return sessions; } export async function searchSessions( baseCwd: string, - query: string -): Promise< - Array -> { - const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR); - - try { - await fs.access(sessionsDir); - } catch { - return []; - } - - const entries = await fs.readdir(sessionsDir); - const results: Array< - SessionMeta & { preview?: string; relevance?: number } - > = []; + query: string, +): Promise> { + const index = await loadIndex(baseCwd); const lowerQuery = query.toLowerCase(); + const results: Array = []; - for (const entry of entries) { - const entryPath = path.join(sessionsDir, entry); - const stats = await fs.stat(entryPath); + for (const [sessionId, entry] of Object.entries(index)) { + let relevance = 0; + let preview = ''; - if (!stats.isDirectory()) continue; + // Check title (from index — fast) + if (entry.title.toLowerCase().includes(lowerQuery)) { + relevance += 1.0; + preview = entry.title; + } - // Check if it's a group - if (entry.startsWith(GROUP_PREFIX)) { - const groupSlug = entry.slice(GROUP_PREFIX.length); - - // Search in group metadata + // Check group membership — boost relevance if group matches + if (entry.groupSlug) { try { - const groupMetaPath = getGroupMetaPath(baseCwd, groupSlug); - const groupMetaContent = await fs.readFile(groupMetaPath, "utf-8"); - const groupMeta: GroupMeta = JSON.parse(groupMetaContent); - - let groupRelevance = 0; - if (groupMeta.name.toLowerCase().includes(lowerQuery)) { - groupRelevance += 2.0; + const group = await loadGroup(baseCwd, entry.groupSlug); + if (group.name.toLowerCase().includes(lowerQuery)) { + relevance += 2.0; } - if (groupMeta.description?.toLowerCase().includes(lowerQuery)) { - groupRelevance += 1.5; - } - - // Search sessions in group - const groupSessions = await fs.readdir(entryPath); - for (const sessionId of groupSessions) { - if (sessionId === GROUP_META_FILE) continue; - - try { - const { meta, messages } = await loadSession(baseCwd, sessionId, groupSlug); - let relevance = groupRelevance; - let preview = ""; - - if (meta.title.toLowerCase().includes(lowerQuery)) { - relevance += 1.0; - preview = meta.title; - } - - for (const msg of messages) { - if (msg.text && msg.text.toLowerCase().includes(lowerQuery)) { - relevance += 0.5; - if (!preview) { - const index = msg.text.toLowerCase().indexOf(lowerQuery); - const start = Math.max(0, index - 50); - const end = Math.min(msg.text.length, index + query.length + 50); - preview = "..." + msg.text.slice(start, end) + "..."; - } - } - } - - if (relevance > 0) { - results.push({ ...meta, preview, relevance }); - } - } catch { - continue; - } + if (group.description?.toLowerCase().includes(lowerQuery)) { + relevance += 1.5; } } catch { - continue; + // Group file missing — skip boost } - } else { - // Regular ungrouped session - try { - const { meta, messages } = await loadSession(baseCwd, entry); - let relevance = 0; - let preview = ""; + } - if (meta.title.toLowerCase().includes(lowerQuery)) { - relevance += 1.0; - preview = meta.title; - } + // 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 index = msg.text.toLowerCase().indexOf(lowerQuery); - const start = Math.max(0, index - 50); - const end = Math.min(msg.text.length, index + query.length + 50); - preview = "..." + msg.text.slice(start, end) + "..."; + 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) + '...'; } } } - - if (relevance > 0) { - results.push({ ...meta, preview, relevance }); - } } catch { - continue; + // 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)); + results.sort((a, b) => (b.relevance ?? 0) - (a.relevance ?? 0)); return results; } -/** - * Group Management Functions - */ +// ── Group management ─────────────────────────────────────────────────── -export async function saveGroup( - cwd: string, - groupMeta: GroupMeta -): Promise { - const groupDir = getGroupDir(cwd, groupMeta.slug); - await fs.mkdir(groupDir, { recursive: true }); +export async function saveGroup(baseCwd: string, groupMeta: GroupMeta): Promise { + const groupsDir = getGroupsDir(baseCwd); + await fs.mkdir(groupsDir, { recursive: true }); - const groupMetaPath = getGroupMetaPath(cwd, groupMeta.slug); - await fs.writeFile(groupMetaPath, JSON.stringify(groupMeta, null, 2)); + const groupPath = getGroupPath(baseCwd, groupMeta.slug); + await fs.writeFile(groupPath, JSON.stringify(groupMeta, null, 2)); } -export async function loadGroup( - cwd: string, - groupSlug: string -): Promise { - const groupMetaPath = getGroupMetaPath(cwd, groupSlug); - const groupMetaContent = await fs.readFile(groupMetaPath, "utf-8"); - return JSON.parse(groupMetaContent) as GroupMeta; +export async function loadGroup(baseCwd: string, groupSlug: string): Promise { + const groupPath = getGroupPath(baseCwd, groupSlug); + const content = await fs.readFile(groupPath, 'utf-8'); + return JSON.parse(content) as GroupMeta; } -export async function groupExists( - cwd: string, - groupSlug: string -): Promise { +export async function groupExists(baseCwd: string, groupSlug: string): Promise { try { - const groupMetaPath = getGroupMetaPath(cwd, groupSlug); - await fs.access(groupMetaPath); + const groupPath = getGroupPath(baseCwd, groupSlug); + await fs.access(groupPath); return true; } catch { return false; } } -export async function listGroups( - baseCwd: string -): Promise { - const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR); +export async function listGroups(baseCwd: string): Promise { + const groupsDir = getGroupsDir(baseCwd); try { - await fs.access(sessionsDir); + await fs.access(groupsDir); } catch { return []; } - const entries = await fs.readdir(sessionsDir); + const files = await fs.readdir(groupsDir); const groups: GroupMeta[] = []; - for (const entry of entries) { - if (!entry.startsWith(GROUP_PREFIX)) continue; + for (const file of files) { + if (!file.endsWith('.json')) continue; - const groupSlug = entry.slice(GROUP_PREFIX.length); try { - const groupMeta = await loadGroup(baseCwd, groupSlug); - groups.push(groupMeta); + const filePath = path.join(groupsDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + groups.push(JSON.parse(content) as GroupMeta); } catch { continue; } @@ -358,88 +626,66 @@ export async function listGroups( } export async function updateGroupMeta( - cwd: string, + baseCwd: string, groupSlug: string, - updates: Partial + updates: Partial, ): Promise { - const groupMeta = await loadGroup(cwd, groupSlug); + const groupMeta = await loadGroup(baseCwd, groupSlug); - const updatedGroupMeta: GroupMeta = { + const updated: GroupMeta = { ...groupMeta, ...updates, - slug: groupMeta.slug, // Prevent slug changes + slug: groupMeta.slug, updatedAt: Date.now(), }; - await saveGroup(cwd, updatedGroupMeta); - return updatedGroupMeta; + await saveGroup(baseCwd, updated); + return updated; } -export async function deleteGroup( - cwd: string, - groupSlug: string -): Promise { - const groupDir = getGroupDir(cwd, groupSlug); +export async function deleteGroup(baseCwd: string, groupSlug: string): Promise { + const index = await loadIndex(baseCwd); - // Move all sessions in the group to root level - try { - const entries = await fs.readdir(groupDir); - const sessionsDir = path.join(cwd, PI_SESSIONS_DIR); - - for (const entry of entries) { - if (entry === GROUP_META_FILE) continue; - - const sessionDir = path.join(groupDir, entry); - const destDir = path.join(sessionsDir, entry); - - // Update session meta to remove groupSlug - try { - const metaPath = path.join(sessionDir, "meta.json"); - const metaContent = await fs.readFile(metaPath, "utf-8"); - const meta: SessionMeta = JSON.parse(metaContent); - meta.groupSlug = null; - await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); - } catch { - // Continue even if meta update fails - } - - // Move session directory - await fs.rename(sessionDir, destDir); + // 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; } - } catch (err) { - // Continue to delete group even if moving fails + } + if (changed) { + await saveIndex(baseCwd, index); } - // Delete the group directory - await fs.rm(groupDir, { recursive: true, force: true }); + // Delete group file + const groupPath = getGroupPath(baseCwd, groupSlug); + await fs.rm(groupPath, { force: true }); } export async function moveSession( - cwd: string, + baseCwd: string, sessionId: string, fromGroupSlug: string | null, - toGroupSlug: string | null + toGroupSlug: string | null, ): Promise { - // Load the session - const { meta, messages } = await loadSession(cwd, sessionId, fromGroupSlug); + const index = await loadIndex(baseCwd); + const entry = index[sessionId]; + if (!entry) { + throw new Error(`Session not found: ${sessionId}`); + } - // Update groupSlug - meta.groupSlug = toGroupSlug; - meta.updatedAt = Date.now(); + entry.groupSlug = toGroupSlug; + entry.updatedAt = Date.now(); + await saveIndex(baseCwd, index); - // Save to new location - await saveSession(cwd, sessionId, meta, messages); - - // Delete from old location - await deleteSession(cwd, sessionId, fromGroupSlug); - - // Update session count in groups + // Update group session counts if (fromGroupSlug) { try { - const fromGroup = await loadGroup(cwd, fromGroupSlug); + const fromGroup = await loadGroup(baseCwd, fromGroupSlug); fromGroup.sessionCount = Math.max(0, fromGroup.sessionCount - 1); fromGroup.updatedAt = Date.now(); - await saveGroup(cwd, fromGroup); + await saveGroup(baseCwd, fromGroup); } catch { // Group might not exist } @@ -447,14 +693,93 @@ export async function moveSession( if (toGroupSlug) { try { - const toGroup = await loadGroup(cwd, toGroupSlug); + const toGroup = await loadGroup(baseCwd, toGroupSlug); toGroup.sessionCount += 1; toGroup.updatedAt = Date.now(); - await saveGroup(cwd, toGroup); + await saveGroup(baseCwd, toGroup); } catch { // Group might not exist } } - return meta; + return indexEntryToMeta(sessionId, entry); +} + +// ── Index recovery ───────────────────────────────────────────────────── + +export async function rebuildIndex(baseCwd: string): Promise { + const sessionsDir = getSessionsDir(baseCwd); + const index: SessionIndex = {}; + + try { + await fs.access(sessionsDir); + } catch { + return index; + } + + const cwdDirs = await fs.readdir(sessionsDir); + + for (const dir of cwdDirs) { + if (dir === OFFICER_DIR) continue; + + const dirPath = path.join(sessionsDir, dir); + const stat = await fs.stat(dirPath); + if (!stat.isDirectory()) continue; + + const files = await fs.readdir(dirPath); + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue; + + try { + const filePath = path.join(dirPath, file); + const raw = await fs.readFile(filePath, 'utf-8'); + const { header, entries } = parseJnlFile(raw); + + const relFile = path.join(dir, file); + let title = ''; + let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; + let model = ''; + let messageCount = 0; + let createdAt = new Date(header.timestamp).getTime(); + let updatedAt = createdAt; + let groupSlug: string | null = null; + + // 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; + } + } + } + + index[header.id] = { + file: relFile, + title, + model, + cwd: header.cwd, + createdAt, + updatedAt, + messageCount, + cost, + groupSlug, + }; + } catch { + continue; + } + } + } + + await saveIndex(baseCwd, index); + return index; } diff --git a/src/servers/api/pi/types.ts b/src/servers/api/pi/types.ts index 831c22f7..05a41c40 100644 --- a/src/servers/api/pi/types.ts +++ b/src/servers/api/pi/types.ts @@ -152,3 +152,92 @@ export type ModelInfo = { reasoning?: boolean; images?: boolean; }; + +// ── Pi-native JSONL types ────────────────────────────────────────────── + +export type JnlSessionHeader = { + type: 'session'; + version: 3; + id: string; + timestamp: string; + cwd: string; +}; + +export type JnlEntryBase = { + type: string; + id: string; + parentId?: string; + timestamp: string; +}; + +export type JnlTextContent = { + type: 'text'; + text: string; +}; + +export type JnlToolCall = { + type: 'tool_use'; + id: string; + name: string; + input: Record; +}; + +export type JnlUserMessage = JnlEntryBase & { + type: 'message'; + message: { + role: 'user'; + content: string; + }; +}; + +export type JnlAssistantMessage = JnlEntryBase & { + type: 'message'; + message: { + role: 'assistant'; + content: Array; + }; +}; + +export type JnlToolResultMessage = JnlEntryBase & { + type: 'message'; + message: { + role: 'toolResult'; + toolCallId: string; + toolName: string; + content: Array; + isError?: boolean; + }; +}; + +export type JnlMessageEntry = JnlUserMessage | JnlAssistantMessage | JnlToolResultMessage; + +export type JnlSessionInfoEntry = JnlEntryBase & { + type: 'session_info'; + name: string; + officer?: { + cost: MessageCost; + model: string; + groupSlug?: string | null; + messageCount: number; + createdAt: number; + updatedAt: number; + }; +}; + +export type JnlEntry = JnlMessageEntry | JnlSessionInfoEntry; + +// ── Session Index ────────────────────────────────────────────────────── + +export type SessionIndexEntry = { + file: string; + title: string; + model: string; + cwd: string; + createdAt: number; + updatedAt: number; + messageCount: number; + cost: MessageCost; + groupSlug?: string | null; +}; + +export type SessionIndex = Record; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 2cdf7081..534928e0 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useLocation } from 'react-router'; import { Trash2 } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; @@ -83,6 +82,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) { }} /> (CHANNEL, null); - const chat = usePiChat(); - - useEffect(() => { - if (chat.sessionId) { - setSelected({ id: chat.sessionId, model: chat.model }); - } - }, [chat.sessionId]); + const chat = usePiChat(undefined, locationState?.model); const initialMessage = locationState?.initialMessage ? { @@ -122,6 +115,7 @@ function NewChat() { onDelete={undefined} /> (CHANNEL, null); - - if (selected && selected.id !== 'new') { - return ; - } - - return ; -} - export const ChatDetailPanel = () => { const [selected] = usePanelChannel(CHANNEL, null); @@ -154,8 +138,8 @@ export const ChatDetailPanel = () => { ); } - if (selected.id === 'new') { - return ; + if (selected.id.startsWith('new')) { + return ; } return ; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 6a540c30..e80f47dd 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -132,7 +132,7 @@ export const SessionList = () => {