diff --git a/README.md b/README.md index 9bb33be..f226aac 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,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. +History also shows the root's current Git branch and changed-file counts before you resume or fork. Open **Current workspace changes**, then use **Review workspace changes** to inspect the current diff. Return to Sessions with the selected history entry and continuation draft preserved. Saved history does not restore files or switch Git branches. + 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. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c539764..114efc3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -613,12 +613,13 @@ function App() { : ''}

- b.navigate('changes')} - > - Review changes - + b.navigate('changes')} + /> {preview.tree?.warning && (

diff --git a/src/renderer/components/WorkspaceStatus.tsx b/src/renderer/components/WorkspaceStatus.tsx index 432d5dc..2d7e4f5 100644 --- a/src/renderer/components/WorkspaceStatus.tsx +++ b/src/renderer/components/WorkspaceStatus.tsx @@ -6,11 +6,13 @@ export function WorkspaceStatus({ details, ready, onReview, + context = 'new', }: { workspace: WorkspaceInfo; details: WorkspaceDetails; ready: boolean; onReview: () => void; + context?: 'new' | 'history'; }) { const unavailable = workspace.availability !== 'available'; const changes = details.changes; @@ -29,10 +31,15 @@ export function WorkspaceStatus({ : 'No changes in this root'; const inspected = !unavailable && ready && !details.gitError; - return ( -

+ const content = ( + <>
-

Workspace changes

+ {context === 'new' &&

Workspace changes

} + {context === 'history' && ( +

+ Saved history does not restore files or switch Git branches. +

+ )}

{message}

{inspected && (

@@ -43,6 +50,23 @@ export function WorkspaceStatus({ Review workspace changes -

+ ); + if (context === 'history') { + return ( +
+
+ + Current workspace changes + + {inspected && {details.branch || 'Detached HEAD'}} + {inspected && ' · '}{message} + + +
{content}
+
+
+ ); + } + return
{content}
; } diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 42d862e..8e5921a 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -391,6 +391,7 @@ html[data-reduced-motion='true'] .wb-shell *::after { } .wb-workspace-status { display: flex; + flex-shrink: 0; align-items: center; justify-content: space-between; gap: 12px; @@ -919,7 +920,7 @@ html[data-reduced-motion='true'] .wb-shell *::after { } .wb-history-heading { display: flex; - align-items: center; + align-items: start; gap: 16px; } .wb-history-heading > div { @@ -933,9 +934,44 @@ html[data-reduced-motion='true'] .wb-shell *::after { -webkit-line-clamp: 2; overflow: hidden; } -.wb-history-heading .wb-meta { +.wb-history-heading > div > .wb-meta { margin-top: 8px; } +.wb-history-workspace-status { + flex: 0 1 280px; + min-width: 0; + max-width: 50%; + font-size: 12px; + border: 1px solid var(--line); + border-radius: 4px; +} +.wb-history-workspace-status summary { + cursor: pointer; + padding: 8px 10px; +} +.wb-history-workspace-status summary .wb-meta { + display: flex; + min-width: 0; + margin-top: 4px; +} +.wb-history-workspace-status summary .wb-meta > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wb-history-status-branch { + flex: 1; + min-width: 0; +} +.wb-history-workspace-status summary .wb-meta > span:last-child { + flex-shrink: 0; + max-width: 100%; +} +.wb-history-workspace-status .wb-workspace-status { + border: 0; + border-top: 1px solid var(--line); + padding: 10px; +} .wb-fork-actions { display: flex; gap: 16px; @@ -1703,7 +1739,7 @@ html[data-reduced-motion='true'] .wb-shell *::after { border-bottom: 1px solid #ffffff12; color: #b9c7bd; } -.wb-history-heading .wb-meta { +.wb-history-heading > div > .wb-meta { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1855,4 +1891,26 @@ html[data-reduced-motion='true'] .wb-shell *::after { padding-bottom: 12px; gap: 8px; } + .wb-history-heading h2 { + font-size: 20px; + line-height: 24px; + } + .wb-history-heading > div > .wb-meta { + margin-top: 4px; + } + .wb-history-workspace-status summary { + padding: 6px 8px; + font-size: 11px; + } + .wb-history-conversation > .wb-branch-notice { + padding: 6px 10px; + } + .wb-history-conversation .wb-composer { + padding: 12px; + min-height: 128px; + } + .wb-history-conversation .wb-composer textarea { + height: 40px; + min-height: 40px; + } } diff --git a/tests/e2e/workspace-status.test.ts b/tests/e2e/workspace-status.test.ts index 44fa91f..f722fd4 100644 --- a/tests/e2e/workspace-status.test.ts +++ b/tests/e2e/workspace-status.test.ts @@ -11,15 +11,26 @@ let page: Page; let fixture: string; let project: string; let clean: string; +let source: string; +let rpcLog: string; const errors: string[] = []; const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' }); const status = () => page.getByRole('region', { name: 'Workspace Git status' }); async function capture(name: string) { for (const directory of ['.tmp/proof', 'test-results/workspace-status']) { await fs.mkdir(path.join(ROOT, directory), { recursive: true }); - await page.screenshot({ path: path.join(ROOT, directory, name) }); + await page.screenshot({ scale: 'css', path: path.join(ROOT, directory, name) }); } } +async function resizeWindow(width: number, height: number) { + const window = await app.browserWindow(page); + await window.evaluate((window, size) => window.setContentSize(size.width, size.height), { width, height }); + const [actualWidth, actualHeight] = await window.evaluate((window) => window.getContentSize()); + await page.waitForFunction( + ({ width, height }) => innerWidth === width && innerHeight === height, + { width: actualWidth, height: actualHeight }, + ); +} async function select(name: string) { await page.getByLabel('Workspace group').selectOption({ label: name }); await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor(); @@ -33,7 +44,19 @@ describe('workspace status in Electron', () => { const plain = path.join(fixture, 'plain'); const userData = path.join(fixture, 'user-data'); await Promise.all([project, clean, plain, userData].map((dir) => fs.mkdir(dir))); - await fs.mkdir(path.join(fixture, 'sessions')); + const archives = path.join(fixture, 'sessions/workspace'); + await fs.mkdir(archives, { recursive: true }); + source = path.join(archives, 'source.jsonl'); + rpcLog = path.join(fixture, 'rpc.jsonl'); + const message = (id: string, parentId: string | null, role: string, text: string) => ({ + type: 'message', id, parentId, message: { role, content: [{ type: 'text', text }] }, + }); + await fs.writeFile(source, [ + { type: 'session', version: 3, id: 'source', cwd: project, timestamp: new Date().toISOString() }, + message('task', null, 'user', 'Review saved parser work'), + message('old-tip', 'task', 'assistant', 'The streaming approach needs review.'), + message('new-tip', 'task', 'assistant', 'The buffered approach is ready.'), + ].map((record) => JSON.stringify(record)).join('\n') + '\n'); for (const cwd of [project, clean]) { git(cwd, 'init', '-b', 'main'); await fs.writeFile(path.join(cwd, 'app.ts'), 'export const value = 1;\n'); @@ -53,11 +76,12 @@ describe('workspace status in Electron', () => { PIDEX_E2E_RENDERER_URL: inject('rendererUrl'), PIDEX_E2E_USER_DATA: userData, PIDEX_SESSIONS_ROOT: path.join(fixture, 'sessions'), PIDEX_PI: path.join(ROOT, 'tests/fixtures/pi-rpc.cjs'), + PIDEX_RPC_LOG: rpcLog, }, }); page = await app.firstWindow(); page.on('pageerror', (error) => errors.push(error.message)); - await page.setViewportSize({ width: 1440, height: 1000 }); + await resizeWindow(1280, 900); await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor(); await select('project'); }); @@ -72,14 +96,14 @@ describe('workspace status in Electron', () => { await page.getByLabel('Message pi').fill('Review the existing changes before editing.'); await page.evaluate(() => document.fonts.ready); await capture('workspace-status.png'); - await page.setViewportSize({ width: 760, height: 900 }); + await resizeWindow(900, 700); await status().scrollIntoViewIfNeeded(); const bounds = await status().boundingBox(); const button = await status().getByRole('button').boundingBox(); if (!bounds || !button) throw new Error('Workspace status is not visible.'); expect(button.x + button.width).toBeLessThanOrEqual(bounds.x + bounds.width); await capture('workspace-status-narrow.png'); - await page.setViewportSize({ width: 1440, height: 1000 }); + await resizeWindow(1280, 900); await status().getByRole('button', { name: 'Review workspace changes' }).click(); await page.getByRole('heading', { name: 'Changes review', exact: true }).waitFor(); expect(await page.getByLabel('Message pi').inputValue()).toBe('Review the existing changes before editing.'); @@ -89,6 +113,54 @@ describe('workspace status in Electron', () => { expect(await page.locator('.wb-sidebar .wb-session-item').filter({ hasText: 'this window' }).count()).toBe(0); }); + it('reviews current changes from saved history without losing the selected entry or continuation draft', async () => { + const original = await fs.readFile(source, 'utf8'); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page.getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem', { name: /Review saved parser work/ }).click(); + await page.getByLabel('Branch tip').selectOption('old-tip'); + await page.locator('.wb-transcript').getByText('The streaming approach needs review.').waitFor(); + await expect.poll(() => status().textContent()).toContain('2 changed files'); + expect(await status().textContent()).toContain('feature/workspace-status · 1 staged · 1 unstaged · 1 untracked'); + await page.getByLabel('Message pi').fill('Check the current files before continuing this branch.'); + + await resizeWindow(1280, 900); + await page.evaluate(() => document.fonts.ready); + await capture('history-workspace-status.png'); + await status().locator('summary').click(); + await status().getByText('Saved history does not restore files or switch Git branches.').waitFor(); + await capture('history-workspace-status-expanded.png'); + await status().getByRole('button', { name: 'Review workspace changes' }).click(); + await page.getByRole('heading', { name: 'Changes review', exact: true }).waitFor(); + await page.getByLabel('Commit message').waitFor(); + await page.getByRole('button', { name: /^Sessions/ }).click(); + expect(await page.getByLabel('Branch tip').inputValue()).toBe('old-tip'); + expect(await page.getByLabel('Message pi').inputValue()).toBe('Check the current files before continuing this branch.'); + expect(await page.getByRole('button', { name: 'Resume in new fork', exact: true }).isEnabled()).toBe(true); + expect(await page.locator('.wb-transcript').textContent()).not.toContain('The buffered approach is ready.'); + + await fs.writeFile(path.join(project, 'notes.txt'), 'A newer edit outside the saved conversation.\n'); + git(project, 'add', 'notes.txt'); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect.poll(() => status().textContent()).toContain('2 staged · 1 unstaged · 0 untracked'); + await status().locator('summary').click(); + await capture('history-workspace-status-refreshed.png'); + await status().locator('summary').click(); + await resizeWindow(900, 600); + expect(await page.locator('.wb-transcript').evaluate((el) => el.clientHeight)).toBeGreaterThan(40); + expect(await page.evaluate(() => document.body.scrollWidth <= innerWidth)).toBe(true); + await capture('history-workspace-status-narrow.png'); + await resizeWindow(1280, 900); + git(project, 'reset', '--', 'notes.txt'); + expect(await fs.readFile(source, 'utf8')).toBe(original); + const commands = (await fs.readFile(rpcLog, 'utf8').catch(() => '')) + .split('\n').filter(Boolean).map((line) => JSON.parse(line)); + expect(commands.every((command) => command.type === 'get_available_models')).toBe(true); + expect(await page.locator('.wb-sidebar .wb-session-item').filter({ hasText: 'this window' }).count()).toBe(0); + expect(errors).toEqual([]); + await page.getByRole('button', { name: 'New session', exact: true }).click(); + }); + it('refreshes changed counts on focus and keeps clean and detached roots distinct', async () => { await fs.unlink(path.join(project, 'notes.txt')); git(project, 'add', 'app.ts'); diff --git a/tests/integration/WorkspaceStatus.test.tsx b/tests/integration/WorkspaceStatus.test.tsx index 1a8ed7f..c57ce55 100644 --- a/tests/integration/WorkspaceStatus.test.tsx +++ b/tests/integration/WorkspaceStatus.test.tsx @@ -14,6 +14,15 @@ const change: GitChange = { afterEach(cleanup); describe('workspace Git status', () => { + it('distinguishes current files from saved conversation history', () => { + const { container } = render(); + expect(container.querySelector('summary')).toHaveTextContent('feature/status · No changes in this root'); + expect(screen.getByRole('button', { name: 'Review workspace changes', hidden: true })).not.toBeVisible(); + fireEvent.click(screen.getByText('Current workspace changes')); + expect(screen.getByText('Saved history does not restore files or switch Git branches.')).toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent('No changes in this root'); + }); + it('counts a partially staged file once overall and in both change groups', () => { const onReview = vi.fn(); render(