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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ playwright-report/
test-results/
blob-report/
.playwright/
.tmp/

# Logs
*.log
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 14 additions & 26 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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() {
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -330,9 +317,10 @@ function App() {
Restore defaults
</BracketButton>
) : view === 'history' ? (
<BracketButton disabled={!preview} onClick={exportSession}>
Export
</BracketButton>
<HistoryExport
key={`${preview?.info.file}:${preview?.entryId}:${!!historyExport}`}
input={historyExport}
/>
) : (
<BracketButton
onClick={() =>
Expand Down
65 changes: 65 additions & 0 deletions src/renderer/components/HistoryExport.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionExportInput, 'exportedAt'> | 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 (
<div className="wb-history-export-actions">
<div className="wb-history-export-buttons">
<BracketButton
disabled={!input || copying}
onClick={() => void copy()}
aria-label="Copy context"
title="Copy the selected history as Markdown, including thinking and tool input/output"
>
{copying ? 'Copying…' : 'Copy context'}
</BracketButton>
<BracketButton disabled={!input} onClick={download}>
Export
</BracketButton>
</div>
{feedback && <p role={failed ? 'alert' : 'status'} title={feedback}>{feedback}</p>}
</div>
);
}
15 changes: 15 additions & 0 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 39 additions & 2 deletions tests/e2e/session-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
});
Expand Down
110 changes: 110 additions & 0 deletions tests/integration/HistoryExport.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionExportInput, 'exportedAt'> = {
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<void>) {
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('<HistoryExport>', () => {
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(<HistoryExport input={input} />);
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(<HistoryExport input={null} />);
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(<HistoryExport input={{ ...input, entries: [] }} />);
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<void>((_resolve, fail) => { reject = fail; }))
.mockResolvedValue(undefined);
clipboard(writeText);
const { getByRole, queryByRole } = render(<HistoryExport input={input} />);
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(<HistoryExport input={input} />);
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(<HistoryExport input={input} />);
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');
});
});
Loading