diff --git a/README.md b/README.md index 26a9793..de58bbd 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,15 @@ These are captures of real Electron interactions with a temporary Git workspace 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. +Saved history includes a collapsible summary of the selected path, with the latest request and reply, message counts and tool outcomes. Unfinished tool calls carry a reminder to review the workspace before retrying. Selecting another branch or an earlier entry updates the summary. + Use **Create workspace** or **Edit workspace** in the sidebar to name a group and select its roots. **Add root** chooses an existing folder. Each session runs in one root; the composer shows its path. Switching roots leaves open sessions running and makes them available under **Open sessions**. Files, Changes, History and defaults remain scoped to the selected root. A missing folder retains readable history, but cannot start or resume Pi until its original path is available. Adding another folder does not relocate a session. Group membership does not change Pi's tool permissions or load sibling roots into its context. Existing `workspaces.json` and `session-defaults.json` records migrate to `workspace-registry.json` in Electron userData. Original files and `.bak` copies remain intact. Workspace groups and selection persist; live processes and drafts do not restart after app quit. A package folder shares its Git worktree's index: pidex refuses its commit when staged changes exist outside that folder. Open the repository top level to review the whole index. 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. -Automatic repository/worktree grouping, group-wide history, recovery summaries, and folder-to-folder context transfer remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). +Automatic repository/worktree grouping, group-wide history and folder-to-folder context transfer remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). ## Install diff --git a/docs/pidex-architecture.md b/docs/pidex-architecture.md index 2aca5f6..b8bed69 100644 --- a/docs/pidex-architecture.md +++ b/docs/pidex-architecture.md @@ -71,7 +71,7 @@ Workspace helpers validate known workspaces and file containment. Realpath check Pi session archives live under `~/.pi/agent/sessions`. pidex discovers workspace paths from session headers and merges manually added folders. Stable UUID roots, named groups, per-root defaults and selected group/root live in Electron userData as `workspace-registry.json`. The first load migrates `workspaces.json` and `session-defaults.json`, retaining originals and `.bak` copies. Writes use a temporary file and atomic rename; invalid/unknown schema data fails closed. Synchronous main-process transactions serialize mutations, and group edits compare prior membership before saving. Available aliases deduplicate by realpath; missing paths remain as roots and preserve history. Independent nested folders remain separate roots. Initially missing paths are canonicalized when they become available, retaining their root IDs and original path aliases. Resolved paths stay pinned; retargeting one does not relocate its sessions. Each archive is indexed by its own cwd across all supported store subdirectories, with stat-based metadata caching. Native session ID and canonical archive locator remain separate; durable pidex archive handles are deferred. -History preview follows the archive's current parent chain so abandoned branches are not mixed into the displayed conversation. Archive metadata scans read a bounded prefix, so displayed message counts and model metadata can be incomplete for large sessions. The full active branch is read separately for preview. +History preview follows the selected archive parent chain so abandoned branches are not mixed into the displayed conversation. A collapsible recovery summary derives counts and bounded request/reply excerpts from those same entries, using the tool-card outcome rules shared with Markdown export. It describes saved history and does not infer completion or current filesystem state. Archive metadata scans read a bounded prefix, so displayed message counts and model metadata can be incomplete for large sessions. The full active branch is read separately for preview. Workspace inspection reads the file tree and Git branch/status. It refreshes on focus, view/runtime-state changes, and every 15 seconds. There is no filesystem watcher. File discovery skips common dependency/build directories and symlinks and is capped at 3,000 files and seven directory levels. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 19db852..3de83d6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import { WorkspaceNavigation } from './components/WorkspaceNavigation'; import { SessionTree, EntryTree } from './components/SessionTree'; +import { HistorySummary } from './components/HistorySummary'; import { useEffect, useState } from 'react'; import { PidexMark } from './components/PidexMark'; import welcomeMark from './assets/workbench/welcome-mark.svg'; @@ -595,7 +596,7 @@ function App() { /> )} -
+
{preview ? ( <>
@@ -636,6 +637,11 @@ function App() { and preserves the source.

)} + {preview.loaded && + !b.previewLoading && + (preview.ready || preview.entries.length > 0) && ( + + )} sessionSummary(entries), [entries]); + const { messages, tools } = summary; + return ( +
+ + Branch summary + + {messages} {messages === 1 ? 'message' : 'messages'} ·{' '} + {tools.total} {tools.total === 1 ? 'tool call' : 'tool calls'} + + +
+ {entries.length === 0 ? ( +

No messages in this selection.

+ ) : ( + <> +
+
+
Latest request
+
+ {summary.latestRequest || 'No request text in this selection.'} +
+
+
+
Latest reply
+
+ {summary.latestReply || 'No reply text in this selection.'} +
+
+
+ {tools.total > 0 && ( +

+ {tools.done} done · {tools.failed} failed · {tools.running} running ·{' '} + {tools.interrupted} interrupted +

+ )} + {(tools.interrupted > 0 || tools.running > 0) && ( +

+ Some tool calls have no completion result. They may have made + changes. Review their output and the workspace before retrying. +

+ )} +

+ Excerpts from the selected path. Read the transcript for full context. +

+ + )} +
+
+ ); +} diff --git a/src/renderer/export-session.ts b/src/renderer/export-session.ts index 4e3084e..7aeb419 100644 --- a/src/renderer/export-session.ts +++ b/src/renderer/export-session.ts @@ -1,4 +1,5 @@ import { toolStatus, type Entry } from './timeline'; +import { sessionSummary } from './session-summary'; export type SessionExportInput = { title: string; @@ -43,27 +44,17 @@ function handoffSection(cwd: string): string { } function summarySection(entries: Entry[]): string { - const users = entries.filter((e) => e.kind === 'user').length; - const assistants = entries.filter((e) => e.kind === 'assistant').length; - const messages = users + assistants; - const tools = entries.filter( - (e): e is Extract => e.kind === 'tool', - ); + const { users, assistants, messages, tools, toolsUsed } = sessionSummary(entries); const lines = [ '## Summary', '', `- ${messages} ${messages === 1 ? 'message' : 'messages'}: ${users} from you, ${assistants} from Pi.`, ]; - if (tools.length > 0) { - const done = tools.filter((e) => toolStatus(e) === 'Done').length; - const failed = tools.filter((e) => toolStatus(e) === 'Failed').length; - const running = tools.filter((e) => toolStatus(e) === 'Running').length; - const interrupted = tools.filter( - (e) => toolStatus(e) === 'Interrupted', - ).length; + if (tools.total > 0) { + const { done, failed, running, interrupted } = tools; lines.push( - `- ${tools.length} ${tools.length === 1 ? 'tool call' : 'tool calls'}: ${done} done, ${failed} failed, ${running} running${interrupted ? `, ${interrupted} interrupted` : ''}.`, - `- Tools used: ${[...new Set(tools.map((e) => e.tool))].join(', ')}.`, + `- ${tools.total} ${tools.total === 1 ? 'tool call' : 'tool calls'}: ${done} done, ${failed} failed, ${running} running${interrupted ? `, ${interrupted} interrupted` : ''}.`, + `- Tools used: ${toolsUsed.join(', ')}.`, ); } return lines.join('\n'); diff --git a/src/renderer/session-summary.ts b/src/renderer/session-summary.ts new file mode 100644 index 0000000..3bfaa8e --- /dev/null +++ b/src/renderer/session-summary.ts @@ -0,0 +1,54 @@ +import { toolStatus, type Entry } from './timeline'; + +const EXCERPT_LIMIT = 240; + +function excerpt(text: string): string { + const plain = text.replace(/\s+/g, ' ').trim(); + return plain.length > EXCERPT_LIMIT + ? `${plain.slice(0, EXCERPT_LIMIT - 3)}...` + : plain; +} + +export function sessionSummary(entries: Entry[]) { + let users = 0; + let assistants = 0; + let latestRequest = ''; + let latestReply = ''; + const tools = { total: 0, done: 0, failed: 0, running: 0, interrupted: 0 }; + const toolsUsed = new Set(); + for (const entry of entries) { + if (entry.kind === 'user') { + users++; + if (entry.text.trim()) latestRequest = excerpt(entry.text); + } else if (entry.kind === 'assistant') { + assistants++; + if (entry.text.trim()) latestReply = excerpt(entry.text); + } else { + tools.total++; + toolsUsed.add(entry.tool); + switch (toolStatus(entry)) { + case 'Done': + tools.done++; + break; + case 'Failed': + tools.failed++; + break; + case 'Running': + tools.running++; + break; + case 'Interrupted': + tools.interrupted++; + break; + } + } + } + return { + messages: users + assistants, + users, + assistants, + tools, + toolsUsed: [...toolsUsed], + latestRequest, + latestReply, + }; +} diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 7c65016..0e71922 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -1013,6 +1013,45 @@ html[data-reduced-motion='true'] .wb-shell *::after { border-bottom: 1px solid var(--line-soft); overflow-wrap: anywhere; } +.wb-history-summary { + flex-shrink: 0; + margin: 0; + border: 1px solid var(--line-soft); + border-radius: 4px; + font-size: 12px; +} +.wb-history-summary > summary { + cursor: pointer; + padding: 6px 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.wb-history-summary > summary .wb-meta { + margin-left: 12px; +} +.wb-history-summary-body { + max-height: 24vh; + overflow-y: auto; + padding: 0 12px 12px; + overflow-wrap: anywhere; +} +.wb-history-summary-body dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin: 0; +} +.wb-history-summary-body dt { + color: var(--muted); + margin-bottom: 4px; +} +.wb-history-summary-body dd { + margin: 0; +} +.wb-history-summary-body p { + margin: 8px 0 0; +} .wb-code-preview { min-height: 0; flex: 1; @@ -1731,3 +1770,11 @@ html[data-reduced-motion='true'] .wb-shell *::after { left: 0; right: 0; } + +@media (max-height: 700px) { + .wb-history-conversation { + padding-top: 12px; + padding-bottom: 12px; + gap: 8px; + } +} diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts index 702bc36..8554a46 100644 --- a/tests/e2e/session-tree.test.ts +++ b/tests/e2e/session-tree.test.ts @@ -17,6 +17,7 @@ let switched: string; let log: string; const errors: string[] = []; const proofDir = process.env.PIDEX_BRANCH_PROOF_DIR; +const summaryShots = path.join(ROOT, 'test-results/history-summary'); let proofVideo: ReturnType; const message = ( id: string, @@ -97,6 +98,31 @@ describe('session branches in Electron', () => { }) + '\n', ); switched = path.join(archives, 'switched.jsonl'); + await fs.writeFile( + path.join(archives, 'recovery.jsonl'), + [ + { type: 'session', version: 3, id: 'recovery', cwd: project, timestamp: '2026-11-02T01:00:00Z' }, + message('r-task', null, 'user', 'Recover interrupted build'), + { + type: 'message', id: 'r-call', parentId: 'r-task', + message: { role: 'assistant', content: [ + { type: 'text', text: 'Checking the build before updating the parser.' }, + { type: 'toolCall', id: 'build', name: 'bash', arguments: { command: 'bun run build' } }, + ] }, + }, + { + type: 'message', id: 'r-failed', parentId: 'r-call', + message: { role: 'toolResult', toolCallId: 'build', isError: true, + content: [{ type: 'text', text: 'Build failed. Missing parser export.' }] }, + }, + { + type: 'message', id: 'r-tip', parentId: 'r-failed', + message: { role: 'assistant', content: [ + { type: 'toolCall', id: 'edit', name: 'write', arguments: { path: 'parser.ts', content: 'export const parser = true;' } }, + ] }, + }, + ].map((record) => JSON.stringify(record)).join('\n') + '\n', + ); await fs.writeFile( switched, [ @@ -142,6 +168,7 @@ describe('session branches in Electron', () => { page = await app.firstWindow(); proofVideo = page.video(); if (proofDir) await fs.mkdir(proofDir, { recursive: true }); + await fs.mkdir(summaryShots, { recursive: true }); page.setDefaultTimeout(15000); await page.setViewportSize({ width: 1100, height: 700 }); page.on('pageerror', (error) => errors.push(error.message)); @@ -158,6 +185,47 @@ describe('session branches in Electron', () => { if (fixture) await fs.rm(fixture, { recursive: true, force: true }); }); + it('summarises unfinished history without launching Pi and follows branch selection', async () => { + await page.setViewportSize({ width: 1280, height: 840 }); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page.getByRole('treeitem', { name: /Recover interrupted build/ }).click(); + const summary = page.locator('.wb-history-summary'); + await summary.getByText('2 messages · 2 tool calls').waitFor(); + expect(await summary.getAttribute('open')).toBeNull(); + await summary.locator('summary').click(); + await summary.getByText('0 done · 1 failed · 0 running · 1 interrupted').waitFor(); + await summary.getByText(/They may have made changes/).waitFor(); + await page.screenshot({ path: path.join(summaryShots, 'unfinished-tools.png') }); + // Native disclosure keeps the transcript accessible on short windows. + await summary.locator('summary').focus(); + await page.keyboard.press('Enter'); + expect(await summary.getAttribute('open')).toBeNull(); + await page.keyboard.press('Enter'); + expect(await summary.getAttribute('open')).not.toBeNull(); + await page.getByRole('treeitem', { name: /Explore parser branches/ }).click(); + await summary.locator('summary').click(); + await summary.getByText('The latest branch uses a buffered parser.').waitFor(); + await page.getByLabel('Branch tip').selectOption('old-tip'); + await summary.locator('summary').click(); + await summary.getByText('4 messages · 0 tool calls').waitFor(); + await summary.getByText('Keep the streaming approach').waitFor(); + await summary.getByText('Streaming branch is ready.').waitFor(); + expect(await summary.textContent()).not.toContain('buffered parser'); + await page.screenshot({ path: path.join(summaryShots, 'selected-branch.png') }); + await page.getByRole('treeitem', { name: /assistant: The original branch/ }).click(); + await summary.locator('summary').click(); + await summary.getByText('2 messages · 0 tool calls').waitFor(); + await summary.getByText('Explore parser branches').waitFor(); + expect(await summary.textContent()).not.toContain('Keep the streaming approach'); + expect((await readLog()).filter((c) => c.type === 'get_state' || c.type === 'prompt')).toEqual([]); + await page.getByRole('treeitem', { name: /\(empty session\)/ }).click(); + await summary.locator('summary').click(); + await summary.getByText('No messages in this selection.').waitFor(); + await page.getByRole('treeitem', { name: /Explore parser branches/ }).click(); + await page.locator('.wb-transcript').getByText('The latest branch uses a buffered parser.').waitFor(); + await page.setViewportSize({ width: 1100, height: 700 }); + }); + 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(); diff --git a/tests/e2e/workspaces.test.ts b/tests/e2e/workspaces.test.ts index d1ea6c6..8220b87 100644 --- a/tests/e2e/workspaces.test.ts +++ b/tests/e2e/workspaces.test.ts @@ -522,11 +522,13 @@ describe('grouped roots in Electron', () => { .getByRole('button', { name: 'New session', exact: true }) .click(); await expect - .poll(async () => - page - .locator('.wb-sidebar .wb-session-item') - .filter({ hasText: 'this window' }) - .count(), + .poll( + async () => + page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(), + { timeout: 10_000 }, ) .toBe(1); expect( @@ -731,6 +733,7 @@ describe('grouped roots in Electron', () => { .getByRole('treeitem', { name: /History for offline/ }) .click(); await page + .locator('.wb-transcript') .getByText('Stored answer for offline', { exact: true }) .waitFor(); expect(await page.getByLabel('Message pi').isDisabled()).toBe(true); diff --git a/tests/integration/HistorySummary.test.tsx b/tests/integration/HistorySummary.test.tsx new file mode 100644 index 0000000..0281cc9 --- /dev/null +++ b/tests/integration/HistorySummary.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { HistorySummary } from '../../src/renderer/components/HistorySummary'; +import type { Entry } from '../../src/renderer/timeline'; + +afterEach(cleanup); + +describe('', () => { + it('updates the excerpts and counts when the selected path changes', () => { + const request: Entry = { key: 'u', kind: 'user', text: 'Fix the parser' }; + const { container, rerender, getByText } = render(); + expect(getByText('2 messages · 0 tool calls')).toBeTruthy(); + expect(getByText('Use the buffered parser')).toBeTruthy(); + expect(container.textContent).not.toContain('Hidden reasoning'); + rerender(); + expect(getByText('1 message · 0 tool calls')).toBeTruthy(); + expect(container.textContent).not.toContain('Use the buffered parser'); + expect(getByText('No reply text in this selection.')).toBeTruthy(); + }); + + it('warns about unknown tool effects without declaring the work complete', () => { + const { container, getByText } = render(); + expect(getByText('0 messages · 1 tool call')).toBeTruthy(); + expect(container.textContent).toContain('0 done · 0 failed · 0 running · 1 interrupted'); + expect(container.textContent).toContain('They may have made changes.'); + expect(getByText('No request text in this selection.')).toBeTruthy(); + }); + + it('shows an empty selection and escapes message markup', () => { + const { container, getByText, rerender } = render(); + expect(getByText('No messages in this selection.')).toBeTruthy(); + rerender(' }, + ]} />); + expect(getByText('')).toBeTruthy(); + expect(container.querySelector('img')).toBeNull(); + }); +}); diff --git a/tests/unit/session-summary.test.ts b/tests/unit/session-summary.test.ts new file mode 100644 index 0000000..2ab756a --- /dev/null +++ b/tests/unit/session-summary.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { sessionSummary } from '../../src/renderer/session-summary'; +import type { Entry } from '../../src/renderer/timeline'; + +const tool: Extract = { + key: 'tool', kind: 'tool', toolId: 'call', tool: 'bash', args: 'bun test', + input: 'bun test', output: '', running: false, ok: true, +}; + +describe('sessionSummary', () => { + it('counts each outcome using the same precedence as tool cards', () => { + const summary = sessionSummary([ + { key: 'u', kind: 'user', text: 'Fix the tests' }, + { key: 'a', kind: 'assistant', text: 'Checking.', thinking: '', streaming: false }, + tool, + { ...tool, key: 'failed', tool: 'read', ok: false }, + { ...tool, key: 'running', running: true, interrupted: true }, + { ...tool, key: 'interrupted', interrupted: true, ok: false }, + ]); + expect(summary.messages).toBe(2); + expect(summary.users).toBe(1); + expect(summary.assistants).toBe(1); + expect(summary.tools).toEqual({ total: 4, done: 1, failed: 1, running: 1, interrupted: 1 }); + expect(summary.toolsUsed).toEqual(['bash', 'read']); + }); + + it('excerpts the latest text without including thinking or tool output', () => { + const summary = sessionSummary([ + { key: 'u1', kind: 'user', text: 'Old request' }, + { key: 'u2', kind: 'user', text: '\n Latest\t request ' }, + { key: 'a', kind: 'assistant', text: `Latest reply ${'x'.repeat(300)}`, thinking: 'Private reasoning', streaming: false }, + { key: 'thinking', kind: 'assistant', text: '', thinking: 'Later reasoning', streaming: false }, + { ...tool, output: 'Tool result is not a reply' }, + ]); + expect(summary.latestRequest).toBe('Latest request'); + expect(summary.latestReply).toHaveLength(240); + expect(summary.latestReply).toBe(`Latest reply ${'x'.repeat(224)}...`); + }); + + it('handles empty and tool-only selections without inventing message text', () => { + expect(sessionSummary([])).toEqual({ + messages: 0, users: 0, assistants: 0, + tools: { total: 0, done: 0, failed: 0, running: 0, interrupted: 0 }, + toolsUsed: [], latestRequest: '', latestReply: '', + }); + const summary = sessionSummary([tool]); + expect(summary.messages).toBe(0); + expect(summary.tools.total).toBe(1); + expect(summary.latestRequest).toBe(''); + expect(summary.latestReply).toBe(''); + }); +});