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 @@ -51,6 +51,8 @@ These are captures of real Electron interactions with a temporary Git workspace
- Switch roots while sessions run in the background; retain per-root drafts, views and model defaults.
- Use quick switch for roots, sessions, files, and commands.

The new-session view shows the selected root's branch and changed-file counts before you send a task. A partially staged file counts once overall and appears in both staged and unstaged totals. Use **Review workspace changes** to open Changes and keep your task draft. Counts follow the existing refresh on focus, navigation and every 15 seconds; unavailable folders and failed inspections never appear clean.

Expanded tool cards show output first. Open Input or close Output independently, and copy either section while it is closed. These choices stay with each card while scrolling through the transcript; reopening the transcript resets them.

Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so forks extract a native branch archive before launching Pi so even runtime startup writes preserve the source. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits.
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SessionTree, EntryTree } from './components/SessionTree';
import { HistorySummary } from './components/HistorySummary';
import { HistoryExport } from './components/HistoryExport';
import { HistoryHandoff } from './components/HistoryHandoff';
import { WorkspaceStatus } from './components/WorkspaceStatus';
import { useEffect, useState } from 'react';
import { PidexMark } from './components/PidexMark';
import welcomeMark from './assets/workbench/welcome-mark.svg';
Expand Down Expand Up @@ -388,6 +389,12 @@ function App() {
</label>
<span className="wb-meta">{ws.cwd}</span>
</div>
<WorkspaceStatus
workspace={ws}
details={details}
ready={b.detailsRoot === ws.dirName}
onReview={() => b.navigate('changes')}
/>
{composer()}
<div className="wb-starters">
<button
Expand Down
48 changes: 48 additions & 0 deletions src/renderer/components/WorkspaceStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { WorkspaceDetails, WorkspaceInfo } from '../api';
import { BracketButton } from './BracketButton';

export function WorkspaceStatus({
workspace,
details,
ready,
onReview,
}: {
workspace: WorkspaceInfo;
details: WorkspaceDetails;
ready: boolean;
onReview: () => void;
}) {
const unavailable = workspace.availability !== 'available';
const changes = details.changes;
const count = new Set(changes.map((change) => change.path)).size;
const staged = changes.filter((change) => change.staged).length;
const unstaged = changes.filter((change) => !change.staged && !change.untracked).length;
const untracked = changes.filter((change) => change.untracked).length;
const message = unavailable
? 'Restore this folder to inspect Git changes.'
: !ready
? 'Checking Git changes…'
: details.gitError
? details.gitError
: count
? `${count} changed ${count === 1 ? 'file' : 'files'}`
: 'No changes in this root';
const inspected = !unavailable && ready && !details.gitError;

return (
<section className="wb-workspace-status" aria-label="Workspace Git status">
<div>
<h3>Workspace changes</h3>
<p role="status">{message}</p>
{inspected && (
<p className="wb-meta">
{details.branch || 'Detached HEAD'} · {staged} staged · {unstaged} unstaged · {untracked} untracked
</p>
)}
</div>
<BracketButton disabled={!inspected} onClick={onReview}>
Review workspace changes
</BracketButton>
</section>
);
}
38 changes: 32 additions & 6 deletions src/renderer/useWorkbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const DEFAULTS: Defaults = {
thinking: 'medium',
};
const EMPTY_DETAILS: WorkspaceDetails = { branch: '', changes: [], files: [] };
const UNAVAILABLE_DETAILS: WorkspaceDetails = {
...EMPTY_DETAILS,
gitError: 'Workspace inspection is unavailable. Return to this root to try again.',
};

export function timeAgo(iso: string): string {
if (!iso || !Number.isFinite(Date.parse(iso))) return 'local';
Expand Down Expand Up @@ -73,6 +77,7 @@ export function useWorkbench() {
const generation = useRef(0);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [details, setDetails] = useState(EMPTY_DETAILS);
const [detailsRoot, setDetailsRoot] = useState('');
const [view, setView] = useState<View>('new');
const [tabs, setTabs] = useState<SessionTab[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
Expand Down Expand Up @@ -186,6 +191,7 @@ export function useWorkbench() {
useEffect(() => {
let cancelled = false;
setDetails(EMPTY_DETAILS);
setDetailsRoot('');
setSessions([]);
setPreviewLoading(false);
setModels([]);
Expand Down Expand Up @@ -222,10 +228,17 @@ export function useWorkbench() {
api
.inspectWorkspace(ws.cwd)
.then((r) => {
if (!cancelled) setDetails(unwrap(r));
if (!cancelled) {
setDetails(unwrap(r));
setDetailsRoot(ws.dirName);
}
})
.catch((error) => {
if (!cancelled) report(error);
if (!cancelled) {
setDetails(UNAVAILABLE_DETAILS);
setDetailsRoot(ws.dirName);
report(error);
}
});
api
.models(ws.cwd)
Expand Down Expand Up @@ -262,9 +275,19 @@ export function useWorkbench() {
if (currentCwd.current === ws.cwd && token === generation.current)
setSessions(list);
if (ws.availability !== 'available') return;
const result = await api.inspectWorkspace(ws.cwd);
if (currentCwd.current === ws.cwd && token === generation.current)
setDetails(unwrap(result));
try {
const result = await api.inspectWorkspace(ws.cwd);
if (currentCwd.current === ws.cwd && token === generation.current) {
setDetails(unwrap(result));
setDetailsRoot(ws.dirName);
}
} catch (error) {
if (currentCwd.current === ws.cwd && token === generation.current) {
setDetails(UNAVAILABLE_DETAILS);
setDetailsRoot(ws.dirName);
}
throw error;
}
}, [api, ws]);
useEffect(() => {
if (!ws) return;
Expand Down Expand Up @@ -700,12 +723,15 @@ export function useWorkbench() {
ws,
sessions,
details,
detailsRoot,
setDetails: (next: WorkspaceDetails) => {
if (
currentCwd.current === ws?.cwd &&
generation.current === renderGeneration
)
) {
setDetails(next);
setDetailsRoot(ws?.dirName ?? '');
}
},
view,
navigate,
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,32 @@ html[data-reduced-motion='true'] .wb-shell *::after {
gap: 16px;
min-height: 80px;
}
.wb-workspace-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 16px;
border: 1px solid var(--line);
border-radius: 4px;
}
.wb-workspace-status h3 {
margin: 0 0 6px;
font-size: 13px;
}
.wb-workspace-status > div {
flex: 1 1 300px;
min-width: 0;
overflow-wrap: anywhere;
}
.wb-workspace-status > button {
max-width: 100%;
white-space: normal;
}
.wb-workspace-status p {
margin: 4px 0 0;
}
.wb-start-title > svg {
flex-shrink: 0;
}
Expand Down
117 changes: 117 additions & 0 deletions tests/e2e/workspace-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 clean: 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) });
}
}
async function select(name: string) {
await page.getByLabel('Workspace group').selectOption({ label: name });
await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor();
}

describe('workspace status in Electron', () => {
beforeAll(async () => {
fixture = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-status-')));
project = path.join(fixture, 'project');
clean = path.join(fixture, 'clean');
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'));
for (const cwd of [project, clean]) {
git(cwd, 'init', '-b', 'main');
await fs.writeFile(path.join(cwd, 'app.ts'), 'export const value = 1;\n');
git(cwd, 'add', '.');
git(cwd, '-c', 'user.name=Test', '-c', 'user.email=test@example.test', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture');
}
git(project, 'switch', '-c', 'feature/workspace-status');
await fs.writeFile(path.join(project, 'app.ts'), 'export const value = 2;\n');
git(project, 'add', 'app.ts');
await fs.writeFile(path.join(project, 'app.ts'), 'export const value = 3;\n');
await fs.writeFile(path.join(project, 'notes.txt'), 'Review before starting Pi.\n');
await fs.writeFile(path.join(userData, 'workspaces.json'), JSON.stringify([project, clean, plain, path.join(fixture, 'missing')]));
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'),
},
});
page = await app.firstWindow();
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor();
await select('project');
});
afterAll(async () => {
await app?.close();
if (fixture) await fs.rm(fixture, { recursive: true, force: true });
});

it('shows mixed Git changes before starting Pi and opens Changes without losing the task draft', async () => {
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('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 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 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.');
await page.getByLabel('Commit message').waitFor();
await page.getByRole('button', { name: 'New session', exact: true }).click();
expect(await page.getByLabel('Message pi').inputValue()).toBe('Review the existing changes before editing.');
expect(await page.locator('.wb-sidebar .wb-session-item').filter({ hasText: 'this window' }).count()).toBe(0);
});

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');
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect.poll(() => status().textContent()).toContain('1 changed file');
expect(await status().textContent()).toContain('1 staged · 0 unstaged · 0 untracked');
await select('clean');
await expect.poll(() => status().textContent()).toContain('No changes in this root');
expect(await status().textContent()).toContain('main · 0 staged · 0 unstaged · 0 untracked');
git(clean, 'checkout', '--detach');
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect.poll(() => status().textContent()).toContain('Detached HEAD');
expect(await status().textContent()).not.toContain('feature/workspace-status');
});

it('does not describe non-Git folders or missing roots as clean', async () => {
await select('plain');
await expect.poll(() => status().textContent()).toContain('Git changes are unavailable for this folder.');
expect(await status().getByRole('button').isDisabled()).toBe(true);
await select('missing');
await expect.poll(() => status().textContent()).toContain('Restore this folder to inspect Git changes.');
expect(await status().getByRole('button').isDisabled()).toBe(true);
expect(await status().textContent()).not.toContain('No changes in this root');
expect(errors).toEqual([]);
});
});
50 changes: 50 additions & 0 deletions tests/integration/WorkspaceStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { WorkspaceStatus } from '../../src/renderer/components/WorkspaceStatus';
import type { GitChange, WorkspaceDetails, WorkspaceInfo } from '../../src/renderer/api';

const workspace: WorkspaceInfo = {
dirName: 'api', name: 'api', cwd: '/repo/api', branch: 'old-branch',
availability: 'available', sessionCount: 0, lastActive: '',
};
const details: WorkspaceDetails = { branch: 'feature/status', changes: [], files: [] };
const change: GitChange = {
path: 'app.ts', additions: 1, deletions: 1, staged: false, untracked: false,
};
afterEach(cleanup);

describe('workspace Git status', () => {
it('counts a partially staged file once overall and in both change groups', () => {
const onReview = vi.fn();
render(<WorkspaceStatus workspace={workspace} ready details={{
...details,
changes: [change, { ...change, staged: true }, { ...change, path: 'new.ts', untracked: true }],
}} onReview={onReview} />);
expect(screen.getByRole('status')).toHaveTextContent('2 changed files');
expect(screen.getByText('feature/status · 1 staged · 1 unstaged · 1 untracked')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'Review workspace changes' }));
expect(onReview).toHaveBeenCalledOnce();
});

it('shows a clean root only after inspection and explains detached HEAD', () => {
const { rerender } = render(<WorkspaceStatus workspace={workspace} details={details} ready onReview={vi.fn()} />);
expect(screen.getByRole('status')).toHaveTextContent('No changes in this root');
expect(screen.getByRole('button')).toBeEnabled();
rerender(<WorkspaceStatus workspace={workspace} details={{ ...details, branch: '', changes: [change] }} ready onReview={vi.fn()} />);
expect(screen.getByRole('status')).toHaveTextContent('1 changed file');
expect(screen.getByText('Detached HEAD · 0 staged · 1 unstaged · 0 untracked')).toBeVisible();
});

it.each([
{ ready: false, availability: 'available' as const, gitError: undefined, message: 'Checking Git changes…' },
{ ready: true, availability: 'missing' as const, gitError: undefined, message: 'Restore this folder to inspect Git changes.' },
{ ready: true, availability: 'unavailable' as const, gitError: undefined, message: 'Restore this folder to inspect Git changes.' },
{ ready: true, availability: 'available' as const, gitError: 'Git changes are unavailable for this folder.', message: 'Git changes are unavailable for this folder.' },
])('does not claim a clean root when $message', ({ ready, availability, gitError, message }) => {
render(<WorkspaceStatus workspace={{ ...workspace, availability }} details={{ ...details, gitError }} ready={ready} onReview={vi.fn()} />);
expect(screen.getByRole('status')).toHaveTextContent(message);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.queryByText('No changes in this root')).not.toBeInTheDocument();
expect(screen.queryByText(/old-branch|feature\/status/)).not.toBeInTheDocument();
});
});
Loading
Loading