From 3e20a60dfe88f434080f73ac91fdb45097ec914a Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:29:47 +0700 Subject: [PATCH] feat: copy selected history context for workspace handoff Co-authored-by: OpenAI Codex --- .gitignore | 1 + README.md | 2 + src/renderer/App.tsx | 40 +++----- src/renderer/components/HistoryExport.tsx | 65 +++++++++++++ src/renderer/workbench.css | 15 +++ tests/e2e/session-tree.test.ts | 41 +++++++- tests/integration/HistoryExport.test.tsx | 110 ++++++++++++++++++++++ 7 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 src/renderer/components/HistoryExport.tsx create mode 100644 tests/integration/HistoryExport.test.tsx 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'); + }); +});