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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ Use **Copy context** in History to put the selected path's Markdown handoff on t

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.

Saved-session shortcuts in the sidebar and Quick switch open History for preview before you choose Resume or Fork from here. Previewing does not launch a session or send a prompt. Open sessions in the sidebar focus their existing live tabs directly.

The new-session view offers the current root's recent saved conversations above the task composer. Choose **Preview** to read one before resuming or forking, or **View all saved sessions** to browse history. Previewing keeps an unsent new-task draft intact and waits for an explicit continuation action before starting Pi. Sending from the new-task composer starts a separate session.

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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/capture-workbench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ try {
await page
.getByLabel('Message pi')
.fill('Check keyboard focus in the composer.');
await page.getByRole('button', { name: 'Send', exact: true }).click();
await page.getByRole('button', { name: 'Resume', exact: true }).click();
await page.getByRole('button', { name: 'Stop', exact: true }).waitFor();
await page.screenshot({ path: path.join(captures, 'workbench-loaded.png') });
await page.getByRole('button', { name: 'Stop', exact: true }).click();
Expand Down
15 changes: 7 additions & 8 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,17 @@ function App() {
<button
key={s.file}
type="button"
className={`wb-session-item${active?.file === s.file && view === 'session' ? ' is-active' : ''}`}
onClick={() => b.showSession(s)}
className={`wb-session-item${preview?.info.file === s.file && view === 'history' ? ' is-active' : ''}`}
aria-label={`Preview ${s.title}`}
aria-current={preview?.info.file === s.file && view === 'history' ? 'page' : undefined}
onClick={() => void b.previewSavedSession(s)}
disabled={b.busy}
>
<span>{s.title}</span>
<small>
{b.tabs.find((t) => t.file === s.file)?.running
? 'Working'
: 'Saved'}{' '}
: 'Saved'} · Preview{' '}
/ {timeAgo(s.timestamp)}
</small>
</button>
Expand Down Expand Up @@ -406,10 +408,7 @@ function App() {
<SessionStartChoice
sessions={sessions}
busy={b.busy}
onPreview={(session) => {
b.navigate('history');
void b.showPreview(session);
}}
onPreview={(session) => void b.previewSavedSession(session)}
onBrowse={() => b.navigate('history')}
/>
<div className="wb-new-conversation">
Expand Down Expand Up @@ -791,7 +790,7 @@ function App() {
commands={active?.commands ?? []}
onClose={() => setPalette(false)}
onWorkspace={b.selectWorkspace}
onSession={b.showSession}
onSession={(session) => void b.previewSavedSession(session)}
onNavigate={b.navigate}
onFile={(file) => {
setPaletteFiles((previous) => ({
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function CommandPalette({
...sessions.map((s) => ({
id: `session:${s.file}`,
title: s.title,
detail: `${s.messageCount} messages / ${s.model || 'Pi'}`,
detail: `Preview saved conversation / ${s.messageCount} messages / ${s.model || 'Pi'}`,
category: 'Sessions' as const,
run: () => onSession(s),
})),
Expand Down
13 changes: 6 additions & 7 deletions src/renderer/useWorkbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,12 +490,6 @@ export function useWorkbench() {
setBusy(false);
}
};
const showSession = (info: SessionInfo) =>
run(async () => {
const token = generation.current;
const tab = await openSession(info);
if (tab && token === generation.current) setView('session');
});
const forkSession = () =>
preview?.ready &&
preview.tree?.canContinue &&
Expand Down Expand Up @@ -540,6 +534,11 @@ export function useWorkbench() {
},
[api, report],
);
const previewSavedSession = (info: SessionInfo) => {
if (opening.current) return;
navigate('history');
return showPreview(info);
};
useEffect(() => {
if (view !== 'history' || previewLoading) return;
if (preview && !preview.loaded)
Expand Down Expand Up @@ -780,7 +779,7 @@ export function useWorkbench() {
refresh,
selectWorkspace,
addWorkspace,
showSession,
previewSavedSession,
closeSession,
forkSession,
prepareHandoff,
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ describe('Figma workbench in Electron', () => {
await win
.getByLabel('Message pi')
.fill('Check keyboard focus in the composer.');
await win.getByRole('button', { name: 'Send', exact: true }).click();
await win.getByRole('button', { name: 'Resume', exact: true }).click();
await win.getByRole('button', { name: 'Stop', exact: true }).waitFor();
await win.screenshot({ path: path.join(shots, '02-active-session.png') });
await win.getByRole('button', { name: 'Stop', exact: true }).click();
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/long-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe('long session in Electron', () => {
it('opens 4,500 mixed entries with a bounded DOM and revisits them quickly', async () => {
const start = performance.now();
await page.locator('.wb-sidebar .wb-session-item').first().click();
await page.getByRole('button', { name: 'Fork from here', exact: true }).click();
await page
.locator('.wb-transcript')
.getByText('Iteration 1499', { exact: true })
Expand Down
76 changes: 76 additions & 0 deletions tests/e2e/session-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,54 @@ describe('session start choice in Electron', () => {
await resize(1280, 1000);
});

it('previews sidebar shortcuts with keyboard selection and preserves drafts at the minimum window size', async () => {
await newSession();
await page.getByLabel('Message pi').fill('Keep this separate task for later.');
const sidebar = page.getByRole('complementary', { name: 'Workspace navigation' });
const selected = sidebar.getByRole('button', { name: 'Preview Check keyboard focus', exact: true });
await selected.focus();
await page.keyboard.press('Enter');
await page.locator('.wb-transcript').getByText('Saved response for Check keyboard focus.', { exact: true }).waitFor();
expect(await selected.getAttribute('aria-current')).toBe('page');
expect(await selected.textContent()).toContain('Preview');
expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this unsent continuation too.');
await capture('sidebar-saved-preview.png');
await resize(900, 600);
const older = sidebar.getByRole('button', { name: 'Preview Older saved task', exact: true });
await older.scrollIntoViewIfNeeded();
await older.click();
await page.locator('.wb-transcript').getByText('Saved response for Older saved task.', { exact: true }).waitFor();
expect(await selected.getAttribute('aria-current')).toBeNull();
expect(await older.getAttribute('aria-current')).toBe('page');
await page.getByRole('button', { name: 'Resume', exact: true }).scrollIntoViewIfNeeded();
await capture('sidebar-saved-preview-narrow.png');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
expect(await sessionRecords()).toEqual([]);
expect(await fs.readFile(source, 'utf8')).toBe(sourceBytes);
await newSession();
expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this separate task for later.');
await resize(1280, 1000);
});

it('previews Quick switch results by keyboard without opening a runtime', async () => {
await page.getByRole('button', { name: 'Quick switch', exact: true }).click();
const palette = page.getByRole('dialog', { name: 'Quick switch' });
await palette.getByRole('tab', { name: /Sessions/ }).click();
const search = palette.getByRole('combobox');
await search.fill('Check keyboard focus');
expect(await palette.getByRole('option').count()).toBe(1);
expect(await palette.getByRole('option').textContent()).toContain('Preview saved conversation');
await capture('quick-switch-saved-preview.png');
await search.press('Enter');
await palette.waitFor({ state: 'hidden' });
await page.locator('.wb-transcript').getByText('Saved response for Check keyboard focus.', { exact: true }).waitFor();
expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this unsent continuation too.');
expect(await sessionRecords()).toEqual([]);
expect(await fs.readFile(source, 'utf8')).toBe(sourceBytes);
await newSession();
expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this separate task for later.');
});

it('scopes choices to the selected root and starts a fresh conversation only after Send', async () => {
await newSession();
await page.getByLabel('Message pi').fill('Keep this separate task for later.');
Expand All @@ -168,4 +216,32 @@ describe('session start choice in Electron', () => {
await page.getByRole('button', { name: 'Stop', exact: true }).click();
expect(errors).toEqual([]);
});

it('resumes an explicitly selected sidebar preview and focuses its live tab without another launch or prompt', async () => {
const sidebar = page.getByRole('complementary', { name: 'Workspace navigation' });
const saved = sidebar.getByRole('button', { name: 'Preview Check keyboard focus', exact: true });
await saved.click();
await page.locator('.wb-transcript').getByText('Saved response for Check keyboard focus.', { exact: true }).waitFor();
const before = await sessionRecords();
expect(before.filter((record) => record.type === 'prompt')).toHaveLength(1);
await page.getByLabel('Message pi').fill('Continue this saved keyboard task.');
await page.getByRole('button', { name: 'Resume', exact: true }).click();
await page.getByRole('button', { name: 'Stop', exact: true }).waitFor();
await expect.poll(async () => (await sessionRecords()).filter((record) => record.type === 'prompt').length).toBe(2);
const resumed = await sessionRecords();
expect(resumed.filter((record) => record.type === 'prompt').at(-1)).toMatchObject({
cwd: project, message: 'Continue this saved keyboard task.',
sessionFile: path.join(path.dirname(source), '1.jsonl'),
});
await newSession();
const live = sidebar.locator('.wb-session-item').filter({ hasText: 'this window' }).filter({ hasText: 'Check keyboard focus' });
await live.click();
await page.getByRole('heading', { name: 'Check keyboard focus', exact: true }).waitFor();
expect(await page.getByRole('button', { name: 'Stop', exact: true }).isVisible()).toBe(true);
expect(await sessionRecords()).toEqual(resumed);
await capture('open-session-direct-focus.png');
await page.getByRole('button', { name: 'Stop', exact: true }).click();
expect(await fs.readFile(source, 'utf8')).toBe(sourceBytes);
expect(errors).toEqual([]);
});
});
1 change: 1 addition & 0 deletions tests/integration/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('command palette', () => {
);
fireEvent.click(screen.getByRole('tab', { name: /Sessions/ }));
expect(screen.getAllByRole('option')).toHaveLength(2);
expect(screen.getAllByText(/Preview saved conversation/)).toHaveLength(2);
rerender(<CommandPalette {...callbacks} sessions={[second]} />);
fireEvent.click(screen.getByRole('option', { name: /Second archive/ }));
expect(callbacks.onSession).toHaveBeenCalledExactlyOnceWith(second);
Expand Down
55 changes: 39 additions & 16 deletions tests/integration/useWorkbench.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ describe('workbench root switching', () => {
await waitFor(() => expect(result.current.ws?.name).toBe('api'));
act(() => result.current.onDraft('Keep my separate task'));
await act(async () => {
result.current.navigate('history');
await result.current.showPreview(source);
await result.current.previewSavedSession(source);
});
expect(result.current.view).toBe('history');
expect(result.current.preview?.info.file).toBe(source.file);
Expand Down Expand Up @@ -122,6 +121,39 @@ describe('workbench root switching', () => {
expect(result.current.detailsRoot).toBe('web');
});

it('previews saved work for a missing root and reports unreadable archives without starting Pi', async () => {
const fixture = apiFixture();
fixture.workspaceCatalog.mockResolvedValue({
...catalog,
roots: [{ ...catalog.roots[0], availability: 'missing' }, catalog.roots[1]],
});
fixture.sessionTree.mockResolvedValue({ data: { leafId: 'saved', canContinue: true, nodes: [] } });
fixture.previewSession.mockResolvedValue({ data: [
{ role: 'user', content: [{ type: 'text', text: 'Recover this saved task' }] },
] });
const openSession = vi.fn();
const sendPrompt = vi.fn();
Object.assign(fixture, { openSession, sendPrompt });
const source = {
cwd: '/code/api', id: 'source', file: '/sessions/source.jsonl',
title: 'Saved task', model: '', timestamp: '', messageCount: 1,
};
const { result } = renderHook(useWorkbench);
await waitFor(() => expect(result.current.ws?.availability).toBe('missing'));
await act(async () => { await result.current.previewSavedSession(source); });
expect(result.current.view).toBe('history');
expect(result.current.preview?.entries[0]).toMatchObject({ text: 'Recover this saved task' });
fixture.sessionTree.mockResolvedValue({ error: 'Archive no longer exists' });
await act(async () => {
await result.current.previewSavedSession({ ...source, file: '/sessions/removed.jsonl' });
});
expect(result.current.error).toBe('Archive no longer exists');
expect(result.current.preview?.ready).toBe(false);
expect(result.current.preview?.entries).toEqual([]);
expect(openSession).not.toHaveBeenCalled();
expect(sendPrompt).not.toHaveBeenCalled();
});

it('prepares selected history across groups, keeps both drafts and restores the source selection', async () => {
const fixture = apiFixture();
fixture.workspaceCatalog.mockResolvedValue({
Expand Down Expand Up @@ -225,20 +257,11 @@ describe('workbench root switching', () => {
const sessionCommands = vi
.fn<PidexApi['sessionCommands']>()
.mockResolvedValue({ commands: [] });
Object.assign(fixture, { openSession, sessionCommands });
Object.assign(fixture, { openSession, sessionCommands, sendPrompt: vi.fn().mockResolvedValue({ success: true }) });
const { result } = renderHook(useWorkbench);
await waitFor(() => expect(result.current.ws?.name).toBe('api'));
act(() =>
result.current.showSession({
cwd: '/code/api',
id: 'archive',
file: '/sessions/archive.jsonl',
title: 'Build',
model: '',
timestamp: '',
messageCount: 0,
}),
);
act(() => result.current.onDraft('Build'));
await act(async () => { await result.current.send(); });
await waitFor(() => expect(result.current.tabs).toHaveLength(1));
const onEvent = fixture.onSessionEvent.mock.calls[0][0];
const onExit = fixture.onSessionExit.mock.calls[0][0];
Expand All @@ -262,13 +285,13 @@ describe('workbench root switching', () => {
});
onExit({ sessionId: 'other-runtime' });
});
expect(result.current.tabs[0].entries[0]).toMatchObject({ running: true });
expect(result.current.tabs[0].entries.at(-1)).toMatchObject({ running: true });
act(() => onExit({ sessionId: 'runtime' }));
expect(result.current.tabs[0]).toMatchObject({
running: false,
exited: true,
});
expect(result.current.tabs[0].entries[0]).toMatchObject({
expect(result.current.tabs[0].entries.at(-1)).toMatchObject({
running: false,
interrupted: true,
output: 'Partial build',
Expand Down
Loading