Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/pidex-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -595,7 +596,7 @@ function App() {
/>
)}
</aside>
<section className="wb-conversation">
<section className="wb-conversation wb-history-conversation">
{preview ? (
<>
<div className="wb-history-heading">
Expand Down Expand Up @@ -636,6 +637,11 @@ function App() {
and preserves the source.
</p>
)}
{preview.loaded &&
!b.previewLoading &&
(preview.ready || preview.entries.length > 0) && (
<HistorySummary entries={preview.entries} />
)}
<Transcript
key={`${preview.info.file}:${preview.entryId}`}
entries={preview.entries}
Expand Down
56 changes: 56 additions & 0 deletions src/renderer/components/HistorySummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import type { Entry } from '../timeline';
import { sessionSummary } from '../session-summary';

export function HistorySummary({ entries }: { entries: Entry[] }) {
const summary = useMemo(() => sessionSummary(entries), [entries]);
const { messages, tools } = summary;
return (
<details className="wb-history-summary">
<summary>
Branch summary
<span className="wb-meta">
{messages} {messages === 1 ? 'message' : 'messages'} ·{' '}
{tools.total} {tools.total === 1 ? 'tool call' : 'tool calls'}
</span>
</summary>
<div className="wb-history-summary-body">
{entries.length === 0 ? (
<p>No messages in this selection.</p>
) : (
<>
<dl>
<div>
<dt>Latest request</dt>
<dd>
{summary.latestRequest || 'No request text in this selection.'}
</dd>
</div>
<div>
<dt>Latest reply</dt>
<dd>
{summary.latestReply || 'No reply text in this selection.'}
</dd>
</div>
</dl>
{tools.total > 0 && (
<p className="wb-meta">
{tools.done} done · {tools.failed} failed · {tools.running} running ·{' '}
{tools.interrupted} interrupted
</p>
)}
{(tools.interrupted > 0 || tools.running > 0) && (
<p className="wb-branch-notice">
Some tool calls have no completion result. They may have made
changes. Review their output and the workspace before retrying.
</p>
)}
<p className="wb-meta">
Excerpts from the selected path. Read the transcript for full context.
</p>
</>
)}
</div>
</details>
);
}
21 changes: 6 additions & 15 deletions src/renderer/export-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toolStatus, type Entry } from './timeline';
import { sessionSummary } from './session-summary';

export type SessionExportInput = {
title: string;
Expand Down Expand Up @@ -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<Entry, { kind: 'tool' }> => 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');
Expand Down
54 changes: 54 additions & 0 deletions src/renderer/session-summary.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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,
};
}
47 changes: 47 additions & 0 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
68 changes: 68 additions & 0 deletions tests/e2e/session-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page['video']>;
const message = (
id: string,
Expand Down Expand Up @@ -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,
[
Expand Down Expand Up @@ -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));
Expand All @@ -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();
Expand Down
13 changes: 8 additions & 5 deletions tests/e2e/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading