diff --git a/README.md b/README.md index 0652ff4..053ba30 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scripts/capture-workbench.mjs b/scripts/capture-workbench.mjs index 16114df..c612255 100644 --- a/scripts/capture-workbench.mjs +++ b/scripts/capture-workbench.mjs @@ -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(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6428fe6..5fe4bda 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -247,15 +247,17 @@ function App() { @@ -406,10 +408,7 @@ function App() { { - b.navigate('history'); - void b.showPreview(session); - }} + onPreview={(session) => void b.previewSavedSession(session)} onBrowse={() => b.navigate('history')} />
@@ -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) => ({ diff --git a/src/renderer/components/CommandPalette.tsx b/src/renderer/components/CommandPalette.tsx index f977503..07e2d79 100644 --- a/src/renderer/components/CommandPalette.tsx +++ b/src/renderer/components/CommandPalette.tsx @@ -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), })), diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index 663cc5d..2328e08 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -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 && @@ -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) @@ -780,7 +779,7 @@ export function useWorkbench() { refresh, selectWorkspace, addWorkspace, - showSession, + previewSavedSession, closeSession, forkSession, prepareHandoff, diff --git a/tests/e2e/app.test.ts b/tests/e2e/app.test.ts index ccc9f89..dcb912c 100644 --- a/tests/e2e/app.test.ts +++ b/tests/e2e/app.test.ts @@ -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(); diff --git a/tests/e2e/long-session.test.ts b/tests/e2e/long-session.test.ts index 8823d1a..0c70f28 100644 --- a/tests/e2e/long-session.test.ts +++ b/tests/e2e/long-session.test.ts @@ -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 }) diff --git a/tests/e2e/session-start.test.ts b/tests/e2e/session-start.test.ts index 9294017..83be233 100644 --- a/tests/e2e/session-start.test.ts +++ b/tests/e2e/session-start.test.ts @@ -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.'); @@ -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([]); + }); }); diff --git a/tests/integration/CommandPalette.test.tsx b/tests/integration/CommandPalette.test.tsx index 7289877..fb7af5e 100644 --- a/tests/integration/CommandPalette.test.tsx +++ b/tests/integration/CommandPalette.test.tsx @@ -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(); fireEvent.click(screen.getByRole('option', { name: /Second archive/ })); expect(callbacks.onSession).toHaveBeenCalledExactlyOnceWith(second); diff --git a/tests/integration/useWorkbench.test.tsx b/tests/integration/useWorkbench.test.tsx index 5ffd088..1940796 100644 --- a/tests/integration/useWorkbench.test.tsx +++ b/tests/integration/useWorkbench.test.tsx @@ -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); @@ -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({ @@ -225,20 +257,11 @@ describe('workbench root switching', () => { const sessionCommands = vi .fn() .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]; @@ -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',