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.

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.

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.
Expand Down
105 changes: 75 additions & 30 deletions src/renderer/components/WorkbenchControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
memo,
useId,
useRef,
useState,
type KeyboardEvent,
Expand Down Expand Up @@ -330,16 +331,69 @@ function useCopy(text: string) {
return { copied, copy };
}

function ToolSection({
title,
text,
stateKey,
expanded,
children,
}: {
title: string;
text: string;
stateKey: string;
expanded: Map<string, boolean>;
children: ReactNode;
}) {
const id = useId();
const [open, setOpen] = useState(
() => expanded.get(stateKey) ?? title === 'Output',
);
const { copied, copy } = useCopy(text);
return (
<div className="wb-tool-section">
<div className="wb-tool-section-head">
<h3>
<button
type="button"
aria-label={`${title} details`}
aria-expanded={open}
aria-controls={id}
onClick={() => {
expanded.set(stateKey, !open);
setOpen(!open);
}}
>
<span aria-hidden="true">{open ? '▾' : '▸'}</span>
{title}
</button>
</h3>
<button
type="button"
className="wb-tool-copy"
aria-label={`Copy ${title.toLowerCase()}`}
onClick={() => void copy()}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<div id={id} hidden={!open}>{open && children}</div>
</div>
);
}

function ToolDiffSection({
text,
oldText,
newText,
stateKey,
expanded,
}: {
text: string;
oldText: string;
newText: string;
stateKey: string;
expanded: Map<string, boolean>;
}) {
const { copied, copy } = useCopy(text);
const [showAll, setShowAll] = useState(false);
const rows = diffLines(oldText, newText);
const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT;
Expand All @@ -355,18 +409,7 @@ function ToolDiffSection({
if (visible.length === 0 && rows.length > 0) visible = [rows[0]];
}
return (
<div className="wb-tool-section">
<div className="wb-tool-section-head">
<h3>Input</h3>
<button
type="button"
className="wb-tool-copy"
aria-label="Copy input"
onClick={() => void copy()}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<ToolSection title="Input" text={text} stateKey={stateKey} expanded={expanded}>
<div className="wb-tool-diff">
{visible.map((row, index) => (
<div
Expand All @@ -388,29 +431,22 @@ function ToolDiffSection({
{showAll ? 'Show less' : 'Show more'}
</button>
)}
</div>
</ToolSection>
);
}

function ToolTextSection({ title, text }: { title: string; text: string }) {
function ToolTextSection({ title, text, stateKey, expanded }: {
title: string;
text: string;
stateKey: string;
expanded: Map<string, boolean>;
}) {
const [showAll, setShowAll] = useState(false);
const { copied, copy } = useCopy(text);
const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT;
const visible =
isLong && !showAll ? text.slice(0, TOOL_TEXT_PREVIEW_LIMIT) : text;
return (
<div className="wb-tool-section">
<div className="wb-tool-section-head">
<h3>{title}</h3>
<button
type="button"
className="wb-tool-copy"
aria-label={`Copy ${title.toLowerCase()}`}
onClick={() => void copy()}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<ToolSection title={title} text={text} stateKey={stateKey} expanded={expanded}>
<pre>{visible}</pre>
{isLong && (
<button
Expand All @@ -421,7 +457,7 @@ function ToolTextSection({ title, text }: { title: string; text: string }) {
{showAll ? 'Show less' : 'Show more'}
</button>
)}
</div>
</ToolSection>
);
}

Expand Down Expand Up @@ -468,12 +504,21 @@ export const TranscriptRow = memo(function TranscriptRow({
text={entry.input || 'No input.'}
oldText={entry.oldText ?? ''}
newText={entry.newText ?? ''}
stateKey={`${entry.key}:input`}
expanded={expanded}
/>
) : (
<ToolTextSection title="Input" text={entry.input || 'No input.'} />
<ToolTextSection
title="Input"
text={entry.input || 'No input.'}
stateKey={`${entry.key}:input`}
expanded={expanded}
/>
)}
<ToolTextSection
title="Output"
stateKey={`${entry.key}:output`}
expanded={expanded}
text={
entry.output ||
(entry.running ? 'Waiting for output…' : 'No output.')
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -727,12 +727,23 @@ html[data-reduced-motion='true'] .wb-shell *::after {
}
.wb-tool-section h3 {
margin: 0;
padding: 8px 12px 0;
flex: 1;
}
.wb-tool-section h3 button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: 0;
background: transparent;
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
}
.wb-tool-section-head {
display: flex;
Expand Down
47 changes: 47 additions & 0 deletions tests/e2e/long-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,53 @@ describe('long session in Electron', () => {
).toBeGreaterThan(500);
});

it('inspects tool sections independently and retains choices after row recycling', async () => {
await page.getByRole('button', { name: /^Sessions/ }).click();
await scroll(false);
const card = page.locator('.wb-tool').first();
await card.locator('summary').click();
const input = card.getByRole('button', { name: 'Input details' });
const output = card.getByRole('button', { name: 'Output details' });
expect(await input.getAttribute('aria-expanded')).toBe('false');
expect(await output.getAttribute('aria-expanded')).toBe('true');
expect(await card.getByText('git diff --stat # iteration 0', { exact: true }).count()).toBe(0);
await card.locator('pre').getByText(/1 file changed/).waitFor();
await card.scrollIntoViewIfNeeded();
const proof = path.join(root, '.tmp/proof');
await fs.mkdir(proof, { recursive: true });
await page.screenshot({ path: path.join(proof, 'tool-output-first.png') });
await page.screenshot({ path: path.join(shotDir, 'tool-output-first.png') });

await input.focus();
await input.press('Enter');
await card.getByText('git diff --stat # iteration 0', { exact: true }).waitFor();
await output.focus();
await output.press('Space');
expect(await output.getAttribute('aria-expanded')).toBe('false');
await card.getByRole('button', { name: 'Copy input' }).click();
await card.getByRole('button', { name: 'Copy input' }).getByText('Copied', { exact: true }).waitFor();
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe('git diff --stat # iteration 0');

// Remove focus from the row so the virtualizer can actually recycle it.
await page.locator('.wb-transcript').focus();
await scroll(true);
await page.locator('.wb-transcript').getByText('Iteration 1499', { exact: true }).waitFor();
await expect.poll(() => page.getByText('git diff --stat # iteration 0', { exact: true }).count()).toBe(0);
await scroll(false);
await page.locator('.wb-transcript').getByText('Iteration 0', { exact: true }).waitFor();
expect(await input.getAttribute('aria-expanded')).toBe('true');
expect(await output.getAttribute('aria-expanded')).toBe('false');
await card.getByText('git diff --stat # iteration 0', { exact: true }).waitFor();
expect(await card.locator('pre').count()).toBe(1);
await card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(proof, 'tool-input-restored.png') });
await page.screenshot({ path: path.join(shotDir, 'tool-input-restored.png') });
await output.click();
await card.locator('summary').click();
await scroll(false);
expect(errors).toEqual([]);
});

it('preserves selection, expansion and measured layout while browsing history', async () => {
await page.getByRole('button', { name: /^Sessions/ }).click();
await page
Expand Down
74 changes: 68 additions & 6 deletions tests/integration/ToolCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ describe('<TranscriptRow> tool card', () => {

it('shows input and output as separate labelled sections when expanded', () => {
const expanded = new Map<string, boolean>([[toolEntry.key, true]]);
const { container } = render(
const { container, getByRole } = render(
<TranscriptRow entry={toolEntry} expanded={expanded} />,
);
fireEvent.click(getByRole('button', { name: 'Input details' }));
const headings = [...container.querySelectorAll('.wb-tool-section h3')].map(
(h) => h.textContent,
(h) => h.querySelector('button')?.getAttribute('aria-label'),
);
expect(headings).toEqual(['Input', 'Output']);
expect(headings).toEqual(['Input details', 'Output details']);
expect(container.textContent).toContain('const before = 1;');
expect(container.textContent).toContain('const after = 2;');
expect(container.textContent).toContain('Edited /repo/src/app.ts');
Expand Down Expand Up @@ -78,9 +79,10 @@ describe('<TranscriptRow> tool card', () => {
newText: 'const keep = 0;\nconst after = 2;',
};
const expanded = new Map<string, boolean>([[editEntry.key, true]]);
const { container } = render(
const { container, getByRole } = render(
<TranscriptRow entry={editEntry} expanded={expanded} />,
);
fireEvent.click(getByRole('button', { name: 'Input details' }));
const del = container.querySelector('.wb-tool-diff-line--del');
const add = container.querySelector('.wb-tool-diff-line--add');
const context = container.querySelector('.wb-tool-diff-line--context');
Expand All @@ -103,9 +105,10 @@ describe('<TranscriptRow> tool card', () => {
output: 'Wrote /repo/new.ts',
};
const expanded = new Map<string, boolean>([[writeEntry.key, true]]);
const { container } = render(
const { container, getByRole } = render(
<TranscriptRow entry={writeEntry} expanded={expanded} />,
);
fireEvent.click(getByRole('button', { name: 'Input details' }));
const added = [...container.querySelectorAll('.wb-tool-diff-line--add')];
expect(added).toHaveLength(2);
expect(added[0]?.textContent).toContain('export const a = 1;');
Expand Down Expand Up @@ -163,7 +166,66 @@ describe('<TranscriptRow> tool card', () => {
expect(getByText('Interrupted')).toBeTruthy();
expect(container.querySelector('summary')?.textContent).not.toContain('Done');
expect(container.textContent).toContain('check before retrying');
expect(container.querySelectorAll('.wb-tool-section pre')[1]?.textContent).toBe('Partial progress');
expect(container.querySelector('.wb-tool-section pre')?.textContent).toBe('Partial progress');
expect(container.querySelector('summary img')?.getAttribute('src')).not.toContain('/check.svg');
});

it('opens output first and toggles input and output independently', () => {
const { getByRole, queryByText } = render(
<TranscriptRow entry={toolEntry} expanded={new Map([[toolEntry.key, true]])} />,
);
const input = getByRole('button', { name: 'Input details' });
const output = getByRole('button', { name: 'Output details' });
expect(input.getAttribute('aria-expanded')).toBe('false');
expect(output.getAttribute('aria-expanded')).toBe('true');
const inputBody = document.getElementById(input.getAttribute('aria-controls')!)!;
expect(inputBody.hidden).toBe(true);
fireEvent.click(input);
expect(inputBody.hidden).toBe(false);
expect(inputBody.textContent).toBe(toolEntry.input);
fireEvent.click(output);
expect(queryByText(toolEntry.output)).toBeNull();
expect(input.getAttribute('aria-expanded')).toBe('true');
expect(document.getElementById(output.getAttribute('aria-controls')!)?.hidden).toBe(true);
});

it('keeps closed output hidden during streaming and reveals the latest result', () => {
const expanded = new Map([[toolEntry.key, true]]);
const running = { ...toolEntry, running: true, output: '' };
const { getByRole, queryByText, rerender } = render(
<TranscriptRow entry={running} expanded={expanded} />,
);
expect(queryByText('Waiting for output…')).toBeTruthy();
fireEvent.click(getByRole('button', { name: 'Output details' }));
rerender(<TranscriptRow entry={{ ...running, output: 'Partial output' }} expanded={expanded} />);
expect(queryByText('Partial output')).toBeNull();
rerender(<TranscriptRow entry={toolEntry} expanded={expanded} />);
expect(queryByText(toolEntry.output)).toBeNull();
fireEvent.click(getByRole('button', { name: 'Output details' }));
expect(queryByText(toolEntry.output)).toBeTruthy();
});

it('restores section choices after remounting without changing other cards', () => {
const expanded = new Map([[toolEntry.key, true]]);
const first = render(<TranscriptRow entry={toolEntry} expanded={expanded} />);
fireEvent.click(first.getByRole('button', { name: 'Input details' }));
fireEvent.click(first.getByRole('button', { name: 'Output details' }));
first.unmount();
const restored = render(<TranscriptRow entry={toolEntry} expanded={expanded} />);
expect(restored.getByRole('button', { name: 'Input details' }).getAttribute('aria-expanded')).toBe('true');
expect(restored.getByRole('button', { name: 'Output details' }).getAttribute('aria-expanded')).toBe('false');
restored.unmount();
const other = render(<TranscriptRow entry={{ ...toolEntry, key: 'other' }} expanded={new Map([...expanded, ['other', true]])} />);
expect(other.getByRole('button', { name: 'Input details' }).getAttribute('aria-expanded')).toBe('false');
expect(other.getByRole('button', { name: 'Output details' }).getAttribute('aria-expanded')).toBe('true');
});

it('shows empty input and output explanations when their sections are open', () => {
const { getByRole, getByText } = render(
<TranscriptRow entry={{ ...toolEntry, input: '', output: '' }} expanded={new Map([[toolEntry.key, true]])} />,
);
expect(getByText('No output.')).toBeTruthy();
fireEvent.click(getByRole('button', { name: 'Input details' }));
expect(getByText('No input.')).toBeTruthy();
});
});
Loading