diff --git a/README.md b/README.md index 521a9ff..14c201d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/pidex-architecture.md b/docs/pidex-architecture.md index b8bed69..f234148 100644 --- a/docs/pidex-architecture.md +++ b/docs/pidex-architecture.md @@ -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. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f244bb8..f8ca356 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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'; @@ -628,7 +629,17 @@ function App() { {preview.loaded && !b.previewLoading && (preview.ready || preview.entries.length > 0) && ( - +
+ + {preview.entries.length > 0 && ( + + )} +
)} void; +}) { + const [rootId, setRootId] = useState(''); + const destinations = roots.filter((root) => root.dirName !== sourceRootId); + const destination = destinations.find((root) => root.dirName === rootId); + return ( +
+ Continue in another root +
{ + event.preventDefault(); + if (destination?.availability === 'available') onPrepare(rootId); + }} + > +

+ Add the selected history to a new-session draft in another root. + Existing draft text is kept. Review it before sending. +

+

+ Files and saved sessions stay in the original root. +

+ + {!destinations.some((root) => root.availability === 'available') && ( +

+ Add an available root from the sidebar to prepare a handoff. +

+ )} + + Prepare draft + +
+
+ ); +} diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index 34d0be7..713dcc5 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -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, @@ -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(); @@ -719,6 +750,7 @@ export function useWorkbench() { showSession, closeSession, forkSession, + prepareHandoff, useSkill, }; } diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 91aecba..ef992ae 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -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; @@ -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; diff --git a/tests/e2e/workspaces.test.ts b/tests/e2e/workspaces.test.ts index 8220b87..a3aace7 100644 --- a/tests/e2e/workspaces.test.ts +++ b/tests/e2e/workspaces.test.ts @@ -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(); diff --git a/tests/integration/HistoryHandoff.test.tsx b/tests/integration/HistoryHandoff.test.tsx new file mode 100644 index 0000000..30a3077 --- /dev/null +++ b/tests/integration/HistoryHandoff.test.tsx @@ -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('', () => { + it('requires an available destination and prepares only on explicit submit', () => { + const onPrepare = vi.fn(); + const { getByLabelText, getByRole, queryByRole } = render( + , + ); + 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( + , + ); + fireEvent.click(document.querySelector('summary')!); + fireEvent.change(getByLabelText('Destination root'), { target: { value: 'web' } }); + rerender( ({ ...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( + , + ); + fireEvent.click(document.querySelector('summary')!); + expect(getByText(/Add an available root from the sidebar/)).toBeTruthy(); + expect(getByRole('button', { name: 'Prepare draft' })).toBeDisabled(); + }); +}); diff --git a/tests/integration/useWorkbench.test.tsx b/tests/integration/useWorkbench.test.tsx index 7f08544..1f751b6 100644 --- a/tests/integration/useWorkbench.test.tsx +++ b/tests/integration/useWorkbench.test.tsx @@ -64,6 +64,96 @@ afterEach(() => { }); describe('workbench root switching', () => { + it('prepares selected history across groups, keeps both drafts and restores the source selection', async () => { + const fixture = apiFixture(); + fixture.workspaceCatalog.mockResolvedValue({ + ...catalog, + groups: [ + { id: 'backend', name: 'Backend', rootIds: ['api'] }, + { id: 'frontend', name: 'Frontend', rootIds: ['web'] }, + ], + selected: { groupId: 'backend', rootId: 'api' }, + }); + fixture.sessionTree.mockResolvedValue({ data: { leafId: 'latest', canContinue: true, nodes: [] } }); + fixture.previewSession.mockResolvedValue({ data: [ + { role: 'user', content: [{ type: 'text', text: 'Selected earlier request' }] }, + ] }); + const openSession = vi.fn(); + const sendPrompt = vi.fn(); + Object.assign(fixture, { openSession, sendPrompt }); + const { result } = renderHook(useWorkbench); + await waitFor(() => expect(result.current.ws?.name).toBe('api')); + act(() => result.current.onDraft('Unsent source task')); + act(() => result.current.selectWorkspace(catalog.roots[1])); + act(() => result.current.onDraft('Keep this destination task')); + act(() => result.current.selectWorkspace(catalog.roots[0])); + await act(async () => { + result.current.navigate('history'); + await result.current.showPreview({ + cwd: '/code/api', id: 'source', file: '/sessions/source.jsonl', + title: 'Source context', model: 'original-model', timestamp: '', messageCount: 1, + }, 'earlier'); + }); + act(() => result.current.onDraft('Unsent history continuation')); + act(() => result.current.prepareHandoff('web')); + expect(result.current.view).toBe('new'); + expect(result.current.ws?.cwd).toBe('/code/web'); + expect(result.current.groupId).toBe('frontend'); + expect(result.current.active).toBeNull(); + expect(result.current.draft).toMatch(/^Keep this destination task\n\n---\n\n# Source context/); + expect(result.current.draft).toContain('Selected earlier request'); + expect(result.current.draft).toContain('It ran in `/code/api`.'); + expect(openSession).not.toHaveBeenCalled(); + expect(sendPrompt).not.toHaveBeenCalled(); + act(() => result.current.selectWorkspace(catalog.roots[0])); + expect(result.current.view).toBe('history'); + expect(result.current.preview?.entryId).toBe('earlier'); + expect(result.current.draft).toBe('Unsent history continuation'); + act(() => result.current.navigate('new')); + expect(result.current.draft).toBe('Unsent source task'); + expect(fixture.previewSession).toHaveBeenCalledExactlyOnceWith('/sessions/source.jsonl', 'earlier'); + }); + + it('does not prepare missing, same-root, empty or loading history handoffs', async () => { + const fixture = apiFixture(); + fixture.sessionTree.mockResolvedValue({ data: { leafId: 'latest', canContinue: true, nodes: [] } }); + const messages = deferred>>(); + fixture.previewSession.mockReturnValue(messages.promise); + const { result } = renderHook(useWorkbench); + await waitFor(() => expect(result.current.ws?.name).toBe('api')); + let preview!: Promise; + act(() => { + result.current.navigate('history'); + preview = result.current.showPreview({ + cwd: '/code/api', id: 'source', file: '/sessions/source.jsonl', + title: 'Source context', model: '', timestamp: '', messageCount: 1, + }); + }); + act(() => result.current.prepareHandoff('web')); + expect(result.current.view).toBe('history'); + await act(async () => { + messages.resolve({ data: [{ role: 'user', content: [{ type: 'text', text: 'Saved request' }] }] }); + await preview; + }); + fixture.workspaceCatalog.mockResolvedValue({ + ...catalog, + roots: [catalog.roots[0], { ...catalog.roots[1], availability: 'missing' }], + }); + await act(async () => { await result.current.refresh(); }); + for (const rootId of ['api', 'web', 'unknown']) { + act(() => result.current.prepareHandoff(rootId)); + expect(result.current.ws?.cwd).toBe('/code/api'); + expect(result.current.draft).toBe(''); + } + fixture.workspaceCatalog.mockResolvedValue(catalog); + await act(async () => { await result.current.refresh(); }); + fixture.previewSession.mockResolvedValue({ data: [] }); + await act(async () => { await result.current.showPreview(result.current.preview!.info); }); + act(() => result.current.prepareHandoff('web')); + expect(result.current.view).toBe('history'); + expect(result.current.ws?.cwd).toBe('/code/api'); + }); + it('settles unfinished tool cards when their runtime exits', async () => { const fixture = apiFixture(); const openSession = vi