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 @@ -59,13 +59,15 @@ Saved history includes a collapsible summary of the selected path, with the late

Use **Copy context** in History to put the selected path's Markdown handoff on the clipboard, or **Export** to save it as a file. Both include the original root, model, messages, thinking and tool input/output. Open the destination root and paste the context into a new session there. This transfers context; saved history and Resume stay with the original root.

To prepare that handoff directly, select a history branch or entry and open **Continue in another root**. Choose a registered destination and **Prepare draft** to add the selected history to its new-session composer. Existing draft text is kept. Review and send it when ready. Files and session archives stay in their original root; this starts a separate session in the destination.

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 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 further recovery improvements remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues).

## Install

Expand Down
2 changes: 2 additions & 0 deletions docs/pidex-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Pi session archives live under `~/.pi/agent/sessions`. pidex discovers workspace

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.

History handoff uses the same selected entries as Markdown export and appends them to the new-session draft of another available, registered root. It switches the selected group/root through the existing navigation path and preserves the source preview and per-root drafts. Preparing a handoff performs no runtime open, resume or prompt action. The destination composer remains editable and uses its own model defaults. Sending starts a separate session there; no files or Pi archives are relocated.

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.

Per-root views, new-session/history drafts, unsaved settings drafts, open tabs, transcript selection, and expanded rows are in-memory UI state. Restoring an interrupted history load retries the saved archive/entry, while completed error states require explicit retry. Settings cache only dirty drafts; clean views follow saved defaults. Changes views remount per root and late mutation results are checked against their original navigation generation. Scroll position and expansion are not persisted when a transcript is reopened.
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { WorkspaceNavigation } from './components/WorkspaceNavigation';
import { SessionTree, EntryTree } from './components/SessionTree';
import { HistorySummary } from './components/HistorySummary';
import { HistoryExport } from './components/HistoryExport';
import { HistoryHandoff } from './components/HistoryHandoff';
import { useEffect, useState } from 'react';
import { PidexMark } from './components/PidexMark';
import welcomeMark from './assets/workbench/welcome-mark.svg';
Expand Down Expand Up @@ -628,7 +629,17 @@ function App() {
{preview.loaded &&
!b.previewLoading &&
(preview.ready || preview.entries.length > 0) && (
<HistorySummary entries={preview.entries} />
<div className="wb-history-recovery">
<HistorySummary entries={preview.entries} />
{preview.entries.length > 0 && (
<HistoryHandoff
key={`handoff:${preview.info.file}:${preview.entryId}`}
roots={b.workspaces}
sourceRootId={b.selected}
onPrepare={b.prepareHandoff}
/>
)}
</div>
)}
<Transcript
key={`${preview.info.file}:${preview.entryId}`}
Expand Down
68 changes: 68 additions & 0 deletions src/renderer/components/HistoryHandoff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { WorkspaceInfo } from '../api';
import { BracketButton } from './BracketButton';

export function HistoryHandoff({
roots,
sourceRootId,
onPrepare,
}: {
roots: WorkspaceInfo[];
sourceRootId: string;
onPrepare: (rootId: string) => void;
}) {
const [rootId, setRootId] = useState('');
const destinations = roots.filter((root) => root.dirName !== sourceRootId);
const destination = destinations.find((root) => root.dirName === rootId);
return (
<details className="wb-history-handoff">
<summary>Continue in another root</summary>
<form
className="wb-history-summary-body"
aria-label="Prepare context handoff"
onSubmit={(event) => {
event.preventDefault();
if (destination?.availability === 'available') onPrepare(rootId);
}}
>
<p>
Add the selected history to a new-session draft in another root.
Existing draft text is kept. Review it before sending.
</p>
<p className="wb-meta">
Files and saved sessions stay in the original root.
</p>
<label>
Destination root
<select
value={rootId}
onChange={(event) => setRootId(event.target.value)}
>
<option value="">Choose a root</option>
{destinations.map((root) => (
<option
key={root.dirName}
value={root.dirName}
disabled={root.availability !== 'available'}
>
{root.name} / {root.cwd}
{root.availability !== 'available' ? ' (unavailable)' : ''}
</option>
))}
</select>
</label>
{!destinations.some((root) => root.availability === 'available') && (
<p className="wb-meta">
Add an available root from the sidebar to prepare a handoff.
</p>
)}
<BracketButton
type="submit"
disabled={destination?.availability !== 'available'}
>
Prepare draft
</BracketButton>
</form>
</details>
);
}
32 changes: 32 additions & 0 deletions src/renderer/useWorkbench.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SessionTree } from '../main/session-tree';
import { useCallback, useEffect, useRef, useState } from 'react';
import { applyEvent, entriesFromMessages, type Entry } from './timeline';
import { sessionExportMarkdown } from './export-session';
import type {
Defaults,
ModelInfo,
Expand Down Expand Up @@ -523,6 +524,36 @@ export function useWorkbench() {
else if (!preview && sessions[0]) void showPreview(sessions[0]);
}, [view, sessions, preview, previewLoading, showPreview]);

const prepareHandoff = (rootId: string) => {
const destination = workspaces.find((root) => root.dirName === rootId);
if (
view !== 'history' ||
!preview?.loaded ||
previewLoading ||
!preview.entries.length ||
!destination ||
destination.availability !== 'available' ||
destination.dirName === selected ||
destination.cwd === preview.info.cwd
)
return;
const context = sessionExportMarkdown({
title: preview.info.title,
cwd: preview.info.cwd,
model: preview.info.model,
entries: preview.entries,
exportedAt: new Date(),
});
const key = `new:${destination.cwd}`;
setDrafts((prev) => ({
...prev,
[key]: prev[key] ? `${prev[key]}\n\n---\n\n${context}` : context,
}));
selectWorkspace(destination);
setActiveId(null);
navigate('new');
};

const send = (raw?: string) =>
run(async () => {
const text = (raw ?? draft).trim();
Expand Down Expand Up @@ -719,6 +750,7 @@ export function useWorkbench() {
showSession,
closeSession,
forkSession,
prepareHandoff,
useSkill,
};
}
30 changes: 28 additions & 2 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -1039,14 +1039,26 @@ html[data-reduced-motion='true'] .wb-shell *::after {
border-bottom: 1px solid var(--line-soft);
overflow-wrap: anywhere;
}
.wb-history-summary {
.wb-history-recovery {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
flex-shrink: 0;
}
.wb-history-recovery:has(> details[open]) {
grid-template-columns: minmax(0, 1fr);
}
.wb-history-summary,
.wb-history-handoff {
min-width: 0;
flex-shrink: 0;
margin: 0;
border: 1px solid var(--line-soft);
border-radius: 4px;
font-size: 12px;
}
.wb-history-summary > summary {
:is(.wb-history-summary, .wb-history-handoff) > summary {
cursor: pointer;
padding: 6px 10px;
white-space: nowrap;
Expand Down Expand Up @@ -1078,6 +1090,20 @@ html[data-reduced-motion='true'] .wb-shell *::after {
.wb-history-summary-body p {
margin: 8px 0 0;
}
.wb-history-handoff label {
display: grid;
gap: 4px;
margin: 10px 0;
}
.wb-history-handoff select {
width: 100%;
min-width: 0;
padding: 6px;
background: var(--panel);
color: var(--copy);
border: 1px solid var(--line-soft);
font: inherit;
}
.wb-code-preview {
min-height: 0;
flex: 1;
Expand Down
64 changes: 64 additions & 0 deletions tests/e2e/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,70 @@ describe('grouped roots in Electron', () => {
).toBe(0);
});

it('prepares ancestor context in another root without starting Pi and sends only after review', async () => {
const original = await fs.readFile(archive, 'utf8');
await page.setViewportSize({ width: 1280, height: 840 });
await root(web);
await page.getByRole('button', { name: 'New session', exact: true }).click();
await page.getByLabel('Message pi').fill('Keep the destination plan');
await root(api);
await page.getByRole('button', { name: /^Sessions/ }).click();
await page.getByRole('tree', { name: 'Session archives', exact: true })
.getByRole('treeitem', { name: /History for api/ }).click();
await page.locator('.wb-transcript').getByText('Stored answer for api', { exact: true }).waitFor();
await page.getByRole('treeitem', { name: /user: History for api/ }).click();
await page.locator('.wb-transcript').getByText('History for api', { exact: true }).waitFor();
await page.getByLabel('Message pi').fill('Keep the source continuation');
const runtimeActions = async () => (await records()).filter((r) => r.type === 'get_state' || r.type === 'prompt');
const before = await runtimeActions();
await page.locator('.wb-history-handoff summary').focus();
await page.keyboard.press('Enter');
const catalog = await page.evaluate(() => window.pidex.workspaceCatalog());
const destinationId = catalog.roots.find((r) => r.cwd === web)!.dirName;
const unavailable = page.getByLabel('Destination root').locator('option').filter({ hasText: missing });
expect(await unavailable.evaluate((option) => (option as HTMLOptionElement).disabled)).toBe(true);
await page.getByLabel('Destination root').selectOption(destinationId);
const proof = path.join(ROOT, '.tmp/proof');
const shots = path.join(ROOT, 'test-results/history-handoff');
await fs.mkdir(proof, { recursive: true });
await fs.mkdir(shots, { recursive: true });
const capture = async (name: string) => {
await page.screenshot({ path: path.join(proof, name) });
await fs.copyFile(path.join(proof, name), path.join(shots, name));
};
await capture('select-destination.png');
await page.getByRole('button', { name: 'Prepare draft', exact: true }).click();
await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor();
const draft = await page.getByLabel('Message pi').inputValue();
expect(draft).toMatch(/^Keep the destination plan\n\n---\n\n# History for api/);
expect(draft).toContain('## You\n\nHistory for api');
expect(draft).not.toContain('Stored answer for api');
expect(await page.locator('.wb-execution-root').textContent()).toContain(web);
expect(await runtimeActions()).toEqual(before);
expect(await fs.readFile(archive, 'utf8')).toBe(original);
const composer = await page.getByLabel('Message pi').boundingBox();
if (!composer) throw new Error('Destination composer is not visible.');
await page.mouse.move(composer.x + composer.width - 3, composer.y + composer.height - 3);
await page.mouse.down();
await page.mouse.move(composer.x + composer.width - 3, composer.y + composer.height + 120, { steps: 12 });
await page.mouse.up();
await capture('prepared-destination-draft.png');
// Review is real editing, with no automatic send.
await page.getByLabel('Message pi').fill(`${draft}\n\nCheck this root first.`);
await page.getByRole('button', { name: 'Send', exact: true }).click();
await page.getByRole('button', { name: 'Stop', exact: true }).click();
const prompt = (await records()).filter((r) => r.type === 'prompt').at(-1);
expect(prompt.cwd).toBe(web);
expect(prompt.message).toBe(`${draft}\n\nCheck this root first.`);
expect(prompt.sessionFile).not.toBe(archive);
expect(await fs.readFile(archive, 'utf8')).toBe(original);
await root(api);
expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep the source continuation');
expect(await page.getByRole('treeitem', { name: /user: History for api/ }).getAttribute('aria-selected')).toBe('true');
expect(errors).toEqual([]);
await page.setViewportSize({ width: 1100, height: 700 });
});

it('does not carry a commit draft into another root when both restore Changes', async () => {
await root(web);
await page.getByRole('button', { name: /^Changes/ }).click();
Expand Down
54 changes: 54 additions & 0 deletions tests/integration/HistoryHandoff.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { HistoryHandoff } from '../../src/renderer/components/HistoryHandoff';
import type { WorkspaceInfo } from '../../src/renderer/api';

const roots: WorkspaceInfo[] = [
{ dirName: 'api', name: 'api', cwd: '/code/api', availability: 'available', branch: '', sessionCount: 1, lastActive: '' },
{ dirName: 'web', name: 'web', cwd: '/code/web', availability: 'available', branch: '', sessionCount: 0, lastActive: '' },
{ dirName: 'offline', name: 'offline', cwd: '/code/offline', availability: 'missing', branch: '', sessionCount: 0, lastActive: '' },
];
afterEach(cleanup);

describe('<HistoryHandoff>', () => {
it('requires an available destination and prepares only on explicit submit', () => {
const onPrepare = vi.fn();
const { getByLabelText, getByRole, queryByRole } = render(
<HistoryHandoff roots={roots} sourceRootId="api" onPrepare={onPrepare} />,
);
fireEvent.click(document.querySelector('summary')!);
const destination = getByLabelText('Destination root');
expect(queryByRole('option', { name: 'api / /code/api' })).toBeNull();
expect(getByRole('option', { name: /offline/ })).toBeDisabled();
expect(getByRole('button', { name: 'Prepare draft' })).toBeDisabled();
fireEvent.change(destination, { target: { value: 'web' } });
expect(onPrepare).not.toHaveBeenCalled();
fireEvent.submit(getByRole('form', { name: 'Prepare context handoff' }));
expect(onPrepare).toHaveBeenCalledExactlyOnceWith('web');
});

it('rejects a destination that becomes unavailable while the form is open', () => {
const onPrepare = vi.fn();
const { getByLabelText, getByRole, rerender } = render(
<HistoryHandoff roots={roots} sourceRootId="api" onPrepare={onPrepare} />,
);
fireEvent.click(document.querySelector('summary')!);
fireEvent.change(getByLabelText('Destination root'), { target: { value: 'web' } });
rerender(<HistoryHandoff
roots={roots.map((root) => ({ ...root, availability: 'missing' }))}
sourceRootId="api" onPrepare={onPrepare}
/>);
expect(getByRole('button', { name: 'Prepare draft' })).toBeDisabled();
fireEvent.submit(getByRole('form', { name: 'Prepare context handoff' }));
expect(onPrepare).not.toHaveBeenCalled();
});

it('explains how to add a destination when no other roots are registered', () => {
const { getByText, getByRole } = render(
<HistoryHandoff roots={[roots[0]]} sourceRootId="api" onPrepare={vi.fn()} />,
);
fireEvent.click(document.querySelector('summary')!);
expect(getByText(/Add an available root from the sidebar/)).toBeTruthy();
expect(getByRole('button', { name: 'Prepare draft' })).toBeDisabled();
});
});
Loading
Loading