diff --git a/README.md b/README.md index 56c3467..0652ff4 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. +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. 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 52bf650..6428fe6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,6 +4,7 @@ import { HistorySummary } from './components/HistorySummary'; import { HistoryExport } from './components/HistoryExport'; import { HistoryHandoff } from './components/HistoryHandoff'; import { WorkspaceStatus } from './components/WorkspaceStatus'; +import { SessionStartChoice } from './components/SessionStartChoice'; import { useEffect, useState } from 'react'; import { PidexMark } from './components/PidexMark'; import welcomeMark from './assets/workbench/welcome-mark.svg'; @@ -402,7 +403,23 @@ function App() { ready={b.detailsRoot === ws.dirName} onReview={() => b.navigate('changes')} /> - {composer()} + { + b.navigate('history'); + void b.showPreview(session); + }} + onBrowse={() => b.navigate('history')} + /> +
+

Start a new conversation

+

+ Send starts a separate session in this root. Use a saved + preview to resume or fork existing work. +

+ {composer()} +
-
- Continue a session - -
-
- {sessions.slice(0, 2).map((s) => ( - - ))} - {!sessions.length && ( -

- No saved sessions yet. Start with a task above. -

- )} -
)} diff --git a/src/renderer/components/SessionStartChoice.tsx b/src/renderer/components/SessionStartChoice.tsx new file mode 100644 index 0000000..7055db9 --- /dev/null +++ b/src/renderer/components/SessionStartChoice.tsx @@ -0,0 +1,46 @@ +import type { SessionInfo } from '../api'; +import { timeAgo } from '../useWorkbench'; + +export function SessionStartChoice({ + sessions, + busy, + onPreview, + onBrowse, +}: { + sessions: SessionInfo[]; + busy: boolean; + onPreview: (session: SessionInfo) => void; + onBrowse: () => void; +}) { + const recent = sessions.slice(0, 2); + if (!recent.length) return null; + + return ( +
+
+

Continue saved work

+ +
+

+ Preview a saved conversation before choosing Resume or Fork from here. +

+
+ {recent.map((session) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 7fad3d4..78aab20 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -643,8 +643,26 @@ html[data-reduced-motion='true'] .wb-shell *::after { .wb-continue { border: 1px solid var(--line-soft); } -.wb-continue > p { - padding: 16px; +.wb-session-choice, +.wb-new-conversation { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.wb-session-choice h3, +.wb-new-conversation h3, +.wb-session-choice p, +.wb-new-conversation > p { + margin: 0; +} +.wb-session-choice .wb-section-heading { + flex-wrap: wrap; + gap: 8px 16px; +} +.wb-session-choice .wb-section-heading > button:disabled { + opacity: 0.5; + cursor: default; } .wb-active-layout { display: grid; diff --git a/tests/e2e/session-start.test.ts b/tests/e2e/session-start.test.ts new file mode 100644 index 0000000..9294017 --- /dev/null +++ b/tests/e2e/session-start.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(__dirname, '../..'); +let app: ElectronApplication; +let page: Page; +let fixture: string; +let project: string; +let empty: string; +let source: string; +let sourceBytes: string; +let rpcLog: string; +const errors: string[] = []; +const choice = () => page.getByRole('region', { name: 'Continue saved work' }); +const newSession = () => page.getByRole('button', { name: 'New session', exact: true }).click(); +async function resize(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 capture(name: string) { + for (const directory of ['.tmp/proof', 'test-results/session-start']) { + await fs.mkdir(path.join(ROOT, directory), { recursive: true }); + await page.screenshot({ scale: 'css', path: path.join(ROOT, directory, name) }); + } +} +async function sessionRecords() { + const log = await fs.readFile(rpcLog, 'utf8').catch(() => ''); + return log.split('\n').filter(Boolean).map((line) => JSON.parse(line)) + .filter((record) => record.type !== 'get_available_models'); +} + +describe('session start choice in Electron', () => { + beforeAll(async () => { + fixture = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-start-'))); + project = path.join(fixture, 'parser'); + empty = path.join(fixture, 'empty'); + rpcLog = path.join(fixture, 'rpc.jsonl'); + const userData = path.join(fixture, 'user-data'); + const archives = path.join(fixture, 'sessions/workspace'); + await Promise.all([project, empty, userData, archives].map((dir) => fs.mkdir(dir, { recursive: true }))); + for (const cwd of [project, empty]) { + const git = (...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' }); + git('init', '-b', 'main'); + await fs.writeFile(path.join(cwd, 'app.ts'), 'export const ready = true;\n'); + git('add', '.'); + git('-c', 'user.name=Test', '-c', 'user.email=test@example.test', '-c', 'commit.gpgsign=false', 'commit', '-m', 'Prepare workspace'); + } + const alias = path.join(fixture, 'parser-alias'); + await fs.symlink(project, alias, 'dir'); + for (const [index, title] of ['Review parser recovery', 'Check keyboard focus', 'Older saved task'].entries()) { + const file = path.join(archives, `${index}.jsonl`); + const records = [ + { type: 'session', version: 3, id: `saved-${index}`, cwd: index === 0 ? alias : project, timestamp: new Date(Date.now() - index * 60_000).toISOString() }, + { type: 'message', id: 'u', parentId: null, message: { role: 'user', content: [{ type: 'text', text: title }] } }, + { type: 'message', id: 'a', parentId: 'u', message: { role: 'assistant', content: [{ type: 'text', text: `Saved response for ${title}.` }] } }, + ]; + await fs.writeFile(file, records.map((record) => JSON.stringify(record)).join('\n') + '\n'); + if (index === 0) source = file; + } + sourceBytes = await fs.readFile(source, 'utf8'); + await fs.writeFile(path.join(userData, 'workspaces.json'), JSON.stringify([project, empty])); + app = await electron.launch({ + executablePath: require('electron'), args: ['.'], cwd: ROOT, + env: { + ...process.env, NODE_ENV: 'development', PIDEX_E2E_CWD: project, + 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 resize(1280, 1000); + await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor(); + await page.getByLabel('Workspace group').selectOption({ label: 'parser' }); + await choice().getByRole('button', { name: 'Preview Review parser recovery', exact: true }).waitFor(); + await page.evaluate(() => document.fonts.ready); + }); + afterAll(async () => { + await app?.close(); + if (fixture) await fs.rm(fixture, { recursive: true, force: true }); + }); + + it('previews a chosen saved conversation before opening a runtime session and preserves the separate task draft', async () => { + await page.getByLabel('Message pi').fill('Keep this separate task for later.'); + await choice().scrollIntoViewIfNeeded(); + const order = await page.evaluate(() => ({ + choice: document.querySelector('.wb-session-choice')!.getBoundingClientRect().bottom, + composer: document.querySelector('.wb-composer')!.getBoundingClientRect().top, + })); + expect(order.choice).toBeLessThan(order.composer); + await page.getByText('Send starts a separate session in this root.', { exact: false }).waitFor(); + await capture('new-or-saved-session.png'); + + // Use the second card so accidental auto-selection of the latest archive fails. + const preview = choice().getByRole('button', { name: 'Preview Check keyboard focus', exact: true }); + await preview.focus(); + await page.keyboard.press('Enter'); + await page.locator('.wb-transcript').getByText('Saved response for Check keyboard focus.', { exact: true }).waitFor(); + expect(await page.getByLabel('Message pi').inputValue()).toBe(''); + await page.getByRole('button', { name: 'Resume', exact: true }).waitFor(); + await page.getByLabel('Message pi').fill('Keep this unsent continuation too.'); + await capture('saved-conversation-preview.png'); + await newSession(); + expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this separate task for later.'); + await choice().getByRole('button', { name: 'View all saved sessions', exact: true }).click(); + await page.getByRole('tree', { name: 'Session archives', exact: true }).getByRole('treeitem', { name: /Older saved task/ }).waitFor(); + expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this unsent continuation too.'); + expect(await fs.readFile(source, 'utf8')).toBe(sourceBytes); + expect(await sessionRecords()).toEqual([]); + expect(await page.locator('.wb-sidebar .wb-session-item').filter({ hasText: 'this window' }).count()).toBe(0); + await newSession(); + }); + + it('keeps both choices reachable by scrolling at the native minimum window size', async () => { + await newSession(); + await page.getByLabel('Message pi').fill('Keep this separate task for later.'); + await resize(900, 600); + await choice().scrollIntoViewIfNeeded(); + await capture('new-or-saved-session-narrow.png'); + for (const button of await choice().getByRole('button').all()) { + await button.scrollIntoViewIfNeeded(); + const bounds = await button.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(900); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600); + } + await page.getByLabel('Message pi').scrollIntoViewIfNeeded(); + await page.getByRole('button', { name: 'Send', exact: true }).scrollIntoViewIfNeeded(); + await capture('new-task-composer-narrow.png'); + expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this separate task for later.'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await sessionRecords()).toEqual([]); + await resize(1280, 1000); + }); + + 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.'); + await page.getByLabel('Workspace group').selectOption({ label: 'empty' }); + await expect.poll(() => choice().count()).toBe(0); + expect(await page.getByLabel('Message pi').inputValue()).toBe(''); + await page.getByText('Send starts a separate session in this root.', { exact: false }).waitFor(); + await capture('empty-root-new-session.png'); + await page.getByLabel('Workspace group').selectOption({ label: 'parser' }); + await choice().getByRole('button', { name: 'Preview Review parser recovery', exact: true }).waitFor(); + expect(await page.getByLabel('Message pi').inputValue()).toBe('Keep this separate task for later.'); + expect(await sessionRecords()).toEqual([]); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + await expect.poll(async () => fs.readFile(rpcLog, 'utf8').catch(() => '')).toContain('"type":"prompt"'); + const records = (await fs.readFile(rpcLog, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + const prompt = records.find((record) => record.type === 'prompt'); + expect(prompt).toMatchObject({ cwd: project, message: 'Keep this separate task for later.' }); + expect(prompt.sessionFile).not.toBe(source); + expect(prompt.sessionFile).toContain(`${path.sep}rpc-new${path.sep}`); + expect(await page.locator('.wb-transcript').textContent()).not.toContain('Saved response for'); + expect(await fs.readFile(source, 'utf8')).toBe(sourceBytes); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/integration/SessionStartChoice.test.tsx b/tests/integration/SessionStartChoice.test.tsx new file mode 100644 index 0000000..e0db387 --- /dev/null +++ b/tests/integration/SessionStartChoice.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { SessionStartChoice } from '../../src/renderer/components/SessionStartChoice'; +import type { SessionInfo } from '../../src/renderer/api'; + +const sessions: SessionInfo[] = ['Parser review', 'Keyboard focus', 'Older task'].map((title, index) => ({ + id: 'shared-native-id', file: `/sessions/${index}.jsonl`, cwd: '/code/api', + title, timestamp: '', model: '', messageCount: 2, +})); +afterEach(cleanup); + +describe('new-session continuation choice', () => { + it('previews the chosen archive and browses history only on explicit actions', () => { + const onPreview = vi.fn(); + const onBrowse = vi.fn(); + render(); + expect(screen.getByRole('region', { name: 'Continue saved work' })).toBeVisible(); + expect(screen.getByText(/Preview a saved conversation before choosing Resume/)).toBeVisible(); + expect(screen.queryByText('Older task')).not.toBeInTheDocument(); + expect(onPreview).not.toHaveBeenCalled(); + expect(onBrowse).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Preview Keyboard focus' })); + expect(onPreview).toHaveBeenCalledExactlyOnceWith(sessions[1]); + expect(onBrowse).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'View all saved sessions' })); + expect(onBrowse).toHaveBeenCalledOnce(); + }); + + it('replaces choices with the selected root’s history and retains archives recorded under a root alias', () => { + const callbacks = { onPreview: vi.fn(), onBrowse: vi.fn() }; + const { rerender } = render(); + rerender(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + const aliased = { ...sessions[0], cwd: '/code/original-web-alias', title: 'Web work' }; + rerender(); + expect(screen.getByRole('button', { name: 'Preview Web work' })).toBeVisible(); + expect(screen.queryByText('Parser review')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Preview Web work' })); + expect(callbacks.onPreview).toHaveBeenCalledExactlyOnceWith(aliased); + }); + + it('has no continuation action for an empty history and disables choices during an open', () => { + const callbacks = { onPreview: vi.fn(), onBrowse: vi.fn() }; + const { rerender } = render(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + rerender(); + for (const button of screen.getAllByRole('button')) { + expect(button).toBeDisabled(); + fireEvent.click(button); + } + expect(callbacks.onPreview).not.toHaveBeenCalled(); + expect(callbacks.onBrowse).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/useWorkbench.test.tsx b/tests/integration/useWorkbench.test.tsx index 4fd3f8b..5ffd088 100644 --- a/tests/integration/useWorkbench.test.tsx +++ b/tests/integration/useWorkbench.test.tsx @@ -64,6 +64,39 @@ afterEach(() => { }); describe('workbench root switching', () => { + it('previews a chosen saved conversation without launching Pi or consuming the new-session draft', async () => { + const fixture = apiFixture(); + const source = { + cwd: '/code/api', id: 'source', file: '/sessions/source.jsonl', + title: 'Saved parser work', model: '', timestamp: '', messageCount: 1, + }; + fixture.sessionTree.mockResolvedValue({ data: { leafId: 'saved', canContinue: true, nodes: [] } }); + fixture.previewSession.mockResolvedValue({ data: [ + { role: 'user', content: [{ type: 'text', text: 'Saved parser work' }] }, + ] }); + 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('Keep my separate task')); + await act(async () => { + result.current.navigate('history'); + await result.current.showPreview(source); + }); + expect(result.current.view).toBe('history'); + expect(result.current.preview?.info.file).toBe(source.file); + expect(result.current.preview?.entryId).toBe('saved'); + expect(result.current.draft).toBe(''); + act(() => result.current.onDraft('Unsent continuation')); + act(() => result.current.navigate('new')); + expect(result.current.draft).toBe('Keep my separate task'); + act(() => result.current.navigate('history')); + expect(result.current.draft).toBe('Unsent continuation'); + expect(openSession).not.toHaveBeenCalled(); + expect(sendPrompt).not.toHaveBeenCalled(); + }); + it('keeps inspection pending until the selected root responds and exposes inspection failures', async () => { const fixture = apiFixture(); const pending = deferred>>();