diff --git a/.gitignore b/.gitignore
index ac4558b..b015404 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ playwright-report/
test-results/
blob-report/
.playwright/
+.tmp/
# Logs
*.log
diff --git a/README.md b/README.md
index de58bbd..8546d24 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,8 @@ Session history shows source/fork relationships and an entry tree with selected
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 **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.
+
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.
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 3de83d6..f244bb8 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,6 +1,7 @@
import { WorkspaceNavigation } from './components/WorkspaceNavigation';
import { SessionTree, EntryTree } from './components/SessionTree';
import { HistorySummary } from './components/HistorySummary';
+import { HistoryExport } from './components/HistoryExport';
import { useEffect, useState } from 'react';
import { PidexMark } from './components/PidexMark';
import welcomeMark from './assets/workbench/welcome-mark.svg';
@@ -20,10 +21,6 @@ import {
} from './components/WorkbenchViews';
import { Transcript } from './components/Transcript';
import { CommandPalette } from './components/CommandPalette';
-import {
- sessionExportFilename,
- sessionExportMarkdown,
-} from './export-session';
import { timeAgo, useWorkbench } from './useWorkbench';
function App() {
@@ -111,25 +108,15 @@ function App() {
const branch = details.branch || ws?.branch || 'local';
const context = `${ws?.name ?? 'pidex'} / ${branch} / ${view === 'history' ? `${sessions.length} sessions` : view === 'changes' ? `${details.changes.length} changes` : view === 'settings' ? 'workspace defaults' : 'local workspace'}`;
const live = b.tabs;
- const exportSession = () => {
- if (!preview) return;
- const exportedAt = new Date();
- const text = sessionExportMarkdown({
- title: preview.info.title,
- cwd: preview.info.cwd,
- model: preview.info.model,
- exportedAt,
- entries: preview.entries,
- });
- const url = URL.createObjectURL(
- new Blob([text], { type: 'text/markdown' }),
- );
- const a = document.createElement('a');
- a.href = url;
- a.download = sessionExportFilename(preview.info.title, exportedAt);
- a.click();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- };
+ const historyExport = preview?.loaded && !b.previewLoading &&
+ (preview.ready || preview.entries.length > 0)
+ ? {
+ title: preview.info.title,
+ cwd: preview.info.cwd,
+ model: preview.info.model,
+ entries: preview.entries,
+ }
+ : null;
return (
) : view === 'history' ? (
-
- Export
-
+
) : (
diff --git a/src/renderer/components/HistoryExport.tsx b/src/renderer/components/HistoryExport.tsx
new file mode 100644
index 0000000..82adc5b
--- /dev/null
+++ b/src/renderer/components/HistoryExport.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { BracketButton } from './BracketButton';
+import {
+ sessionExportFilename,
+ sessionExportMarkdown,
+ type SessionExportInput,
+} from '../export-session';
+
+export function HistoryExport({
+ input,
+}: {
+ input: Omit | null;
+}) {
+ const [copying, setCopying] = useState(false);
+ const [feedback, setFeedback] = useState('');
+ const [failed, setFailed] = useState(false);
+
+ const copy = async () => {
+ if (!input || copying) return;
+ setCopying(true);
+ setFeedback('');
+ setFailed(false);
+ try {
+ await navigator.clipboard.writeText(
+ sessionExportMarkdown({ ...input, exportedAt: new Date() }),
+ );
+ setFeedback('Context copied. Paste it into a new session in the destination root.');
+ } catch {
+ setFailed(true);
+ setFeedback('Could not copy context. Try again or use Export.');
+ } finally {
+ setCopying(false);
+ }
+ };
+ const download = () => {
+ if (!input) return;
+ const exportedAt = new Date();
+ const text = sessionExportMarkdown({ ...input, exportedAt });
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }));
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = sessionExportFilename(input.title, exportedAt);
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ };
+
+ return (
+
+
+ void copy()}
+ aria-label="Copy context"
+ title="Copy the selected history as Markdown, including thinking and tool input/output"
+ >
+ {copying ? 'Copying…' : 'Copy context'}
+
+
+ Export
+
+
+ {feedback &&
{feedback}
}
+
+ );
+}
diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css
index 0e71922..29fea39 100644
--- a/src/renderer/workbench.css
+++ b/src/renderer/workbench.css
@@ -327,6 +327,21 @@ html[data-reduced-motion='true'] .wb-shell *::after {
overflow: hidden;
text-overflow: ellipsis;
}
+.wb-view-header > .wb-history-export-actions {
+ flex: 0 1 auto;
+ max-width: 360px;
+}
+.wb-history-export-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.wb-view-header .wb-history-export-actions p {
+ margin-top: 4px;
+ white-space: normal;
+ font-size: 11px;
+ line-height: 14px;
+}
.wb-view-header p {
font-size: 12px;
line-height: 18px;
diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts
index 8554a46..6969e76 100644
--- a/tests/e2e/session-tree.test.ts
+++ b/tests/e2e/session-tree.test.ts
@@ -202,7 +202,7 @@ describe('session branches in Electron', () => {
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 page.getByRole('tree', { name: 'Session archives', exact: true }).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');
@@ -221,7 +221,44 @@ describe('session branches in Electron', () => {
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.getByRole('tree', { name: 'Session archives', exact: true }).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('copies only the selected history path as portable context without launching Pi', async () => {
+ const original = await fs.readFile(source, 'utf8');
+ await page.setViewportSize({ width: 1280, height: 840 });
+ await page.getByRole('tree', { name: 'Session archives', exact: true }).getByRole('treeitem', { name: /Explore parser branches/ }).click();
+ await page.locator('.wb-transcript').getByText('The latest branch uses a buffered parser.').waitFor();
+ await page.getByRole('button', { name: 'Copy context', exact: true }).click();
+ await page.getByRole('status').filter({ hasText: 'Context copied.' }).waitFor();
+ const latest = await app.evaluate(({ clipboard }) => clipboard.readText());
+ expect(latest).toContain('The latest branch uses a buffered parser.');
+ expect(latest).toContain('## Continue elsewhere');
+ expect(latest).toContain('It ran in `');
+ expect(latest).not.toContain('Streaming branch is ready.');
+ await page.getByLabel('Branch tip').selectOption('old-tip');
+ await page.locator('.wb-transcript').getByText('Streaming branch is ready.').waitFor();
+ expect(await page.getByText('Context copied.', { exact: false }).count()).toBe(0);
+ await page.getByRole('button', { name: 'Copy context', exact: true }).click();
+ await page.getByRole('status').filter({ hasText: 'Context copied.' }).waitFor();
+ const branch = await app.evaluate(({ clipboard }) => clipboard.readText());
+ expect(branch).toContain('Streaming branch is ready.');
+ expect(branch).not.toContain('buffered parser');
+ await page.getByRole('treeitem', { name: /assistant: The original branch/ }).click();
+ await page.locator('.wb-transcript').getByText('The original branch uses a streaming parser.').waitFor();
+ await page.getByRole('button', { name: 'Copy context', exact: true }).click();
+ await page.getByRole('status').filter({ hasText: 'Context copied.' }).waitFor();
+ const ancestor = await app.evaluate(({ clipboard }) => clipboard.readText());
+ expect(ancestor).toContain('The original branch uses a streaming parser.');
+ expect(ancestor).not.toContain('Keep the streaming approach');
+ expect(ancestor).not.toContain('Streaming branch is ready.');
+ await fs.mkdir(path.join(ROOT, '.tmp/proof'), { recursive: true });
+ await page.screenshot({ path: path.join(ROOT, '.tmp/proof/copied-history-context.png') });
+ expect(await fs.readFile(source, 'utf8')).toBe(original);
+ expect((await readLog()).filter((c) => c.type === 'get_state' || c.type === 'prompt')).toEqual([]);
+ await page.getByRole('tree', { name: 'Session archives', exact: true }).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 });
});
diff --git a/tests/integration/HistoryExport.test.tsx b/tests/integration/HistoryExport.test.tsx
new file mode 100644
index 0000000..5277056
--- /dev/null
+++ b/tests/integration/HistoryExport.test.tsx
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
+import { HistoryExport } from '../../src/renderer/components/HistoryExport';
+import { sessionExportMarkdown, type SessionExportInput } from '../../src/renderer/export-session';
+
+const input: Omit = {
+ title: 'Recover the parser', cwd: '/code/api', model: 'test-model',
+ entries: [
+ { key: 'u', kind: 'user', text: 'Fix the parser' },
+ { key: 'a', kind: 'assistant', text: 'Use a buffered parser', thinking: 'Check boundaries', streaming: false },
+ { key: 't', kind: 'tool', toolId: 't', tool: 'bash', args: 'bun test', input: 'bun test', output: 'failed', running: false, ok: false },
+ ],
+};
+const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
+const createDescriptor = Object.getOwnPropertyDescriptor(URL, 'createObjectURL');
+const revokeDescriptor = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL');
+function clipboard(writeText: (text: string) => Promise) {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true, value: { writeText },
+ });
+}
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ if (clipboardDescriptor) Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
+ else Reflect.deleteProperty(navigator, 'clipboard');
+ for (const [key, descriptor] of [
+ ['createObjectURL', createDescriptor], ['revokeObjectURL', revokeDescriptor],
+ ] as const) {
+ if (descriptor) Object.defineProperty(URL, key, descriptor);
+ else Reflect.deleteProperty(URL, key);
+ }
+});
+
+describe('', () => {
+ it('copies the same complete Markdown handoff as Export and allows copying again', async () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ const exportedAt = new Date('2026-11-12T01:02:03Z');
+ vi.setSystemTime(exportedAt);
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ clipboard(writeText);
+ const { getByRole } = render();
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ expect(writeText).toHaveBeenCalledWith(sessionExportMarkdown({ ...input, exportedAt }));
+ await waitFor(() => expect(getByRole('status').textContent).toContain('Paste it into a new session'));
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2));
+ });
+
+ it('disables both actions until readable history is supplied, including empty history', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ clipboard(writeText);
+ const { getByRole, rerender } = render();
+ expect(getByRole('button', { name: 'Copy context' })).toBeDisabled();
+ expect(getByRole('button', { name: 'Export' })).toBeDisabled();
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ expect(writeText).not.toHaveBeenCalled();
+ rerender();
+ expect(getByRole('button', { name: 'Export' })).toBeEnabled();
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ await waitFor(() => expect(getByRole('status')).toBeTruthy());
+ expect(writeText.mock.calls[0][0]).toContain('No messages in this selection.');
+ });
+
+ it('prevents duplicate pending copies and keeps clipboard failures retryable', async () => {
+ let reject!: (error: Error) => void;
+ const writeText = vi.fn()
+ .mockImplementationOnce(() => new Promise((_resolve, fail) => { reject = fail; }))
+ .mockResolvedValue(undefined);
+ clipboard(writeText);
+ const { getByRole, queryByRole } = render();
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ expect(getByRole('button', { name: 'Copy context' })).toBeDisabled();
+ expect(getByRole('button', { name: 'Export' })).toBeEnabled();
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ expect(writeText).toHaveBeenCalledTimes(1);
+ reject(new Error('Clipboard denied'));
+ await waitFor(() => expect(getByRole('alert').textContent).toContain('Try again or use Export'));
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ await waitFor(() => expect(getByRole('status')).toBeTruthy());
+ expect(queryByRole('alert')).toBeNull();
+ });
+
+ it('offers Export when the clipboard API is unavailable', async () => {
+ Reflect.deleteProperty(navigator, 'clipboard');
+ const { getByRole } = render();
+ fireEvent.click(getByRole('button', { name: 'Copy context' }));
+ await waitFor(() => expect(getByRole('alert')).toBeTruthy());
+ expect(getByRole('button', { name: 'Export' })).toBeEnabled();
+ });
+
+ it('downloads the selected history as a Markdown file', () => {
+ vi.useFakeTimers();
+ const create = vi.fn().mockReturnValue('blob:history');
+ const revoke = vi.fn();
+ Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: create });
+ Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revoke });
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+ expect(this.download).toMatch(/^pidex-recover-the-parser-\d{4}-\d{2}-\d{2}\.md$/);
+ expect(this.href).toBe('blob:history');
+ });
+ const { getByRole } = render();
+ fireEvent.click(getByRole('button', { name: 'Export' }));
+ expect(click).toHaveBeenCalledOnce();
+ expect(create.mock.calls[0][0].type).toBe('text/markdown');
+ vi.advanceTimersByTime(1000);
+ expect(revoke).toHaveBeenCalledWith('blob:history');
+ });
+});