From c7de40ce59aba8d4f063e56f2342287fbb8e93fa Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:36:19 +0700 Subject: [PATCH] feat: inspect tool input and output independently Co-authored-by: OpenAI Codex --- README.md | 2 + src/renderer/components/WorkbenchControls.tsx | 105 +++++++++++++----- src/renderer/workbench.css | 13 ++- tests/e2e/long-session.test.ts | 47 ++++++++ tests/integration/ToolCard.test.tsx | 74 +++++++++++- 5 files changed, 204 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8546d24..521a9ff 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/renderer/components/WorkbenchControls.tsx b/src/renderer/components/WorkbenchControls.tsx index ca221a8..7624781 100644 --- a/src/renderer/components/WorkbenchControls.tsx +++ b/src/renderer/components/WorkbenchControls.tsx @@ -1,5 +1,6 @@ import { memo, + useId, useRef, useState, type KeyboardEvent, @@ -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; + children: ReactNode; +}) { + const id = useId(); + const [open, setOpen] = useState( + () => expanded.get(stateKey) ?? title === 'Output', + ); + const { copied, copy } = useCopy(text); + return ( +
+
+

+ +

+ +
+ +
+ ); +} + function ToolDiffSection({ text, oldText, newText, + stateKey, + expanded, }: { text: string; oldText: string; newText: string; + stateKey: string; + expanded: Map; }) { - const { copied, copy } = useCopy(text); const [showAll, setShowAll] = useState(false); const rows = diffLines(oldText, newText); const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT; @@ -355,18 +409,7 @@ function ToolDiffSection({ if (visible.length === 0 && rows.length > 0) visible = [rows[0]]; } return ( -
-
-

Input

- -
+
{visible.map((row, index) => (
)} -
+ ); } -function ToolTextSection({ title, text }: { title: string; text: string }) { +function ToolTextSection({ title, text, stateKey, expanded }: { + title: string; + text: string; + stateKey: string; + expanded: Map; +}) { 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 ( -
-
-

{title}

- -
+
{visible}
{isLong && (
+ ); } @@ -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} /> ) : ( - + )} { ).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 diff --git a/tests/integration/ToolCard.test.tsx b/tests/integration/ToolCard.test.tsx index 640ffcb..2881fcd 100644 --- a/tests/integration/ToolCard.test.tsx +++ b/tests/integration/ToolCard.test.tsx @@ -20,13 +20,14 @@ describe(' tool card', () => { it('shows input and output as separate labelled sections when expanded', () => { const expanded = new Map([[toolEntry.key, true]]); - const { container } = render( + const { container, getByRole } = render( , ); + 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'); @@ -78,9 +79,10 @@ describe(' tool card', () => { newText: 'const keep = 0;\nconst after = 2;', }; const expanded = new Map([[editEntry.key, true]]); - const { container } = render( + const { container, getByRole } = render( , ); + 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'); @@ -103,9 +105,10 @@ describe(' tool card', () => { output: 'Wrote /repo/new.ts', }; const expanded = new Map([[writeEntry.key, true]]); - const { container } = render( + const { container, getByRole } = render( , ); + 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;'); @@ -163,7 +166,66 @@ describe(' 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( + , + ); + 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( + , + ); + expect(queryByText('Waiting for output…')).toBeTruthy(); + fireEvent.click(getByRole('button', { name: 'Output details' })); + rerender(); + expect(queryByText('Partial output')).toBeNull(); + rerender(); + 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(); + fireEvent.click(first.getByRole('button', { name: 'Input details' })); + fireEvent.click(first.getByRole('button', { name: 'Output details' })); + first.unmount(); + const restored = render(); + 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(); + 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( + , + ); + expect(getByText('No output.')).toBeTruthy(); + fireEvent.click(getByRole('button', { name: 'Input details' })); + expect(getByText('No input.')).toBeTruthy(); + }); });