Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 7 additions & 6 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,12 +613,13 @@ function App() {
: ''}
</p>
</div>
<BracketButton
primary
onClick={() => b.navigate('changes')}
>
Review changes
</BracketButton>
<WorkspaceStatus
context="history"
workspace={ws}
details={details}
ready={b.detailsRoot === ws.dirName}
onReview={() => b.navigate('changes')}
/>
</div>
{preview.tree?.warning && (
<p className="wb-meta" role="status">
Expand Down
32 changes: 28 additions & 4 deletions src/renderer/components/WorkspaceStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,10 +31,15 @@ export function WorkspaceStatus({
: 'No changes in this root';
const inspected = !unavailable && ready && !details.gitError;

return (
<section className="wb-workspace-status" aria-label="Workspace Git status">
const content = (
<>
<div>
<h3>Workspace changes</h3>
{context === 'new' && <h3>Workspace changes</h3>}
{context === 'history' && (
<p className="wb-meta">
Saved history does not restore files or switch Git branches.
</p>
)}
<p role="status">{message}</p>
{inspected && (
<p className="wb-meta">
Expand All @@ -43,6 +50,23 @@ export function WorkspaceStatus({
<BracketButton disabled={!inspected} onClick={onReview}>
Review workspace changes
</BracketButton>
</section>
</>
);
if (context === 'history') {
return (
<section className="wb-history-workspace-status" aria-label="Workspace Git status">
<details>
<summary>
Current workspace changes
<span className="wb-meta" title={inspected ? `${details.branch || 'Detached HEAD'} · ${message}` : message}>
{inspected && <span className="wb-history-status-branch">{details.branch || 'Detached HEAD'}</span>}
<span>{inspected && ' · '}{message}</span>
</span>
</summary>
<div className="wb-workspace-status">{content}</div>
</details>
</section>
);
}
return <section className="wb-workspace-status" aria-label="Workspace Git status">{content}</section>;
}
64 changes: 61 additions & 3 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
82 changes: 77 additions & 5 deletions tests/e2e/workspace-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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');
Expand All @@ -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');
});
Expand All @@ -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.');
Expand All @@ -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');
Expand Down
9 changes: 9 additions & 0 deletions tests/integration/WorkspaceStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ const change: GitChange = {
afterEach(cleanup);

describe('workspace Git status', () => {
it('distinguishes current files from saved conversation history', () => {
const { container } = render(<WorkspaceStatus context="history" workspace={workspace} details={details} ready onReview={vi.fn()} />);
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(<WorkspaceStatus workspace={workspace} ready details={{
Expand Down
Loading