From e3d6ab794697130dc87516a7470979a853a8bf4f Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:36:18 +0900 Subject: [PATCH 1/5] feat: navigate archived session branches and continue selected entries Co-authored-by: OpenAI Codex --- README.md | 3 + docs/adr/0013-session-branch-selection.md | 6 + docs/adr/README.md | 1 + src/main/index.ts | 101 ++++++-- src/main/session-tree.ts | 212 +++++++++++++++ src/main/sessions.ts | 60 +++-- src/main/workbench-ipc.ts | 9 +- src/main/workbench.ts | 43 +--- src/preload/index.ts | 13 +- src/renderer/App.tsx | 102 +++++--- src/renderer/api.ts | 9 +- src/renderer/components/SessionTree.tsx | 246 ++++++++++++++++++ src/renderer/useWorkbench.ts | 70 ++++- src/renderer/workbench.css | 67 +++++ tests/e2e/app.test.ts | 19 +- tests/e2e/session-tree.test.ts | 297 ++++++++++++++++++++++ tests/fixtures/long-session.ts | 1 + tests/fixtures/pi-rpc.cjs | 48 +++- tests/integration/SessionTree.test.tsx | 125 +++++++++ tests/unit/session-tree.test.ts | 173 +++++++++++++ tests/unit/workbench.test.ts | 2 +- 21 files changed, 1462 insertions(+), 145 deletions(-) create mode 100644 docs/adr/0013-session-branch-selection.md create mode 100644 src/main/session-tree.ts create mode 100644 src/renderer/components/SessionTree.tsx create mode 100644 tests/e2e/session-tree.test.ts create mode 100644 tests/integration/SessionTree.test.tsx create mode 100644 tests/unit/session-tree.test.ts diff --git a/README.md b/README.md index 883cb0a..bf87fcc 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,14 @@ These are captures of real Electron interactions with a temporary Git workspace - Add local workspaces and discover existing Pi sessions. - Start, stop, steer, resume, and fork sessions; preview history before launching Pi. +- Browse linked session archives and branch entries; preview and continue the selected path. - Render streamed Markdown, thinking, and expandable tool output. - Browse files, review diffs, stage changes, and commit. - Discover workspace and global skills, switch models, and save model and reasoning defaults per workspace. - Use quick switch for workspaces, sessions, files, and commands. +Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so historical forks extract a native branch archive; current-tip forks retain Pi's `clone` command. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits. + Active sessions and history use a virtualized transcript. A local development benchmark with 4,500 mixed entries opened in about 1.4 seconds, down from 8.3 seconds, and mounted fewer than 100 transcript DOM nodes instead of 39,000. Timings vary by machine and load; see [ADR 0011](docs/adr/0011-virtualized-transcript.md) for the method and limits. Session trees, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). diff --git a/docs/adr/0013-session-branch-selection.md b/docs/adr/0013-session-branch-selection.md new file mode 100644 index 0000000..93aa7fb --- /dev/null +++ b/docs/adr/0013-session-branch-selection.md @@ -0,0 +1,6 @@ +# ADR 0013 — Explicit archive branch selection over Pi RPC + +- **Status**: Proposed +- **Context**: A Pi v3 JSONL archive is an append-only tree. Entries of every type have `id` and `parentId`; the session header is not a tree node. Reloading an archive selects the last non-header entry. Fork archives link to their source with `parentSession`. Inspection of installed Pi 1.5.0 (`docs/rpc-commands.md`, `dist/modes/rpc/rpc-mode.js`, `dist/core/session-manager.js`) confirmed that RPC exposes `get_tree`, `fork`, and `clone`, but does not expose the SDK's `navigateTree`. `fork` branches before a previous user message, while `clone` duplicates the currently active path. Neither selects an arbitrary archived entry. +- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. Current-tip forks retain the supported `clone` RPC, including its cancellation handling. Historical forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Historical extraction does not invoke Pi extension fork hooks. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives. +- **Consequences**: Historical continuation creates a separate session rather than moving the original session's in-memory leaf. Old archive versions remain previewable where their lineage is readable; continuation requires intact v3 input and tells the user to migrate older files in Pi. Missing parents, cycles, duplicate IDs, malformed records and stale selections fail closed for affected operations. Archive browsing has a 16 MiB bound. Preview shows stored conversation history along the selected parent chain, including pre-compaction messages; Pi constructs its own compacted runtime context when it opens the resulting archive. Cross-folder lineage migration and multi-root workflows remain follow-ups. A future documented navigation RPC can replace extraction after its exact-entry semantics are verified. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3443ad7..c96b1bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Each ADR follows this shape: | [0010](0010-distribution-channels.md) | Ship desktop installers and public package channels | Accepted | | [0011](0011-virtualized-transcript.md) | Virtualize the session transcript | Accepted | | [0012](0012-release-please-calver.md) | Release Please with a CalVer adapter | Accepted | +| [0013](0013-session-branch-selection.md) | Explicit archive branch selection over Pi RPC | Proposed | diff --git a/src/main/index.ts b/src/main/index.ts index 8dc8f40..1bd0ac8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import { forkArchive, loadArchive, selectedPath } from './session-tree'; import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import fs from 'fs'; import path from 'path'; @@ -104,19 +105,51 @@ ipcMain.handle('pidex:sessions', (_e, dirName: string) => { ipcMain.handle( 'pidex:session/open', - async (_e, req: { cwd: string; sessionFile?: string; fork?: boolean }) => { + async ( + _e, + req: { + cwd: string; + sessionFile?: string; + entryId?: string | null; + fork?: boolean; + }, + ) => { const cwd = typeof req?.cwd === 'string' ? req.cwd : ''; - const sessionFile = + let sessionFile = typeof req?.sessionFile === 'string' ? req.sessionFile : undefined; if (!cwd || !fs.existsSync(cwd)) { return { error: 'workspace path does not exist' }; } + let expectedEntryId: string | null | undefined; + let cloneCurrent = false; if (sessionFile) { try { - await sessionPath(sessionFile); - } catch { + sessionFile = await sessionPath(sessionFile); + const archive = await loadArchive(sessionFile); + if (!archive.header || archive.header.version !== 3 || archive.damaged) + throw new Error( + 'Continuing requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.', + ); + if ( + req.entryId !== undefined && + req.entryId !== null && + typeof req.entryId !== 'string' + ) + throw new Error('Invalid selected entry.'); + const selected = selectedPath(archive, req.entryId).at(-1)?.id; + expectedEntryId = selected ?? null; + cloneCurrent = Boolean( + req.fork && selected === archive.entries.at(-1)?.id, + ); + if (req.fork && !selected) + throw new Error('This session has no entry to fork.'); + if (selected && selected !== archive.entries.at(-1)?.id) { + sessionFile = await forkArchive(sessionFile, selected); + } + } catch (error) { return { - error: 'session file outside pi sessions root or unavailable', + error: + error instanceof Error ? error.message : 'Session unavailable.', }; } } @@ -135,18 +168,6 @@ ipcMain.handle( sendToRenderer('pidex:session:exit', { sessionId }); sessions.set(id, session); - if (req.fork && sessionFile) { - const cloned = await session.command({ type: 'clone' }); - if ( - !cloned.success || - (cloned.data as { cancelled?: boolean } | undefined)?.cancelled - ) { - sessions.delete(id); - session.dispose(); - return { error: cloned.error ?? 'Session fork was cancelled.' }; - } - } - if (!sessionFile) { const defaults = await sessionDefaults(cwd); if (defaults.model && defaults.provider) { @@ -174,15 +195,57 @@ ipcMain.handle( } } + if (expectedEntryId !== undefined) { + const position = await session.command({ + type: 'get_entries', + ...(expectedEntryId ? { since: expectedEntryId } : {}), + }); + const data = position.data as + { leafId?: string | null; entries?: { type?: string }[] } | undefined; + // Pi may initialize model/thinking metadata in an otherwise empty session. + const matches = + expectedEntryId === null + ? Array.isArray(data?.entries) && + !data.entries.some((e) => e.type === 'message') + : data?.leafId === expectedEntryId; + if (!position.success || !matches) { + sessions.delete(id); + session.dispose(); + return { + error: + position.error ?? + 'The runtime branch moved since selection. Refresh history and select the branch again.', + }; + } + } + + if (cloneCurrent) { + const cloned = await session.command({ type: 'clone' }); + if ( + !cloned.success || + (cloned.data as { cancelled?: boolean } | undefined)?.cancelled + ) { + sessions.delete(id); + session.dispose(); + return { error: cloned.error ?? 'Session fork was cancelled.' }; + } + } + const [state, messages] = await Promise.all([ session.getState(), session.getMessages(), ]); const launchError = session.launchError(); - if (launchError) { + if (launchError || !state.success || !messages.success) { sessions.delete(id); session.dispose(); - return { error: launchError }; + return { + error: + launchError ?? + state.error ?? + messages.error ?? + 'Could not read Pi session state.', + }; } return { sessionId: id, diff --git a/src/main/session-tree.ts b/src/main/session-tree.ts new file mode 100644 index 0000000..a1bb289 --- /dev/null +++ b/src/main/session-tree.ts @@ -0,0 +1,212 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { sessionPath } from './workbench'; +import { SESSIONS_ROOT } from './sessions'; + +type ArchiveEntry = Record & { + id: string; + type: string; + parentId?: unknown; +}; +export type EntryNode = { + id: string; + parentId: string | null; + depth: number; + label: string; + tip: boolean; + valid: boolean; +}; +export type SessionTree = { + nodes: EntryNode[]; + leafId: string | null; + warning?: string; + canContinue: boolean; +}; +export type Archive = { + header?: ArchiveEntry; + entries: ArchiveEntry[]; + damaged: boolean; +}; + +export function parseArchive(text: string): Archive { + const entries: ArchiveEntry[] = []; + let header: ArchiveEntry | undefined; + let damaged = false; + const ids = new Set(); + for (const line of text.split('\n')) { + if (!line.trim()) continue; + try { + const value = JSON.parse(line); + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + typeof value.id !== 'string' || + !value.id || + typeof value.type !== 'string' + ) { + damaged = true; + continue; + } + if (value.type === 'session') { + if (header) damaged = true; + header ??= value; + continue; + } + if (ids.has(value.id)) { + damaged = true; + continue; + } + ids.add(value.id); + entries.push(value); + } catch { + damaged = true; + } + } + return { header, entries, damaged }; +} + +export function selectedPath( + archive: Archive, + entryId?: string | null, +): ArchiveEntry[] { + if (entryId === null) { + if (archive.entries.length) + throw new Error( + 'This empty session has advanced. Select its branch again.', + ); + return []; + } + const byId = new Map(archive.entries.map((e) => [e.id, e])); + let current = + entryId === undefined ? archive.entries.at(-1) : byId.get(entryId); + if (entryId !== undefined && !current) + throw new Error('Selected entry no longer exists. Select it again.'); + const result: ArchiveEntry[] = []; + const seen = new Set(); + while (current) { + if (seen.has(current.id)) + throw new Error('This branch has cyclic lineage.'); + seen.add(current.id); + result.push(current); + if (current.parentId === null) break; + if (typeof current.parentId !== 'string' || !byId.has(current.parentId)) + throw new Error('This branch has missing lineage.'); + current = byId.get(current.parentId); + } + return result.reverse(); +} + +function label(entry: ArchiveEntry): string { + const message = entry.message as + { role?: string; content?: unknown } | undefined; + const content = message?.content; + const text = + typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .filter((p) => p?.type === 'text') + .map((p) => p.text) + .join(' ') + : ''; + return `${message?.role ?? entry.type}: ${String( + text || entry.summary || entry.name || entry.modelId || '', + ) + .replace(/\s+/g, ' ') + .slice(0, 80)}`.replace(/: $/, ''); +} + +export function archiveTree(archive: Archive): SessionTree { + const byId = new Map(archive.entries.map((e) => [e.id, e])); + const children = new Map(); + const roots: ArchiveEntry[] = []; + for (const entry of archive.entries) { + if (typeof entry.parentId === 'string' && byId.has(entry.parentId)) { + const group = children.get(entry.parentId) ?? []; + group.push(entry); + children.set(entry.parentId, group); + } else roots.push(entry); + } + const nodes: EntryNode[] = []; + const seen = new Set(); + const walk = (root: ArchiveEntry, valid: boolean) => { + const stack = [{ entry: root, depth: 0 }]; + while (stack.length) { + const { entry, depth } = stack.pop()!; + if (seen.has(entry.id)) continue; + seen.add(entry.id); + nodes.push({ + id: entry.id, + parentId: typeof entry.parentId === 'string' ? entry.parentId : null, + depth, + label: label(entry), + tip: !children.has(entry.id), + valid, + }); + for (const child of [...(children.get(entry.id) ?? [])].reverse()) + stack.push({ entry: child, depth: depth + 1 }); + } + }; + for (const root of roots) walk(root, root.parentId === null); + for (const entry of archive.entries) + if (!seen.has(entry.id)) walk(entry, false); + return { + nodes, + canContinue: archive.header?.version === 3 && !archive.damaged, + leafId: archive.entries.at(-1)?.id ?? null, + warning: + archive.damaged || nodes.some((n) => !n.valid) + ? 'Archive has incomplete or invalid lineage. Invalid branches cannot be continued; damaged archives cannot be continued.' + : archive.header?.version !== 3 + ? 'Continuation requires Pi v3. Open this archive in Pi to migrate it first.' + : undefined, + }; +} + +export async function loadArchive( + file: string, + root = SESSIONS_ROOT, +): Promise { + const target = await sessionPath(file, root); + if ((await fs.stat(target)).size > 16 * 1024 * 1024) + throw new Error('This session is too large to browse.'); + return parseArchive(await fs.readFile(target, 'utf8')); +} + +export async function readSessionTree(file: string): Promise { + return archiveTree(await loadArchive(file)); +} + +/** Pi RPC cannot navigate an arbitrary entry. Extract a native v3 branch archive, + * retaining entry IDs and every record type (including compaction/tool metadata). + * Never rewrite the source. Opening this file uses Pi's supported --session flag. */ +export async function forkArchive( + file: string, + entryId: string, + root = SESSIONS_ROOT, +): Promise { + const source = await sessionPath(file, root); + const archive = await loadArchive(source, root); + if (!archive.header || archive.header.version !== 3 || archive.damaged) + throw new Error( + 'Forking requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.', + ); + const entries = selectedPath(archive, entryId); + if (!entries.length) throw new Error('This session has no entry to fork.'); + const id = randomUUID(); + const timestamp = new Date().toISOString(); + const target = path.join( + path.dirname(source), + `${timestamp.replace(/[:.]/g, '-')}_${id}.jsonl`, + ); + await fs.writeFile( + target, + [{ ...archive.header, id, timestamp, parentSession: source }, ...entries] + .map((e) => JSON.stringify(e)) + .join('\n') + '\n', + { flag: 'wx' }, + ); + return target; +} diff --git a/src/main/sessions.ts b/src/main/sessions.ts index e5e2257..200686a 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -24,6 +24,7 @@ type SessionInfo = { model: string; timestamp: string; messageCount: number; + parentSession?: string; }; function readLines(file: string, maxBytes = 256 * 1024): string[] { @@ -42,13 +43,14 @@ function readLines(file: string, maxBytes = 256 * 1024): string[] { function textOf(content: unknown): string { if (!Array.isArray(content)) return ''; return content - .filter(p => p && p.type === 'text' && typeof p.text === 'string') - .map(p => p.text as string) + .filter((p) => p && p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text as string) .join('\n'); } function parseSession(file: string): SessionInfo | null { try { + file = fs.realpathSync(file); const lines = readLines(file); let id = ''; let cwd = ''; @@ -56,6 +58,7 @@ function parseSession(file: string): SessionInfo | null { let title = ''; let model = ''; let messageCount = 0; + let parentSession: string | undefined; for (const line of lines) { if (!line) continue; let entry: Record; @@ -66,6 +69,17 @@ function parseSession(file: string): SessionInfo | null { } if (entry.type === 'session') { id = String(entry.id ?? ''); + parentSession = + typeof entry.parentSession === 'string' + ? entry.parentSession + : undefined; + if (parentSession) { + try { + parentSession = fs.realpathSync(parentSession); + } catch { + /* A missing source remains visible. */ + } + } cwd = String(entry.cwd ?? ''); timestamp = String(entry.timestamp ?? ''); continue; @@ -76,21 +90,34 @@ function parseSession(file: string): SessionInfo | null { } if (entry.type === 'message') { messageCount += 1; - const message = entry.message as { role?: string; content?: unknown } | undefined; + const message = entry.message as + { role?: string; content?: unknown } | undefined; if (!title && message?.role === 'user') { - title = textOf(message.content).replace(/\s+/g, ' ').trim().slice(0, 90); + title = textOf(message.content) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 90); } } } if (!id) return null; - return { id, file, cwd, title: title || '(empty session)', model, timestamp, messageCount }; + return { + id, + file, + cwd, + title: title || '(empty session)', + model, + timestamp, + messageCount, + parentSession, + }; } catch { return null; } } function gitBranch(cwd: string): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { execFile( 'git', ['-C', cwd, 'branch', '--show-current'], @@ -102,27 +129,30 @@ function gitBranch(cwd: string): Promise { }); } -async function listWorkspaces(extraCwds: string[] = []): Promise { +async function listWorkspaces( + extraCwds: string[] = [], +): Promise { const byDir = new Map(); if (fs.existsSync(SESSIONS_ROOT)) { const dirs = fs .readdirSync(SESSIONS_ROOT, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); + .filter((d) => d.isDirectory()) + .map((d) => d.name); for (const dirName of dirs) { const dirPath = path.join(SESSIONS_ROOT, dirName); const files = fs .readdirSync(dirPath) - .filter(f => f.endsWith('.jsonl')) - .map(f => path.join(dirPath, f)); + .filter((f) => f.endsWith('.jsonl')) + .map((f) => path.join(dirPath, f)); if (files.length === 0) continue; let cwd = ''; let lastActive = ''; for (const file of files) { const stat = fs.statSync(file); - if (stat.mtime.toISOString() > lastActive) lastActive = stat.mtime.toISOString(); + if (stat.mtime.toISOString() > lastActive) + lastActive = stat.mtime.toISOString(); if (!cwd) { for (const line of readLines(file, 64 * 1024)) { if (!line) continue; @@ -151,7 +181,7 @@ async function listWorkspaces(extraCwds: string[] = []): Promise w.cwd)); + const knownCwds = new Set([...byDir.values()].map((w) => w.cwd)); for (const cwd of extraCwds) { if (knownCwds.has(cwd) || !fs.existsSync(cwd)) continue; const dirName = `--${cwd.replace(/\//g, '-')}--`; @@ -176,8 +206,8 @@ function listSessions(dirName: string): SessionInfo[] { if (!dirPath.startsWith(SESSIONS_ROOT) || !fs.existsSync(dirPath)) return []; const sessions = fs .readdirSync(dirPath) - .filter(f => f.endsWith('.jsonl')) - .map(f => parseSession(path.join(dirPath, f))) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => parseSession(path.join(dirPath, f))) .filter((s): s is SessionInfo => s !== null); sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return sessions; diff --git a/src/main/workbench-ipc.ts b/src/main/workbench-ipc.ts index e9c397e..aad931d 100644 --- a/src/main/workbench-ipc.ts +++ b/src/main/workbench-ipc.ts @@ -1,3 +1,4 @@ +import { loadArchive, selectedPath, readSessionTree } from './session-tree'; import { app, dialog, ipcMain, shell, type BrowserWindow } from 'electron'; import fs from 'fs/promises'; import os from 'os'; @@ -11,7 +12,6 @@ import { inside, inspectWorkspace, listSkills, - readSession, readText, validateDefaults, workspacePath, @@ -111,7 +111,12 @@ export function registerWorkbench( } else throw new Error('Unsupported Git action.'); return inspectWorkspace(cwd); }); - handle('session:preview', async (file) => readSession(file)); + handle('session:tree', async (file) => readSessionTree(file)); + handle('session:preview', async (file, entryId) => + selectedPath(await loadArchive(file), entryId) + .filter((e) => e.type === 'message') + .map((e) => e.message), + ); handle('workspace:skills', async (cwd) => listSkills(await knownWorkspace(cwd)), ); diff --git a/src/main/workbench.ts b/src/main/workbench.ts index 9b63c94..f892fe0 100644 --- a/src/main/workbench.ts +++ b/src/main/workbench.ts @@ -259,45 +259,10 @@ export async function readSession( file: string, sessionsRoot = SESSIONS_ROOT, ): Promise { - const target = await sessionPath(file, sessionsRoot); - const stat = await fs.stat(target); - if (stat.size > 16 * MAX_TEXT) - throw new Error( - 'This session is too large to preview. Resume it to continue.', - ); - const records: { - id?: string; - parentId?: string; - type?: string; - message?: unknown; - }[] = []; - for (const line of (await fs.readFile(target, 'utf8')).split('\n')) { - try { - const record = JSON.parse(line); - if ( - record && - typeof record === 'object' && - !Array.isArray(record) && - typeof record.id === 'string' - ) - records.push(record); - } catch { - /* Partial final writes are expected. */ - } - } - // Follow the current branch rather than mixing messages from abandoned branches. - const byId = new Map(records.filter((r) => r.id).map((r) => [r.id, r])); - let current = records.at(-1); - const branch: unknown[] = []; - const seen = new Set(); - while (current) { - if (current.id && seen.has(current.id)) break; - if (current.id) seen.add(current.id); - if (current.type === 'message' && current.message) - branch.unshift(current.message); - current = current.parentId ? byId.get(current.parentId) : undefined; - } - return branch; + const { loadArchive, selectedPath } = await import('./session-tree.js'); + return selectedPath(await loadArchive(file, sessionsRoot)) + .filter((entry) => entry.type === 'message') + .map((entry) => entry.message); } export async function listSkills(cwd: string): Promise { diff --git a/src/preload/index.ts b/src/preload/index.ts index bdfca0c..d7817f8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,8 +21,9 @@ contextBridge.exposeInMainWorld('pidex', { ipcRenderer.invoke('pidex:workspace:patch', cwd, file, staged, untracked), gitAction: (cwd: string, action: string, file?: string) => ipcRenderer.invoke('pidex:workspace:git', cwd, action, file), - previewSession: (file: string) => - ipcRenderer.invoke('pidex:session:preview', file), + sessionTree: (file: string) => ipcRenderer.invoke('pidex:session:tree', file), + previewSession: (file: string, entryId?: string | null) => + ipcRenderer.invoke('pidex:session:preview', file, entryId), skills: (cwd: string) => ipcRenderer.invoke('pidex:workspace:skills', cwd), openProject: (cwd: string) => ipcRenderer.invoke('pidex:workspace:open', cwd), attachFiles: () => ipcRenderer.invoke('pidex:workspace:attach'), @@ -34,8 +35,12 @@ contextBridge.exposeInMainWorld('pidex', { setModel: (id: string, provider: string, model: string) => ipcRenderer.invoke('pidex:session:model', id, provider, model), sessions: (dirName: string) => ipcRenderer.invoke('pidex:sessions', dirName), - openSession: (req: { cwd: string; sessionFile?: string; fork?: boolean }) => - ipcRenderer.invoke('pidex:session/open', req), + openSession: (req: { + cwd: string; + sessionFile?: string; + entryId?: string | null; + fork?: boolean; + }) => ipcRenderer.invoke('pidex:session/open', req), sendPrompt: (req: { sessionId: string; text: string; streaming: boolean }) => ipcRenderer.invoke('pidex:session/send', req), abortSession: (sessionId: string) => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fed8373..6155403 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,3 +1,4 @@ +import { SessionTree, EntryTree } from './components/SessionTree'; import { useEffect, useState } from 'react'; import { PidexMark } from './components/PidexMark'; import welcomeMark from './assets/workbench/welcome-mark.svg'; @@ -69,7 +70,10 @@ function App() { onModel={b.changeModel} running={view === 'session' && active?.running} disabled={ - !ws || (view === 'session' && active?.exited) || b.previewLoading + !ws || + (view === 'session' && active?.exited) || + b.previewLoading || + (view === 'history' && !preview?.ready) } busy={b.busy} placeholder={placeholder} @@ -499,6 +503,12 @@ function App() { > Review {details.changes.length} files + {active.startedAt && ( +

+ Opened at entry {active.startedAt.slice(0, 8)}. The + live branch advances as you send messages. +

+ )} Activity

{active.entries.filter((e) => e.kind === 'tool').length}{' '} @@ -539,39 +549,13 @@ function App() { label="Search sessions" />

- {(allHistory - ? filteredSessions - : filteredSessions.slice(0, 30) - ).map((s, i, arr) => { - const day = new Date(s.timestamp).toLocaleDateString(); - const prev = - i > 0 - ? new Date( - arr[i - 1].timestamp, - ).toLocaleDateString() - : ''; - return ( -
- {day !== prev && ( -

- {day === new Date().toLocaleDateString() - ? 'Today' - : day} -

- )} - -
- ); - })} + void b.showPreview(s)} + /> {!filteredSessions.length && (

No sessions found.

)} @@ -579,6 +563,13 @@ function App() { setAllHistory((v) => !v)}> {allHistory ? 'Show recent' : 'Show all sessions'} + {preview?.tree && ( + void b.showPreview(preview.info, id)} + /> + )}
{preview ? ( @@ -588,8 +579,17 @@ function App() {

{preview.info.title}

-

- {preview.info.model || 'Pi'} / saved locally +

+ {preview.info.model || 'Pi'} /{' '} + {preview.entryId + ? `Selected ${preview.entryId.slice(0, 8)}` + : 'Empty session'} + {preview.entryId === preview.tree?.leafId + ? ' / Archive tip' + : ''}

+ {preview.tree?.warning && ( +

+ {preview.tree.warning} +

+ )} + {preview.entryId && + preview.entryId !== preview.tree?.leafId && ( +

+ Pi RPC cannot resume an earlier entry in place. + Continuing creates a new fork from this selection + and preserves the source. +

+ )} {b.previewLoading ? ( @@ -611,7 +624,12 @@ function App() { <>
Fork from here @@ -623,7 +641,13 @@ function App() { )} - {composer('Continue this session…', 'Resume')} + {composer( + 'Continue selected branch…', + preview.entryId && + preview.entryId !== preview.tree?.leafId + ? 'Resume in new fork' + : 'Resume', + )} ) : ( diff --git a/src/renderer/api.ts b/src/renderer/api.ts index 840fd8f..f0e7a9a 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -1,3 +1,4 @@ +import type { SessionTree } from '../main/session-tree'; import type { Defaults, GitChange, @@ -30,6 +31,7 @@ type SessionInfo = { model: string; timestamp: string; messageCount: number; + parentSession?: string; }; type SessionState = { @@ -77,7 +79,11 @@ type PidexApi = { action: 'stage' | 'stage-all' | 'unstage' | 'commit', file?: string, ) => Promise>; - previewSession: (file: string) => Promise>; + sessionTree: (file: string) => Promise>; + previewSession: ( + file: string, + entryId?: string | null, + ) => Promise>; skills: (cwd: string) => Promise>; openProject: (cwd: string) => Promise>; attachFiles: () => Promise>; @@ -94,6 +100,7 @@ type PidexApi = { openSession: (req: { cwd: string; sessionFile?: string; + entryId?: string | null; fork?: boolean; }) => Promise; sendPrompt: (req: { diff --git a/src/renderer/components/SessionTree.tsx b/src/renderer/components/SessionTree.tsx new file mode 100644 index 0000000..e641882 --- /dev/null +++ b/src/renderer/components/SessionTree.tsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, type KeyboardEvent } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { SessionInfo } from '../api'; +import type { SessionTree as Tree } from '../../main/session-tree'; + +function treeKeys(event: KeyboardEvent) { + const items = [ + ...event.currentTarget.querySelectorAll( + '[role="treeitem"]', + ), + ]; + const index = items.indexOf(document.activeElement as HTMLButtonElement); + const next = + event.key === 'ArrowDown' + ? index + 1 + : event.key === 'ArrowUp' + ? index - 1 + : event.key === 'Home' + ? 0 + : event.key === 'End' + ? items.length - 1 + : -1; + if (next >= 0 && next < items.length) { + event.preventDefault(); + items[next].focus(); + } +} + +export function sessionForest(sessions: SessionInfo[]) { + const byFile = new Map(sessions.map((s) => [s.file, s])); + const rows: { + info: SessionInfo; + depth: number; + missing: boolean; + cyclic: boolean; + }[] = []; + const seen = new Set(); + const children = new Map(); + for (const s of sessions) { + if (s.parentSession && byFile.has(s.parentSession)) { + const list = children.get(s.parentSession) ?? []; + list.push(s); + children.set(s.parentSession, list); + } + } + const walk = (root: SessionInfo) => { + const stack = [{ info: root, depth: 0 }]; + while (stack.length) { + const row = stack.pop()!; + if (seen.has(row.info.file)) continue; + seen.add(row.info.file); + const chain = new Set(); + let ancestor: SessionInfo | undefined = row.info; + while (ancestor && !chain.has(ancestor.file)) { + chain.add(ancestor.file); + ancestor = ancestor.parentSession + ? byFile.get(ancestor.parentSession) + : undefined; + } + rows.push({ + ...row, + cyclic: Boolean(ancestor), + missing: Boolean( + row.info.parentSession && !byFile.has(row.info.parentSession), + ), + }); + for (const child of [...(children.get(row.info.file) ?? [])].reverse()) + stack.push({ info: child, depth: row.depth + 1 }); + } + }; + for (const s of sessions) + if (!s.parentSession || !byFile.has(s.parentSession)) walk(s); + for (const s of sessions) if (!seen.has(s.file)) walk(s); + return rows; +} + +export function SessionTree({ + sessions, + query = '', + limit, + selectedFile, + onSelect, +}: { + sessions: SessionInfo[]; + query?: string; + limit?: number; + selectedFile?: string; + onSelect: (info: SessionInfo) => void; +}) { + const included = new Set(); + const byFile = new Map(sessions.map((s) => [s.file, s])); + for (const info of sessions + .filter((s) => + `${s.title} ${s.model}`.toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, limit)) { + let current: SessionInfo | undefined = info; + while (current && !included.has(current.file)) { + included.add(current.file); + current = current.parentSession + ? byFile.get(current.parentSession) + : undefined; + } + } + return ( +
+ {sessionForest(sessions) + .filter((row) => included.has(row.info.file)) + .map(({ info, depth, missing, cyclic }) => ( + + ))} +
+ ); +} + +export function EntryTree({ + tree, + selectedId, + onSelect, +}: { + tree: Tree; + selectedId?: string | null; + onSelect: (id: string) => void; +}) { + const ref = useRef(null); + const virtual = useVirtualizer({ + count: tree.nodes.length, + getScrollElement: () => ref.current, + estimateSize: () => 54, + overscan: 8, + enabled: tree.nodes.length > 200, + }); + useEffect(() => { + if (tree.nodes.length > 200) { + const index = tree.nodes.findIndex((node) => node.id === selectedId); + if (index >= 0) virtual.scrollToIndex(index, { align: 'auto' }); + } + }, [selectedId, tree.nodes, virtual]); + const render = (index: number) => { + const node = tree.nodes[index]; + return ( + + ); + }; + return ( +
+ +
+ {tree.nodes.length > 200 ? ( +
+ {virtual.getVirtualItems().map((row) => ( +
+ {render(row.index)} +
+ ))} +
+ ) : ( + tree.nodes.map((node, index) => ( +
{render(index)}
+ )) + )} + {!tree.nodes.length && ( +

+ Empty session. Resume to start its first branch. +

+ )} +
+
+ ); +} diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index 0b7b311..e382f2e 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -1,3 +1,4 @@ +import type { SessionTree } from '../main/session-tree'; import { useCallback, useEffect, useRef, useState } from 'react'; import { applyEvent, entriesFromMessages, type Entry } from './timeline'; import type { @@ -15,6 +16,8 @@ export type View = type SessionTab = { runtimeId: string; file?: string; + entryId?: string | null; + startedAt?: string; cwd: string; title: string; model: string; @@ -56,6 +59,9 @@ export function useWorkbench() { const [preview, setPreview] = useState<{ info: SessionInfo; entries: Entry[]; + tree?: SessionTree; + entryId?: string | null; + ready?: boolean; } | null>(null); const [previewLoading, setPreviewLoading] = useState(false); const [drafts, setDrafts] = useState>({}); @@ -76,7 +82,7 @@ export function useWorkbench() { tabs.find((t) => t.runtimeId === activeId && t.cwd === ws?.cwd) ?? null; const draftKey = view === 'history' - ? `preview:${preview?.info.file ?? ''}` + ? `preview:${preview?.info.file ?? ''}:${preview?.entryId ?? ''}` : active && view !== 'new' ? active.runtimeId : `new:${ws?.cwd ?? ''}`; @@ -155,6 +161,7 @@ export function useWorkbench() { setDetails(EMPTY_DETAILS); setSessions([]); setPreview(null); + setPreviewLoading(false); setModels([]); setModelsLoading(Boolean(ws)); setModelError(''); @@ -245,12 +252,19 @@ export function useWorkbench() { const openSession = async ( info?: SessionInfo, fork = false, + entryId?: string | null, ): Promise => { if (!ws || opening.current) return null; const existing = !fork && info && - tabs.find((t) => t.cwd === ws.cwd && t.file === info.file && !t.exited); + tabs.find( + (t) => + t.cwd === ws.cwd && + t.file === info.file && + t.entryId === entryId && + !t.exited, + ); if (existing) { setActiveId(existing.runtimeId); return existing; @@ -263,6 +277,7 @@ export function useWorkbench() { cwd: ws.cwd, sessionFile: info?.file, fork, + entryId, }); if (result.error || !result.sessionId) throw new Error(result.error ?? 'Could not open this session.'); @@ -275,10 +290,12 @@ export function useWorkbench() { const tab: SessionTab = { runtimeId: id, cwd: ws.cwd, - file: fork - ? result.state?.sessionFile - : (info?.file ?? result.state?.sessionFile), - title: info ? `${info.title}${fork ? ' (fork)' : ''}` : 'New session', + file: result.state?.sessionFile ?? info?.file, + entryId, + startedAt: entryId ?? undefined, + title: info + ? `${info.title}${fork || (result.state?.sessionFile && result.state.sessionFile !== info.file) ? ' (fork)' : ''}` + : 'New session', model: result.state?.model?.id ?? info?.model ?? '', entries: entriesFromMessages(result.messages ?? []), commands: [], @@ -309,21 +326,34 @@ export function useWorkbench() { if (tab) setView('session'); }); const forkSession = () => - preview && + preview?.ready && + preview.tree?.canContinue && run(async () => { - const tab = await openSession(preview.info, true); + const tab = await openSession(preview.info, true, preview.entryId); if (tab) setView('session'); }); const showPreview = useCallback( - async (info: SessionInfo) => { + async (info: SessionInfo, entryId?: string) => { const seq = ++previewSeq.current; - setPreview({ info, entries: [] }); + setPreview({ info, entries: [], entryId }); setPreviewLoading(true); setError(''); try { - const messages = unwrap(await api.previewSession(info.file)); + const tree = unwrap(await api.sessionTree(info.file)); + const selectedId = entryId === undefined ? tree.leafId : entryId; + if (previewSeq.current !== seq) return; + setPreview({ info, tree, entryId: selectedId, entries: [] }); + const messages = unwrap( + await api.previewSession(info.file, selectedId), + ); if (previewSeq.current === seq) - setPreview({ info, entries: entriesFromMessages(messages) }); + setPreview({ + info, + tree, + entryId: selectedId, + ready: tree.canContinue, + entries: entriesFromMessages(messages), + }); } catch (e) { if (previewSeq.current === seq) report(e); } finally { @@ -340,12 +370,23 @@ export function useWorkbench() { const send = (raw?: string) => run(async () => { const text = (raw ?? draft).trim(); - if (!text || busy || !ws || sending.current) return; + if ( + !text || + busy || + !ws || + sending.current || + (view === 'history' && !preview?.ready) + ) + return; sending.current = true; try { let tab = view === 'new' ? null : active; if (view === 'history' && preview) - tab = await openSession(preview.info); + tab = await openSession( + preview.info, + preview.entryId !== preview.tree?.leafId, + preview.entryId, + ); else if (!tab || tab.exited) tab = await openSession(); if (!tab || tab.exited) return; const key = `local-${crypto.randomUUID()}`; @@ -359,6 +400,7 @@ export function useWorkbench() { title: t.title === 'New session' ? text.slice(0, 90) : t.title, running: true, + entryId: undefined, entries: [...t.entries, { key, kind: 'user', text }], } : t, diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 29ed0e6..1b752b8 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -1458,3 +1458,70 @@ html[data-reduced-motion='true'] .wb-shell *::after { scroll-behavior: auto !important; } } + +.wb-entry-tree { + border-top: 1px solid var(--line, #30343a); + margin-top: 12px; + padding-top: 12px; +} +.wb-entry-tree label { + display: grid; + gap: 6px; + padding: 0 10px 10px; +} +.wb-entry-tree select { + width: 100%; + min-width: 0; + background: var(--surface, #171b20); + color: inherit; + border: 1px solid var(--line, #30343a); + padding: 7px; + font: inherit; +} +.wb-entry-scroll { + max-height: 360px; + overflow: auto; +} +.wb-entry-node { + display: grid; + gap: 4px; + width: 100%; + height: 54px; + text-align: left; + padding: 8px; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: inherit; + cursor: pointer; +} +.wb-entry-node span, +.wb-entry-node small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wb-entry-node small { + opacity: 0.65; + font-size: 10px; +} +.wb-entry-node:hover { + background: #ffffff08; +} +.wb-entry-node.is-active { + background: #ffffff0d; + border-left-color: #99b9a5; +} +.wb-branch-notice { + font-size: 12px; + line-height: 1.5; + padding: 12px 24px; + margin: 0; + border-bottom: 1px solid #ffffff12; + color: #b9c7bd; +} +.wb-history-heading .wb-meta { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/tests/e2e/app.test.ts b/tests/e2e/app.test.ts index 09b28ea..0571e99 100644 --- a/tests/e2e/app.test.ts +++ b/tests/e2e/app.test.ts @@ -94,7 +94,13 @@ describe('Figma workbench in Electron', () => { Date.now() - (i + 1) * 15 * 60_000, ).toISOString(); const records = [ - { type: 'session', id: `session-${i}`, timestamp, cwd: project }, + { + type: 'session', + version: 3, + id: `session-${i}`, + timestamp, + cwd: project, + }, { type: 'model_change', id: 'm', @@ -245,6 +251,7 @@ describe('Figma workbench in Electron', () => { .filter((c) => c.type === 'get_state').length; await win.getByRole('button', { name: /^Sessions/ }).click(); await win + .locator('.wb-transcript') .getByText('The composer now keeps a visible focus ring', { exact: false, }) @@ -357,11 +364,13 @@ describe('Figma workbench in Electron', () => { .getByRole('heading', { name: 'Fix composer focus (fork)', exact: true }) .waitFor(); expect(await fs.readFile(source, 'utf8')).toBe(before); + const forks = await fs.readdir(path.dirname(source)); + const fork = forks.find( + (file) => !['0.jsonl', '1.jsonl', '2.jsonl'].includes(file), + ); + expect(fork).toBeTruthy(); expect( - await fs.readFile( - path.join(fixtureRoot, 'sessions', 'workspace', 'fork.jsonl'), - 'utf8', - ), + await fs.readFile(path.join(path.dirname(source), fork!), 'utf8'), ).toContain('The composer now keeps'); }); diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts new file mode 100644 index 0000000..037448a --- /dev/null +++ b/tests/e2e/session-tree.test.ts @@ -0,0 +1,297 @@ +import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from 'playwright-core'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const ROOT = path.resolve(__dirname, '../..'); +let app: ElectronApplication; +let page: Page; +let fixture: string; +let source: string; +let log: string; +const errors: string[] = []; +const message = ( + id: string, + parentId: string | null, + role: string, + text: string, +) => ({ + type: 'message', + id, + parentId, + message: { role, content: [{ type: 'text', text }] }, +}); +const readLog = async () => + (await fs.readFile(log, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + +describe('session branches in Electron', () => { + beforeAll(async () => { + fixture = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-branches-')); + const project = path.join(fixture, 'project'); + const archives = path.join(fixture, 'sessions/workspace'); + await Promise.all([ + fs.mkdir(project), + fs.mkdir(archives, { recursive: true }), + fs.mkdir(path.join(fixture, 'user-data')), + ]); + source = path.join(archives, 'source.jsonl'); + log = path.join(fixture, 'rpc.jsonl'); + await fs.writeFile( + source, + [ + { + type: 'session', + version: 3, + id: 'source', + cwd: project, + timestamp: '2026-11-05T01:00:00Z', + }, + message('task', null, 'user', 'Explore parser branches'), + message( + 'old-answer', + 'task', + 'assistant', + 'The original branch uses a streaming parser.', + ), + message( + 'old-followup', + 'old-answer', + 'user', + 'Keep the streaming approach', + ), + message( + 'old-tip', + 'old-followup', + 'assistant', + 'Streaming branch is ready.', + ), + message( + 'new-tip', + 'task', + 'assistant', + 'The latest branch uses a buffered parser.', + ), + ] + .map((r) => JSON.stringify(r)) + .join('\n') + '\n', + ); + await fs.writeFile( + path.join(archives, 'empty.jsonl'), + JSON.stringify({ + type: 'session', + version: 3, + id: 'empty', + cwd: project, + timestamp: '2026-11-04T01:00:00Z', + }) + '\n', + ); + app = await electron.launch({ + executablePath: require('electron'), + args: ['.'], + cwd: ROOT, + env: { + ...process.env, + PIDEX_E2E_CWD: project, + PIDEX_E2E_RENDERER_URL: inject('rendererUrl'), + PIDEX_E2E_USER_DATA: path.join(fixture, 'user-data'), + PIDEX_SESSIONS_ROOT: path.join(fixture, 'sessions'), + PIDEX_PI: path.join(ROOT, 'tests/fixtures/pi-rpc.cjs'), + PIDEX_RPC_LOG: log, + }, + }); + page = await app.firstWindow(); + page.setDefaultTimeout(15000); + page.on('pageerror', (error) => errors.push(error.message)); + await page + .getByRole('heading', { name: 'Start a session', exact: true }) + .waitFor(); + }); + afterAll(async () => { + await app?.close(); + if (fixture) await fs.rm(fixture, { recursive: true, force: true }); + }); + + it('previews selected branch and ancestor, forks exact selection and preserves both source branches', async () => { + const original = await fs.readFile(source, 'utf8'); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .waitFor(); + await page.getByLabel('Branch tip').selectOption('old-tip'); + await page + .locator('.wb-transcript') + .getByText('Streaming branch is ready.') + .waitFor(); + expect( + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .count(), + ).toBe(0); + expect( + await page + .getByRole('treeitem', { name: /Streaming branch is ready/ }) + .getAttribute('aria-selected'), + ).toBe('true'); + // Select an ancestor, not just a leaf. Preview and fork must stop here. + await page + .getByRole('treeitem', { name: /assistant: The original branch/ }) + .click(); + await page + .locator('.wb-transcript') + .getByText('The original branch uses a streaming parser.') + .waitFor(); + expect( + await page + .locator('.wb-transcript') + .getByText('Streaming branch is ready.') + .count(), + ).toBe(0); + await page + .getByRole('button', { name: 'Fork from here', exact: true }) + .click(); + await page + .getByRole('heading', { + name: 'Explore parser branches (fork)', + exact: true, + }) + .waitFor(); + const stateRequest = (await readLog()) + .filter((c) => c.type === 'get_state' && c.sessionFile) + .at(-1); + const forkPath = stateRequest.sessionFile as string; + expect(forkPath).not.toBe(await fs.realpath(source)); + const forkRecords = (await fs.readFile(forkPath, 'utf8')) + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + expect(forkRecords[0].parentSession).toBe(await fs.realpath(source)); + expect(forkRecords.slice(1).map((e) => e.id)).toEqual([ + 'task', + 'old-answer', + ]); + expect(await fs.readFile(source, 'utf8')).toBe(original); + await page.getByLabel('Message pi').fill('Continue the fork'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect( + (await readLog()).filter((c) => c.type === 'prompt').at(-1).sessionFile, + ).toBe(forkPath); + expect(await fs.readFile(source, 'utf8')).toBe(original); + }); + + it('continues an older tip as an explicit new fork and resumes the archive tip in place', async () => { + const original = await fs.readFile(source, 'utf8'); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page.getByLabel('Branch tip').selectOption('old-tip'); + await page + .locator('.wb-transcript') + .getByText('Streaming branch is ready.') + .waitFor(); + await page.getByLabel('Message pi').fill('Continue older tip'); + await page + .getByRole('button', { name: 'Resume in new fork', exact: true }) + .click(); + await page + .getByRole('heading', { + name: 'Explore parser branches (fork)', + exact: true, + }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const prompt = (await readLog()).filter((c) => c.type === 'prompt').at(-1); + expect(prompt.sessionFile).not.toBe(await fs.realpath(source)); + const content = await fs.readFile(prompt.sessionFile, 'utf8'); + expect(content).toContain('old-tip'); + expect(content).not.toContain('new-tip'); + expect(await fs.readFile(source, 'utf8')).toBe(original); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page.getByLabel('Branch tip').selectOption('new-tip'); + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .waitFor(); + await page.getByLabel('Message pi').fill('Continue latest tip'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: 'Explore parser branches', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect( + (await readLog()).filter((c) => c.type === 'prompt').at(-1).sessionFile, + ).toBe(await fs.realpath(source)); + const resumed = await fs.readFile(source, 'utf8'); + expect(resumed.startsWith(original)).toBe(true); + expect(resumed).toContain('"parentId":"new-tip"'); + expect(resumed).toContain('old-tip'); + expect(errors).toEqual([]); + }); + it('handles empty sessions and rejects stale entry selections without opening an unrelated leaf', async () => { + const original = await fs.readFile(source, 'utf8'); + const cwd = JSON.parse(original.split('\n')[0]).cwd as string; + const before = (await readLog()).filter( + (c) => c.type === 'get_state', + ).length; + const stale = await page.evaluate( + async ({ cwd, source }) => + window.pidex.openSession({ + cwd, + sessionFile: source, + entryId: 'missing-entry', + }), + { cwd, source }, + ); + expect(stale.error).toContain('no longer exists'); + const staleEmpty = await page.evaluate( + async ({ cwd, source }) => + window.pidex.openSession({ cwd, sessionFile: source, entryId: null }), + { cwd, source }, + ); + expect(staleEmpty.error).toContain('has advanced'); + expect((await readLog()).filter((c) => c.type === 'get_state').length).toBe( + before, + ); + expect(await fs.readFile(source, 'utf8')).toBe(original); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page.getByRole('treeitem', { name: /\(empty session\)/ }).click(); + await page + .getByText('Empty session. Resume to start its first branch.') + .waitFor(); + expect( + await page + .getByRole('button', { name: 'Fork from here', exact: true }) + .isDisabled(), + ).toBe(true); + await page.getByLabel('Message pi').fill('Start empty branch'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: '(empty session)', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect( + (await readLog()).filter((c) => c.type === 'prompt').at(-1).sessionFile, + ).toBe(await fs.realpath(path.join(path.dirname(source), 'empty.jsonl'))); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/fixtures/long-session.ts b/tests/fixtures/long-session.ts index 75e07d7..fc38d33 100644 --- a/tests/fixtures/long-session.ts +++ b/tests/fixtures/long-session.ts @@ -3,6 +3,7 @@ export function longSession(cwd: string, turns = 1500) { const records: object[] = [ { type: 'session', + version: 3, id: 'long-session', cwd, timestamp: new Date().toISOString(), diff --git a/tests/fixtures/pi-rpc.cjs b/tests/fixtures/pi-rpc.cjs index 24de792..2b38b26 100755 --- a/tests/fixtures/pi-rpc.cjs +++ b/tests/fixtures/pi-rpc.cjs @@ -6,15 +6,31 @@ const args = process.argv.slice(2); let file = args.includes('--session') ? args[args.indexOf('--session') + 1] : undefined; -let messages = file +// Match Pi: loading a file chooses its last non-header entry and follows +// parentId, rather than combining abandoned branches into one transcript. +let records = file ? fs .readFileSync(file, 'utf8') .trim() .split('\n') + .filter(Boolean) .map((line) => JSON.parse(line)) - .filter((entry) => entry.type === 'message') - .map((entry) => entry.message) : []; +const branchMessages = () => { + const entries = records.filter((entry) => entry.type !== 'session'); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const branch = []; + const seen = new Set(); + let current = entries.at(-1); + while (current && !seen.has(current.id)) { + seen.add(current.id); + if (current.type === 'message') branch.unshift(current.message); + current = byId.get(current.parentId); + } + return branch; +}; +let messages = branchMessages(); +let promptSeq = 0; let streaming = false; let model = { id: 'claude-sonnet', @@ -37,12 +53,18 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { if (process.env.PIDEX_RPC_LOG) fs.appendFileSync( process.env.PIDEX_RPC_LOG, - JSON.stringify(command) + '\n', + JSON.stringify({ ...command, sessionFile: file }) + '\n', ); switch (command.type) { case 'get_state': reply({ model, isStreaming: streaming, sessionFile: file }); break; + case 'get_entries': + reply({ + entries: [], + leafId: records.filter((e) => e.type !== 'session').at(-1)?.id ?? null, + }); + break; case 'get_messages': reply({ messages }); break; @@ -90,6 +112,7 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { const entries = [ { type: 'session', + version: 3, id: 'fork', timestamp: new Date().toISOString(), cwd: process.cwd(), @@ -124,10 +147,23 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { }); break; } - messages.push({ + const message = { role: 'user', content: [{ type: 'text', text: command.message }], - }); + }; + if (file) { + const entry = { + type: 'message', + id: `prompt-${process.pid}-${++promptSeq}`, + parentId: + records.filter((e) => e.type !== 'session').at(-1)?.id ?? null, + message, + }; + records.push(entry); + // Accept fixtures with or without a final newline. + fs.appendFileSync(file, '\n' + JSON.stringify(entry) + '\n'); + } + messages.push(message); reply(); streaming = true; event('agent_start'); diff --git a/tests/integration/SessionTree.test.tsx b/tests/integration/SessionTree.test.tsx new file mode 100644 index 0000000..c9c7d48 --- /dev/null +++ b/tests/integration/SessionTree.test.tsx @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { + EntryTree, + SessionTree, + sessionForest, +} from '../../src/renderer/components/SessionTree'; +import type { SessionInfo } from '../../src/renderer/api'; +import type { SessionTree as Tree } from '../../src/main/session-tree'; +const info = (file: string, parentSession?: string): SessionInfo => ({ + id: file, + file, + title: file, + parentSession, + model: 'pi', + timestamp: '', + messageCount: 2, +}); +const tree: Tree = { + leafId: 'new', + canContinue: true, + nodes: [ + { + id: 'root', + parentId: null, + depth: 0, + label: 'user: Task', + tip: false, + valid: true, + }, + { + id: 'old', + parentId: 'root', + depth: 1, + label: 'assistant: Old answer', + tip: true, + valid: true, + }, + { + id: 'new', + parentId: 'root', + depth: 1, + label: 'assistant: New answer', + tip: true, + valid: true, + }, + ], +}; +describe('session tree navigation', () => { + afterEach(cleanup); + it('nests fork archives, preserves source context when searching and exposes selection', () => { + const onSelect = vi.fn(); + render( + , + ); + const rows = screen.getAllByRole('treeitem'); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain('source'); + expect(rows[1].getAttribute('aria-level')).toBe('2'); + expect(rows[1].getAttribute('aria-selected')).toBe('true'); + fireEvent.click(rows[0]); + expect(onSelect).toHaveBeenCalledWith(info('source')); + rows[0].focus(); + fireEvent.keyDown(rows[0], { key: 'ArrowDown' }); + expect(document.activeElement).toBe(rows[1]); + }); + it('terminates missing and cyclic session lineage without hiding archives', () => { + const sessions = [info('a', 'b'), info('b', 'a'), info('c', 'gone')]; + expect( + sessionForest(sessions) + .map((r) => r.info.file) + .sort(), + ).toEqual(['a', 'b', 'c']); + render(); + expect(screen.getByText(/Source unavailable/)).toBeTruthy(); + }); + it('selects an exact entry or branch tip and distinguishes selected from archive tip', () => { + const onSelect = vi.fn(); + const { rerender } = render( + , + ); + const old = screen.getByRole('treeitem', { name: /Old answer/ }); + fireEvent.click(old); + expect(onSelect).toHaveBeenCalledWith('old'); + fireEvent.change(screen.getByLabelText('Branch tip'), { + target: { value: 'old' }, + }); + expect(onSelect).toHaveBeenLastCalledWith('old'); + rerender(); + expect( + screen + .getByRole('treeitem', { name: /Old answer/ }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect( + within(screen.getByRole('treeitem', { name: /New answer/ })).getByText( + /Archive tip/, + ), + ).toBeTruthy(); + }); + it('explains empty sessions', () => { + render( + , + ); + expect(screen.getByText(/Empty session/)).toBeTruthy(); + }); +}); diff --git a/tests/unit/session-tree.test.ts b/tests/unit/session-tree.test.ts new file mode 100644 index 0000000..2551bc8 --- /dev/null +++ b/tests/unit/session-tree.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { + archiveTree, + forkArchive, + loadArchive, + parseArchive, + selectedPath, +} from '../../src/main/session-tree'; + +const message = (id: string, parentId: string | null, text = id) => ({ + type: 'message', + id, + parentId, + message: { role: 'assistant', content: [{ type: 'text', text }] }, +}); +const header = { + type: 'session', + version: 3, + id: 'session', + cwd: '/tmp/project', +}; +const records = [ + header, + message('root', null), + message('old', 'root'), + { type: 'model_change', id: 'model', parentId: 'root', modelId: 'test' }, + message('new', 'model'), + { + type: 'label', + id: 'label', + parentId: 'new', + targetId: 'old', + label: 'Experiment', + }, +]; +const encoded = (value: unknown[]) => + value.map((r) => JSON.stringify(r)).join('\n') + '\n'; +const temporary: string[] = []; +describe('Pi archive branch selection', () => { + afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + it('includes metadata in the tree and resolves explicit abandoned branches without latest-leaf fallback', () => { + const archive = parseArchive(encoded(records)); + expect(archiveTree(archive)).toMatchObject({ + leafId: 'label', + canContinue: true, + nodes: [ + { id: 'root', depth: 0 }, + { id: 'old', depth: 1, tip: true }, + { id: 'model', depth: 1 }, + { id: 'new', depth: 2 }, + { id: 'label', depth: 3, tip: true }, + ], + }); + expect(selectedPath(archive, 'old').map((e) => e.id)).toEqual([ + 'root', + 'old', + ]); + expect(selectedPath(archive).map((e) => e.id)).toEqual([ + 'root', + 'model', + 'new', + 'label', + ]); + expect(() => selectedPath(archive, 'deleted')).toThrow('no longer exists'); + }); + it('shows orphaned and cyclic entries as invalid browseable roots and rejects their continuation', () => { + const archive = parseArchive( + encoded([ + header, + message('orphan', 'gone'), + message('a', 'b'), + message('b', 'a'), + ]), + ); + expect(archiveTree(archive).nodes).toHaveLength(3); + expect(archiveTree(archive).nodes.every((n) => !n.valid)).toBe(true); + expect(() => selectedPath(archive, 'orphan')).toThrow('missing lineage'); + expect(() => selectedPath(archive, 'b')).toThrow('cyclic lineage'); + }); + it('handles empty archives, malformed tails, duplicate ids and unsupported archive versions', () => { + expect(archiveTree(parseArchive(encoded([header])))).toMatchObject({ + nodes: [], + leafId: null, + }); + expect(selectedPath(parseArchive(encoded([header])), null)).toEqual([]); + expect(() => selectedPath(parseArchive(encoded(records)), null)).toThrow( + 'has advanced', + ); + for (const text of [ + encoded(records) + '{partial', + encoded([...records, message('old', null)]), + ]) { + expect(archiveTree(parseArchive(text))).toMatchObject({ + canContinue: false, + warning: expect.any(String), + }); + } + expect( + archiveTree(parseArchive(encoded([{ ...header, version: 2 }]))) + .canContinue, + ).toBe(false); + }); + it('forks exactly the selected parent chain, preserving tool/compaction records and source bytes', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-tree-')); + temporary.push(root); + const file = path.join(root, 'source.jsonl'); + const original = encoded([ + ...records, + { + type: 'compaction', + id: 'compact', + parentId: 'old', + firstKeptEntryId: 'root', + summary: 'keep', + }, + { + type: 'message', + id: 'tool', + parentId: 'compact', + message: { role: 'toolResult', toolCallId: 'call', content: [] }, + }, + ]); + await fs.writeFile(file, original); + const fork = await forkArchive(file, 'tool', root); + const archive = await loadArchive(fork, root); + expect(archive.header).toMatchObject({ + version: 3, + parentSession: await fs.realpath(file), + }); + expect(archive.header?.id).not.toBe('session'); + expect(archive.entries.map((e) => e.id)).toEqual([ + 'root', + 'old', + 'compact', + 'tool', + ]); + expect(archive.entries[2]).toMatchObject({ + firstKeptEntryId: 'root', + summary: 'keep', + }); + expect(await fs.readFile(file, 'utf8')).toBe(original); + await expect(forkArchive(file, 'missing', root)).rejects.toThrow( + 'no longer exists', + ); + await fs.writeFile(file, original + '{partial'); + await expect(forkArchive(file, 'old', root)).rejects.toThrow( + 'intact Pi v3', + ); + }); + it('rejects out-of-root and symlinked source paths before writing a fork', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-tree-')); + temporary.push(root); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-outside-')); + temporary.push(outside); + const file = path.join(outside, 'source.jsonl'); + await fs.writeFile(file, encoded(records)); + const link = path.join(root, 'link.jsonl'); + await fs.symlink(file, link); + await expect(forkArchive(link, 'old', root)).rejects.toThrow( + 'Invalid session', + ); + expect(await fs.readdir(root)).toEqual(['link.jsonl']); + }); +}); diff --git a/tests/unit/workbench.test.ts b/tests/unit/workbench.test.ts index 04ee6ec..037f3bf 100644 --- a/tests/unit/workbench.test.ts +++ b/tests/unit/workbench.test.ts @@ -235,7 +235,7 @@ describe('workspace inspection and preview', () => { .map((record) => JSON.stringify(record)) .join('\n'), ); - expect(await readSession(file, root)).toEqual([first, second]); + await expect(readSession(file, root)).rejects.toThrow('cyclic lineage'); const secret = path.join(outside, 'private.jsonl'); await fs.writeFile(secret, '{}'); const linked = path.join(root, 'linked.jsonl'); From 3afc3b2287f1f577661faaa569fe6357f7d7aa77 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:44:07 +0900 Subject: [PATCH 2/5] fix: preserve fork sources across Pi startup metadata writes Co-authored-by: OpenAI Codex --- README.md | 4 +-- docs/adr/0013-session-branch-selection.md | 2 +- src/main/index.ts | 38 +++++++---------------- src/main/session-tree.ts | 27 ++++++++++++++++ tests/e2e/session-tree.test.ts | 11 ++++--- tests/fixtures/pi-rpc.cjs | 27 +++++++++++++--- tests/unit/session-tree.test.ts | 35 +++++++++++++++++++++ 7 files changed, 106 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index bf87fcc..c3ad5d1 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,11 @@ These are captures of real Electron interactions with a temporary Git workspace - Discover workspace and global skills, switch models, and save model and reasoning defaults per workspace. - Use quick switch for workspaces, sessions, files, and commands. -Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so historical forks extract a native branch archive; current-tip forks retain Pi's `clone` command. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits. +Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so forks extract a native branch archive before launching Pi so even runtime startup writes preserve the source. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits. Active sessions and history use a virtualized transcript. A local development benchmark with 4,500 mixed entries opened in about 1.4 seconds, down from 8.3 seconds, and mounted fewer than 100 transcript DOM nodes instead of 39,000. Timings vary by machine and load; see [ADR 0011](docs/adr/0011-virtualized-transcript.md) for the method and limits. -Session trees, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). +Recovery summaries and work-state signals, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). ## Install diff --git a/docs/adr/0013-session-branch-selection.md b/docs/adr/0013-session-branch-selection.md index 93aa7fb..e41a366 100644 --- a/docs/adr/0013-session-branch-selection.md +++ b/docs/adr/0013-session-branch-selection.md @@ -2,5 +2,5 @@ - **Status**: Proposed - **Context**: A Pi v3 JSONL archive is an append-only tree. Entries of every type have `id` and `parentId`; the session header is not a tree node. Reloading an archive selects the last non-header entry. Fork archives link to their source with `parentSession`. Inspection of installed Pi 1.5.0 (`docs/rpc-commands.md`, `dist/modes/rpc/rpc-mode.js`, `dist/core/session-manager.js`) confirmed that RPC exposes `get_tree`, `fork`, and `clone`, but does not expose the SDK's `navigateTree`. `fork` branches before a previous user message, while `clone` duplicates the currently active path. Neither selects an arbitrary archived entry. -- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. Current-tip forks retain the supported `clone` RPC, including its cancellation handling. Historical forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Historical extraction does not invoke Pi extension fork hooks. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives. +- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. All forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Pi can append model/thinking settings during startup, so accept only a verified linear chain of those setup entries after the selection; reject other movement. Starting Pi against the source before calling `clone` can therefore modify source bytes. Extracting before startup preserves the source for current-tip forks too. Archive extraction does not invoke Pi extension fork hooks. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives. - **Consequences**: Historical continuation creates a separate session rather than moving the original session's in-memory leaf. Old archive versions remain previewable where their lineage is readable; continuation requires intact v3 input and tells the user to migrate older files in Pi. Missing parents, cycles, duplicate IDs, malformed records and stale selections fail closed for affected operations. Archive browsing has a 16 MiB bound. Preview shows stored conversation history along the selected parent chain, including pre-compaction messages; Pi constructs its own compacted runtime context when it opens the resulting archive. Cross-folder lineage migration and multi-root workflows remain follow-ups. A future documented navigation RPC can replace extraction after its exact-entry semantics are verified. diff --git a/src/main/index.ts b/src/main/index.ts index 1bd0ac8..26d885f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,9 @@ -import { forkArchive, loadArchive, selectedPath } from './session-tree'; +import { + forkArchive, + loadArchive, + selectedPath, + runtimeMatchesSelection, +} from './session-tree'; import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import fs from 'fs'; import path from 'path'; @@ -121,7 +126,6 @@ ipcMain.handle( return { error: 'workspace path does not exist' }; } let expectedEntryId: string | null | undefined; - let cloneCurrent = false; if (sessionFile) { try { sessionFile = await sessionPath(sessionFile); @@ -138,12 +142,9 @@ ipcMain.handle( throw new Error('Invalid selected entry.'); const selected = selectedPath(archive, req.entryId).at(-1)?.id; expectedEntryId = selected ?? null; - cloneCurrent = Boolean( - req.fork && selected === archive.entries.at(-1)?.id, - ); if (req.fork && !selected) throw new Error('This session has no entry to fork.'); - if (selected && selected !== archive.entries.at(-1)?.id) { + if (selected && (req.fork || selected !== archive.entries.at(-1)?.id)) { sessionFile = await forkArchive(sessionFile, selected); } } catch (error) { @@ -200,15 +201,10 @@ ipcMain.handle( type: 'get_entries', ...(expectedEntryId ? { since: expectedEntryId } : {}), }); - const data = position.data as - { leafId?: string | null; entries?: { type?: string }[] } | undefined; - // Pi may initialize model/thinking metadata in an otherwise empty session. - const matches = - expectedEntryId === null - ? Array.isArray(data?.entries) && - !data.entries.some((e) => e.type === 'message') - : data?.leafId === expectedEntryId; - if (!position.success || !matches) { + if ( + !position.success || + !runtimeMatchesSelection(expectedEntryId, position.data) + ) { sessions.delete(id); session.dispose(); return { @@ -219,18 +215,6 @@ ipcMain.handle( } } - if (cloneCurrent) { - const cloned = await session.command({ type: 'clone' }); - if ( - !cloned.success || - (cloned.data as { cancelled?: boolean } | undefined)?.cancelled - ) { - sessions.delete(id); - session.dispose(); - return { error: cloned.error ?? 'Session fork was cancelled.' }; - } - } - const [state, messages] = await Promise.all([ session.getState(), session.getMessages(), diff --git a/src/main/session-tree.ts b/src/main/session-tree.ts index a1bb289..7ba674b 100644 --- a/src/main/session-tree.ts +++ b/src/main/session-tree.ts @@ -210,3 +210,30 @@ export async function forkArchive( ); return target; } + +/** Pi initializes model/thinking settings on open. Those entries may advance the + * leaf without changing the selected conversation. Reject all other movement, + * including messages, branching metadata, unknown records and broken chains. */ +export function runtimeMatchesSelection( + entryId: string | null, + data: unknown, +): boolean { + if (!data || typeof data !== 'object') return false; + const position = data as { entries?: unknown; leafId?: unknown }; + if (!Array.isArray(position.entries)) return false; + let parent = entryId; + for (const value of position.entries) { + if (!value || typeof value !== 'object') return false; + const entry = value as { type?: unknown; id?: unknown; parentId?: unknown }; + if ( + (entry.type !== 'model_change' && + entry.type !== 'thinking_level_change') || + typeof entry.id !== 'string' || + !entry.id || + entry.parentId !== parent + ) + return false; + parent = entry.id; + } + return position.leafId === parent; +} diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts index 037448a..6122f5c 100644 --- a/tests/e2e/session-tree.test.ts +++ b/tests/e2e/session-tree.test.ts @@ -173,12 +173,15 @@ describe('session branches in Electron', () => { const forkRecords = (await fs.readFile(forkPath, 'utf8')) .trim() .split('\n') + .filter(Boolean) .map((l) => JSON.parse(l)); expect(forkRecords[0].parentSession).toBe(await fs.realpath(source)); - expect(forkRecords.slice(1).map((e) => e.id)).toEqual([ - 'task', - 'old-answer', - ]); + expect( + forkRecords + .slice(1) + .filter((e) => e.type === 'message') + .map((e) => e.id), + ).toEqual(['task', 'old-answer']); expect(await fs.readFile(source, 'utf8')).toBe(original); await page.getByLabel('Message pi').fill('Continue the fork'); await page.getByRole('button', { name: 'Send', exact: true }).click(); diff --git a/tests/fixtures/pi-rpc.cjs b/tests/fixtures/pi-rpc.cjs index 2b38b26..1f10608 100755 --- a/tests/fixtures/pi-rpc.cjs +++ b/tests/fixtures/pi-rpc.cjs @@ -29,6 +29,18 @@ const branchMessages = () => { } return branch; }; +// Pi can append setup metadata while opening a session. Model that behavior +// so forks must avoid loading the source, and position checks allow only setup. +if (file) { + const entry = { + type: 'thinking_level_change', + id: `setup-${process.pid}`, + parentId: records.filter((e) => e.type !== 'session').at(-1)?.id ?? null, + thinkingLevel: 'medium', + }; + records.push(entry); + fs.appendFileSync(file, '\n' + JSON.stringify(entry) + '\n'); +} let messages = branchMessages(); let promptSeq = 0; let streaming = false; @@ -60,10 +72,17 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { reply({ model, isStreaming: streaming, sessionFile: file }); break; case 'get_entries': - reply({ - entries: [], - leafId: records.filter((e) => e.type !== 'session').at(-1)?.id ?? null, - }); + { + const entries = records.filter((e) => e.type !== 'session'); + const index = command.since + ? entries.findIndex((e) => e.id === command.since) + : -1; + reply({ + entries: entries.slice(index + 1), + leafId: entries.at(-1)?.id ?? null, + }); + } + break; case 'get_messages': reply({ messages }); diff --git a/tests/unit/session-tree.test.ts b/tests/unit/session-tree.test.ts index 2551bc8..a530cbc 100644 --- a/tests/unit/session-tree.test.ts +++ b/tests/unit/session-tree.test.ts @@ -8,6 +8,7 @@ import { loadArchive, parseArchive, selectedPath, + runtimeMatchesSelection, } from '../../src/main/session-tree'; const message = (id: string, parentId: string | null, text = id) => ({ @@ -170,4 +171,38 @@ describe('Pi archive branch selection', () => { ); expect(await fs.readdir(root)).toEqual(['link.jsonl']); }); + it('allows only linear model/thinking startup entries after a selection', () => { + const setup = { + type: 'thinking_level_change', + id: 'setup', + parentId: 'old', + }; + expect(runtimeMatchesSelection('old', { entries: [], leafId: 'old' })).toBe( + true, + ); + expect( + runtimeMatchesSelection('old', { entries: [setup], leafId: 'setup' }), + ).toBe(true); + expect( + runtimeMatchesSelection(null, { + entries: [{ ...setup, parentId: null }], + leafId: 'setup', + }), + ).toBe(true); + for (const entry of [ + { ...setup, type: 'message' }, + { ...setup, type: 'custom' }, + { ...setup, parentId: 'unrelated' }, + ]) + expect( + runtimeMatchesSelection('old', { entries: [entry], leafId: 'setup' }), + ).toBe(false); + expect( + runtimeMatchesSelection('old', { entries: [], leafId: 'unrelated' }), + ).toBe(false); + expect(runtimeMatchesSelection(null, { entries: [], leafId: null })).toBe( + true, + ); + expect(runtimeMatchesSelection('old', { leafId: 'old' })).toBe(false); + }); }); From 4ae2149766647d212a8f5ad9a2f05b00d4e572a5 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:55:08 +0900 Subject: [PATCH 3/5] test: capture and upload session branch navigation proof Co-authored-by: OpenAI Codex --- .github/workflows/e2e.yml | 2 ++ tests/e2e/session-tree.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c7702d0..53cc6b4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,12 +10,14 @@ jobs: runs-on: macos-latest env: NODE_ENV: development + PIDEX_BRANCH_PROOF_DIR: test-results/branch-proof steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1 with: install: true - run: bun install --frozen-lockfile + - run: bunx playwright-core install ffmpeg - run: mise run test:e2e - name: upload test artefacts if: always() diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts index 6122f5c..094a01a 100644 --- a/tests/e2e/session-tree.test.ts +++ b/tests/e2e/session-tree.test.ts @@ -15,6 +15,8 @@ let fixture: string; let source: string; let log: string; const errors: string[] = []; +const proofDir = process.env.PIDEX_BRANCH_PROOF_DIR; +let proofVideo: ReturnType; const message = ( id: string, parentId: string | null, @@ -97,6 +99,12 @@ describe('session branches in Electron', () => { executablePath: require('electron'), args: ['.'], cwd: ROOT, + recordVideo: proofDir + ? { + dir: path.join(fixture, 'video'), + size: { width: 1280, height: 840 }, + } + : undefined, env: { ...process.env, PIDEX_E2E_CWD: project, @@ -108,6 +116,8 @@ describe('session branches in Electron', () => { }, }); page = await app.firstWindow(); + proofVideo = page.video(); + if (proofDir) await fs.mkdir(proofDir, { recursive: true }); page.setDefaultTimeout(15000); page.on('pageerror', (error) => errors.push(error.message)); await page @@ -116,6 +126,10 @@ describe('session branches in Electron', () => { }); afterAll(async () => { await app?.close(); + if (proofDir && proofVideo) + await proofVideo.saveAs( + path.join(proofDir, 'session-tree-navigation.webm'), + ); if (fixture) await fs.rm(fixture, { recursive: true, force: true }); }); @@ -142,6 +156,10 @@ describe('session branches in Electron', () => { .getByRole('treeitem', { name: /Streaming branch is ready/ }) .getAttribute('aria-selected'), ).toBe('true'); + if (proofDir) + await page.screenshot({ + path: path.join(proofDir, 'selected-branch.png'), + }); // Select an ancestor, not just a leaf. Preview and fork must stop here. await page .getByRole('treeitem', { name: /assistant: The original branch/ }) @@ -165,6 +183,10 @@ describe('session branches in Electron', () => { exact: true, }) .waitFor(); + if (proofDir) + await page.screenshot({ + path: path.join(proofDir, 'selected-entry-fork.png'), + }); const stateRequest = (await readLog()) .filter((c) => c.type === 'get_state' && c.sessionFile) .at(-1); From 3a16be3df521e84d99cfba4d03ee47a3a92b04e1 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:13:12 +0900 Subject: [PATCH 4/5] fix: reuse archive owners and keep session navigation accessible Co-authored-by: OpenAI Codex --- docs/adr/0013-session-branch-selection.md | 2 +- src/main/index.ts | 108 ++++++++++++++-------- src/main/rpc.ts | 25 ++++- src/renderer/useWorkbench.ts | 30 +++--- src/renderer/workbench.css | 6 +- tests/e2e/session-tree.test.ts | 59 ++++++++++++ tests/fixtures/pi-rpc.cjs | 6 +- 7 files changed, 177 insertions(+), 59 deletions(-) diff --git a/docs/adr/0013-session-branch-selection.md b/docs/adr/0013-session-branch-selection.md index e41a366..471a168 100644 --- a/docs/adr/0013-session-branch-selection.md +++ b/docs/adr/0013-session-branch-selection.md @@ -2,5 +2,5 @@ - **Status**: Proposed - **Context**: A Pi v3 JSONL archive is an append-only tree. Entries of every type have `id` and `parentId`; the session header is not a tree node. Reloading an archive selects the last non-header entry. Fork archives link to their source with `parentSession`. Inspection of installed Pi 1.5.0 (`docs/rpc-commands.md`, `dist/modes/rpc/rpc-mode.js`, `dist/core/session-manager.js`) confirmed that RPC exposes `get_tree`, `fork`, and `clone`, but does not expose the SDK's `navigateTree`. `fork` branches before a previous user message, while `clone` duplicates the currently active path. Neither selects an arbitrary archived entry. -- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. All forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Pi can append model/thinking settings during startup, so accept only a verified linear chain of those setup entries after the selection; reject other movement. Starting Pi against the source before calling `clone` can therefore modify source bytes. Extracting before startup preserves the source for current-tip forks too. Archive extraction does not invoke Pi extension fork hooks. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives. +- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. All forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Pi can append model/thinking settings during startup, so accept only a verified linear chain of those setup entries after the selection; reject other movement. Starting Pi against the source before calling `clone` can therefore modify source bytes. Extracting before startup preserves the source for current-tip forks too. Archive extraction does not invoke Pi extension fork hooks. Enforce one live runtime per archive in the main process; refresh runtime state and validate explicit history selections before reusing its existing tab. Running or mismatched selections fail without starting a competing writer. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives. - **Consequences**: Historical continuation creates a separate session rather than moving the original session's in-memory leaf. Old archive versions remain previewable where their lineage is readable; continuation requires intact v3 input and tells the user to migrate older files in Pi. Missing parents, cycles, duplicate IDs, malformed records and stale selections fail closed for affected operations. Archive browsing has a 16 MiB bound. Preview shows stored conversation history along the selected parent chain, including pre-compaction messages; Pi constructs its own compacted runtime context when it opens the resulting archive. Cross-folder lineage migration and multi-root workflows remain follow-ups. A future documented navigation RPC can replace extraction after its exact-entry semantics are verified. diff --git a/src/main/index.ts b/src/main/index.ts index 26d885f..8d2d659 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -126,6 +126,7 @@ ipcMain.handle( return { error: 'workspace path does not exist' }; } let expectedEntryId: string | null | undefined; + let extractedFork = false; if (sessionFile) { try { sessionFile = await sessionPath(sessionFile); @@ -146,6 +147,7 @@ ipcMain.handle( throw new Error('This session has no entry to fork.'); if (selected && (req.fork || selected !== archive.entries.at(-1)?.id)) { sessionFile = await forkArchive(sessionFile, selected); + extractedFork = true; } } catch (error) { return { @@ -155,6 +157,66 @@ ipcMain.handle( } } + // Archive ownership is authoritative here, rather than inferred from a + // renderer snapshot that becomes stale as soon as a prompt advances Pi. + async function readRuntime(runtime: RpcSession, reused = false) { + const state = await runtime.getState(); + if (!state.success || runtime.launchError()) + return { + error: + state.error ?? + runtime.launchError() ?? + 'Could not read Pi session state.', + }; + if (sessionFile && runtime.archiveFile !== sessionFile) + return { + error: + 'The open runtime switched archives. Select this archive again.', + }; + if ( + reused && + (state.data as { isStreaming?: boolean } | undefined)?.isStreaming + ) + return { + error: + 'This archive is running in an open tab. Stop it before resuming, or fork this selection.', + }; + if (expectedEntryId !== undefined) { + const position = await runtime.command({ + type: 'get_entries', + ...(expectedEntryId ? { since: expectedEntryId } : {}), + }); + if ( + !position.success || + !runtimeMatchesSelection(expectedEntryId, position.data) + ) + return { + error: + position.error ?? + 'The runtime branch moved since selection. Refresh history and select the branch again, or fork this entry.', + }; + } + const messages = await runtime.getMessages(); + if (!messages.success) + return { error: messages.error ?? 'Could not read Pi history.' }; + return { + sessionId: runtime.id, + state: state.data, + messages: + (messages.data as { messages?: unknown[] } | undefined)?.messages ?? + [], + }; + } + if (sessionFile && !extractedFork) { + for (const runtime of sessions.values()) { + // Extension commands can switch a runtime's archive. Refresh its real + // state before deciding whether this file already has a live owner. + const state = await runtime.getState(); + if (state.success && runtime.archiveFile === sessionFile) + return readRuntime(runtime, true); + } + } + const id = `s${++sessionSeq}`; let session: RpcSession; try { @@ -165,8 +227,10 @@ ipcMain.handle( } session.onEvent = (sessionId, event) => sendToRenderer('pidex:session:event', { sessionId, event }); - session.onExit = (sessionId) => + session.onExit = (sessionId) => { + sessions.delete(sessionId); sendToRenderer('pidex:session:exit', { sessionId }); + }; sessions.set(id, session); if (!sessionFile) { @@ -196,48 +260,12 @@ ipcMain.handle( } } - if (expectedEntryId !== undefined) { - const position = await session.command({ - type: 'get_entries', - ...(expectedEntryId ? { since: expectedEntryId } : {}), - }); - if ( - !position.success || - !runtimeMatchesSelection(expectedEntryId, position.data) - ) { - sessions.delete(id); - session.dispose(); - return { - error: - position.error ?? - 'The runtime branch moved since selection. Refresh history and select the branch again.', - }; - } - } - - const [state, messages] = await Promise.all([ - session.getState(), - session.getMessages(), - ]); - const launchError = session.launchError(); - if (launchError || !state.success || !messages.success) { + const result = await readRuntime(session); + if ('error' in result) { sessions.delete(id); session.dispose(); - return { - error: - launchError ?? - state.error ?? - messages.error ?? - 'Could not read Pi session state.', - }; } - return { - sessionId: id, - state: state.success ? state.data : null, - messages: messages.success - ? ((messages.data as { messages?: unknown[] })?.messages ?? []) - : [], - }; + return result; }, ); diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 975dcec..0c44c97 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -71,6 +71,7 @@ function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { class RpcSession { readonly id: string; readonly cwd: string; + archiveFile?: string; private proc: ChildProcess; private buffer = ''; private seq = 0; @@ -91,6 +92,7 @@ class RpcSession { ) { this.id = id; this.cwd = cwd; + this.archiveFile = sessionFile; const bin = resolvePi(); if (!bin) throw new Error(PI_MISSING); const args = ['--mode', 'rpc']; @@ -204,8 +206,27 @@ class RpcSession { return this.closed ? this.failed : null; } - getState() { - return this.command({ type: 'get_state' }); + async getState() { + const response = await this.command({ type: 'get_state' }); + const file = (response.data as { sessionFile?: unknown } | undefined) + ?.sessionFile; + if (response.success && typeof file === 'string') { + try { + this.archiveFile = fs.realpathSync(file); + } catch { + try { + this.archiveFile = path.join( + fs.realpathSync(path.dirname(file)), + path.basename(file), + ); + } catch { + this.archiveFile = path.resolve(file); + } + } + } + if (response.success && typeof file !== 'string') + this.archiveFile = undefined; + return response; } getMessages() { diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index e382f2e..e9b5b21 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -16,7 +16,6 @@ export type View = type SessionTab = { runtimeId: string; file?: string; - entryId?: string | null; startedAt?: string; cwd: string; title: string; @@ -258,14 +257,8 @@ export function useWorkbench() { const existing = !fork && info && - tabs.find( - (t) => - t.cwd === ws.cwd && - t.file === info.file && - t.entryId === entryId && - !t.exited, - ); - if (existing) { + tabs.find((t) => t.cwd === ws.cwd && t.file === info.file && !t.exited); + if (existing && entryId === undefined) { setActiveId(existing.runtimeId); return existing; } @@ -283,7 +276,7 @@ export function useWorkbench() { throw new Error(result.error ?? 'Could not open this session.'); const id = result.sessionId; if (currentCwd.current !== ws.cwd) { - await api.closeSession(id); + if (!tabs.some((t) => t.runtimeId === id)) await api.closeSession(id); return null; } // Mount before loading commands so live events cannot be lost while that request runs. @@ -291,7 +284,6 @@ export function useWorkbench() { runtimeId: id, cwd: ws.cwd, file: result.state?.sessionFile ?? info?.file, - entryId, startedAt: entryId ?? undefined, title: info ? `${info.title}${fork || (result.state?.sessionFile && result.state.sessionFile !== info.file) ? ' (fork)' : ''}` @@ -302,7 +294,20 @@ export function useWorkbench() { running: Boolean(result.state?.isStreaming), exited: exitedSessions.current.has(id), }; - setTabs((prev) => [...prev, tab]); + setTabs((prev) => + prev.some((t) => t.runtimeId === id) + ? prev.map((t) => + t.runtimeId === id + ? { + ...t, + running: tab.running, + model: tab.model, + exited: tab.exited, + } + : t, + ) + : [...prev, tab], + ); setActiveId(id); void api .sessionCommands(id) @@ -400,7 +405,6 @@ export function useWorkbench() { title: t.title === 'New session' ? text.slice(0, 90) : t.title, running: true, - entryId: undefined, entries: [...t.entries, { key, kind: 'user', text }], } : t, diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 1b752b8..6648992 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -790,8 +790,9 @@ html[data-reduced-motion='true'] .wb-shell *::after { color: var(--copy); } .wb-history-list { - flex: 1; - min-height: 0; + flex: 0 0 auto; + min-height: 100px; + max-height: 30vh; overflow-y: auto; } .wb-day-label { @@ -1460,6 +1461,7 @@ html[data-reduced-motion='true'] .wb-shell *::after { } .wb-entry-tree { + flex-shrink: 0; border-top: 1px solid var(--line, #30343a); margin-top: 12px; padding-top: 12px; diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts index 094a01a..861bd51 100644 --- a/tests/e2e/session-tree.test.ts +++ b/tests/e2e/session-tree.test.ts @@ -119,6 +119,7 @@ describe('session branches in Electron', () => { proofVideo = page.video(); if (proofDir) await fs.mkdir(proofDir, { recursive: true }); page.setDefaultTimeout(15000); + await page.setViewportSize({ width: 1100, height: 700 }); page.on('pageerror', (error) => errors.push(error.message)); await page .getByRole('heading', { name: 'Start a session', exact: true }) @@ -272,6 +273,64 @@ describe('session branches in Electron', () => { expect(resumed).toContain('old-tip'); expect(errors).toEqual([]); }); + it('reuses the live archive owner after sending and reselecting its latest tip', async () => { + const canonical = await fs.realpath(source); + const previous = (await readLog()) + .filter((c) => c.type === 'prompt' && c.sessionFile === canonical) + .at(-1); + const tabsBefore = await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page + .locator('.wb-transcript') + .getByText('Continue latest tip', { exact: true }) + .waitFor(); + await page.getByLabel('Message pi').fill('Resume this live branch again'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: 'Explore parser branches', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + const records = (await fs.readFile(source, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + const blocked = await page.evaluate( + async ({ cwd, source, entryId }) => + window.pidex.openSession({ cwd, sessionFile: source, entryId }), + { + cwd: records[0].cwd as string, + source, + entryId: records.at(-1).id as string, + }, + ); + expect(blocked.error).toContain('running in an open tab'); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const prompts = (await readLog()).filter( + (c) => c.type === 'prompt' && c.sessionFile === canonical, + ); + expect(prompts.at(-1).runtimePid).toBe(previous.runtimePid); + expect(new Set(prompts.map((c) => c.runtimePid)).size).toBe(1); + expect( + await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(), + ).toBe(tabsBefore); + expect(await fs.readFile(source, 'utf8')).toContain( + 'Resume this live branch again', + ); + expect(errors).toEqual([]); + }); + it('handles empty sessions and rejects stale entry selections without opening an unrelated leaf', async () => { const original = await fs.readFile(source, 'utf8'); const cwd = JSON.parse(original.split('\n')[0]).cwd as string; diff --git a/tests/fixtures/pi-rpc.cjs b/tests/fixtures/pi-rpc.cjs index 1f10608..34955e8 100755 --- a/tests/fixtures/pi-rpc.cjs +++ b/tests/fixtures/pi-rpc.cjs @@ -65,7 +65,11 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { if (process.env.PIDEX_RPC_LOG) fs.appendFileSync( process.env.PIDEX_RPC_LOG, - JSON.stringify({ ...command, sessionFile: file }) + '\n', + JSON.stringify({ + ...command, + sessionFile: file, + runtimePid: process.pid, + }) + '\n', ); switch (command.type) { case 'get_state': From 3bce2eab0a0c738e589254fdc336e8679c11bacf Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:24:32 +0900 Subject: [PATCH 5/5] fix: guard uncertain owners and refresh switched archive tabs Co-authored-by: OpenAI Codex --- src/main/index.ts | 8 +- src/main/rpc.ts | 15 ++++ src/renderer/useWorkbench.ts | 14 ++-- tests/e2e/session-tree.test.ts | 130 +++++++++++++++++++++++++++++++++ tests/fixtures/pi-rpc.cjs | 28 ++++++- 5 files changed, 187 insertions(+), 8 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8d2d659..20c9d9e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -212,7 +212,13 @@ ipcMain.handle( // Extension commands can switch a runtime's archive. Refresh its real // state before deciding whether this file already has a live owner. const state = await runtime.getState(); - if (state.success && runtime.archiveFile === sessionFile) + if (!state.success) { + if (runtime.launchError()) continue; + return { + error: `Could not verify archive ownership. Close the unresponsive session tab before resuming. ${state.error ?? ''}`, + }; + } + if (runtime.archiveFile === sessionFile) return readRuntime(runtime, true); } } diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 0c44c97..46393ad 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -210,6 +210,16 @@ class RpcSession { const response = await this.command({ type: 'get_state' }); const file = (response.data as { sessionFile?: unknown } | undefined) ?.sessionFile; + if ( + response.success && + this.archiveFile && + (typeof file !== 'string' || !file) + ) + return { + ...response, + success: false, + error: 'Pi did not return its active archive path.', + }; if (response.success && typeof file === 'string') { try { this.archiveFile = fs.realpathSync(file); @@ -224,6 +234,11 @@ class RpcSession { } } } + if (response.success && typeof file === 'string') + response.data = { + ...(response.data as Record), + sessionFile: this.archiveFile, + }; if (response.success && typeof file !== 'string') this.archiveFile = undefined; return response; diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index e9b5b21..4c65420 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -298,12 +298,14 @@ export function useWorkbench() { prev.some((t) => t.runtimeId === id) ? prev.map((t) => t.runtimeId === id - ? { - ...t, - running: tab.running, - model: tab.model, - exited: tab.exited, - } + ? t.file !== tab.file + ? tab + : { + ...t, + running: tab.running, + model: tab.model, + exited: tab.exited, + } : t, ) : [...prev, tab], diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts index 861bd51..702bc36 100644 --- a/tests/e2e/session-tree.test.ts +++ b/tests/e2e/session-tree.test.ts @@ -13,6 +13,7 @@ let app: ElectronApplication; let page: Page; let fixture: string; let source: string; +let switched: string; let log: string; const errors: string[] = []; const proofDir = process.env.PIDEX_BRANCH_PROOF_DIR; @@ -95,6 +96,28 @@ describe('session branches in Electron', () => { timestamp: '2026-11-04T01:00:00Z', }) + '\n', ); + switched = path.join(archives, 'switched.jsonl'); + await fs.writeFile( + switched, + [ + { + type: 'session', + version: 3, + id: 'switched', + cwd: project, + timestamp: '2026-11-03T01:00:00Z', + }, + message('b-user', null, 'user', 'Alternate archive after switch'), + message( + 'b-tip', + 'b-user', + 'assistant', + 'Only the alternate archive history belongs here.', + ), + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n', + ); app = await electron.launch({ executablePath: require('electron'), args: ['.'], @@ -113,6 +136,7 @@ describe('session branches in Electron', () => { PIDEX_SESSIONS_ROOT: path.join(fixture, 'sessions'), PIDEX_PI: path.join(ROOT, 'tests/fixtures/pi-rpc.cjs'), PIDEX_RPC_LOG: log, + PIDEX_RPC_SWITCH_FILE: switched, }, }); page = await app.firstWindow(); @@ -378,4 +402,110 @@ describe('session branches in Electron', () => { ).toBe(await fs.realpath(path.join(path.dirname(source), 'empty.jsonl'))); expect(errors).toEqual([]); }); + it('refreshes identity and history when an existing runtime switched archives', async () => { + const canonicalSource = await fs.realpath(source); + const sourcePid = (await readLog()) + .filter((c) => c.type === 'prompt' && c.sessionFile === canonicalSource) + .at(-1)?.runtimePid; + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page.getByLabel('Message pi').fill('Switch archive for test'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: 'Explore parser branches', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Send', exact: true }).waitFor(); + const tabsBefore = await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem', { name: /Alternate archive after switch/ }) + .click(); + await page + .locator('.wb-transcript') + .getByText('Only the alternate archive history belongs here.') + .waitFor(); + await page.getByLabel('Message pi').fill('Continue the switched archive'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { + name: 'Alternate archive after switch', + exact: true, + }) + .waitFor(); + await page + .locator('.wb-transcript') + .getByText('Only the alternate archive history belongs here.') + .waitFor(); + expect( + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .count(), + ).toBe(0); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const prompt = (await readLog()) + .filter( + (c) => + c.type === 'prompt' && c.message === 'Continue the switched archive', + ) + .at(-1); + expect(prompt.runtimePid).toBe(sourcePid); + expect(prompt.sessionFile).toBe(await fs.realpath(switched)); + expect( + await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(), + ).toBe(tabsBefore); + expect(errors).toEqual([]); + }); + + it('fails closed on an unresponsive owner without launching a competing writer', async () => { + await page.getByLabel('Message pi').fill('Simulate owner state failure'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const before = await fs.readFile(switched, 'utf8'); + const canonical = await fs.realpath(switched); + const ownerPid = (await readLog()) + .filter((c) => c.type === 'prompt' && c.sessionFile === canonical) + .at(-1).runtimePid; + const promptCount = (await readLog()).filter( + (c) => c.type === 'prompt', + ).length; + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem', { name: /Alternate archive after switch/ }) + .click(); + await page + .getByLabel('Message pi') + .fill('This must not start a second writer'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('alert') + .getByText(/Could not verify archive ownership/) + .waitFor(); + expect(await fs.readFile(switched, 'utf8')).toBe(before); + const records = await readLog(); + expect(records.filter((c) => c.type === 'prompt')).toHaveLength( + promptCount, + ); + expect( + new Set( + records + .filter((c) => c.sessionFile === canonical) + .map((c) => c.runtimePid), + ), + ).toEqual(new Set([ownerPid])); + expect(errors).toEqual([]); + }); }); diff --git a/tests/fixtures/pi-rpc.cjs b/tests/fixtures/pi-rpc.cjs index 34955e8..89e3866 100755 --- a/tests/fixtures/pi-rpc.cjs +++ b/tests/fixtures/pi-rpc.cjs @@ -44,6 +44,7 @@ if (file) { let messages = branchMessages(); let promptSeq = 0; let streaming = false; +let failState = false; let model = { id: 'claude-sonnet', name: 'Claude Sonnet', @@ -73,7 +74,15 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { ); switch (command.type) { case 'get_state': - reply({ model, isStreaming: streaming, sessionFile: file }); + if (failState) + output({ + type: 'response', + id: command.id, + command: command.type, + success: false, + error: 'Fixture state refresh failure.', + }); + else reply({ model, isStreaming: streaming, sessionFile: file }); break; case 'get_entries': { @@ -160,6 +169,23 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { reply(); break; case 'prompt': { + if ( + command.message === 'Switch archive for test' && + process.env.PIDEX_RPC_SWITCH_FILE + ) { + file = fs.realpathSync(process.env.PIDEX_RPC_SWITCH_FILE); + records = fs + .readFileSync(file, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + messages = branchMessages(); + reply(); + event('agent_end'); + break; + } + if (command.message === 'Simulate owner state failure') failState = true; if (command.message === 'reject this prompt') { output({ type: 'response',