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
20 changes: 14 additions & 6 deletions src/renderer/components/WorkbenchControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,17 +396,20 @@ function ToolDiffSection({
}) {
const [showAll, setShowAll] = useState(false);
const rows = diffLines(oldText, newText);
const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT;
const isLong =
rows.reduce((length, row) => length + row.text.length + 1, 0) >
TOOL_TEXT_PREVIEW_LIMIT;
let visible = rows;
if (isLong && !showAll) {
visible = [];
let used = 0;
let remaining = TOOL_TEXT_PREVIEW_LIMIT;
for (const row of rows) {
used += row.text.length + 1;
if (used > TOOL_TEXT_PREVIEW_LIMIT) break;
visible.push(row);
if (remaining <= 0) break;
// Include the diff marker in the budget and clip even a single long row.
const clipped = row.text.slice(0, remaining - 1);
visible.push({ ...row, text: clipped });
remaining -= clipped.length + 1;
}
if (visible.length === 0 && rows.length > 0) visible = [rows[0]];
}
return (
<ToolSection title="Input" text={text} stateKey={stateKey} expanded={expanded}>
Expand All @@ -422,6 +425,11 @@ function ToolDiffSection({
))}
{visible.length === 0 && <div className="wb-tool-diff-line">No changes.</div>}
</div>
{isLong && !showAll && (
<p className="wb-tool-notice wb-muted">
Input preview shortened. Show more or copy the full input.
</p>
)}
{isLong && (
<button
type="button"
Expand Down
119 changes: 119 additions & 0 deletions tests/e2e/tool-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

const root = path.resolve(__dirname, '../..');
const oldText = `const settings = {${'enabled: false, '.repeat(400)}}; // old-end`;
const newText = `const settings = {${'enabled: true, '.repeat(400)}}; // new-end`;
const shots = path.join(root, 'test-results/tool-preview');
let app: ElectronApplication;
let page: Page;
let fixture: string;
const errors: string[] = [];

describe('bounded tool previews in Electron', () => {
beforeAll(async () => {
fixture = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-tool-preview-'));
const project = path.join(fixture, 'project');
const archives = path.join(fixture, 'sessions/workspace');
await Promise.all([
fs.mkdir(project),
fs.mkdir(archives, { recursive: true }),
fs.mkdir(path.join(fixture, 'user-data')),
fs.mkdir(path.join(root, '.tmp/proof'), { recursive: true }),
fs.mkdir(shots, { recursive: true }),
]);
for (const tool of ['write', 'edit']) {
const records = [
{ type: 'session', version: 3, id: tool, cwd: project, timestamp: new Date().toISOString() },
{ type: 'message', id: 'task', parentId: null, message: {
role: 'user', content: [{ type: 'text', text: `Inspect long ${tool} input` }],
} },
{ type: 'message', id: 'call', parentId: 'task', message: {
role: 'assistant', content: [{ type: 'toolCall', id: tool, name: tool, arguments: {
path: 'settings.js',
...(tool === 'write' ? { content: newText } : { oldText, newText }),
} }],
} },
{ type: 'message', id: 'result', parentId: 'call', message: {
role: 'toolResult', toolCallId: tool, toolName: tool,
content: [{ type: 'text', text: 'Updated settings.js' }],
} },
];
await fs.writeFile(path.join(archives, `${tool}.jsonl`), records.map((r) => JSON.stringify(r)).join('\n'));
}
app = await electron.launch({
executablePath: require('electron'),
args: ['.'],
cwd: root,
env: {
...process.env,
PIDEX_E2E_CWD: project,
PIDEX_E2E_RENDERER_URL: inject('rendererUrl'),
PIDEX_E2E_USER_DATA: path.join(fixture, 'user-data'),
PIDEX_SESSIONS_ROOT: path.join(fixture, 'sessions'),
PIDEX_PI: path.join(root, 'tests/fixtures/pi-rpc.cjs'),
},
});
page = await app.firstWindow();
page.setDefaultTimeout(10_000);
page.on('pageerror', (error) => errors.push(error.message));
await page.getByRole('heading', { name: 'Start a session', exact: true }).waitFor();
await page.setViewportSize({ width: 1280, height: 1100 });
await page.evaluate(() => document.fonts.ready);
await page.getByRole('button', { name: /^Sessions/ }).click();
});

afterAll(async () => {
await app?.close();
if (fixture) await fs.rm(fixture, { recursive: true, force: true });
});

it.each(['write', 'edit'])('bounds a long %s line, copies the full input and expands on request', async (tool) => {
await page.getByRole('tree', { name: 'Session archives', exact: true })
.getByRole('treeitem', { name: new RegExp(`Inspect long ${tool} input`) }).click();
const card = page.locator('.wb-tool');
await card.locator('summary').click();
await card.getByRole('button', { name: 'Output details' }).click();
await card.getByRole('button', { name: 'Input details' }).click();
const diff = card.locator('.wb-tool-diff');
await expect.poll(async () => (await diff.textContent())?.length).toBe(2000);
expect(await diff.textContent()).not.toContain('new-end');
await card.getByText('Input preview shortened. Show more or copy the full input.').waitFor();
await card.getByRole('button', { name: 'Copy input' }).click();
await card.getByText('Copied', { exact: true }).waitFor();
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe(
tool === 'write' ? `settings.js\n\n${newText}` : `settings.js\n\nOld:\n${oldText}\n\nNew:\n${newText}`,
);
await card.getByRole('button', { name: 'Show more' }).focus();
await page.keyboard.press('Enter');
expect(await diff.textContent()).toContain(newText);
if (tool === 'edit') expect(await diff.textContent()).toContain(oldText);
await card.getByRole('button', { name: 'Show less' }).click();
expect((await diff.textContent())?.length).toBe(2000);
await card.locator('summary').scrollIntoViewIfNeeded();
// Expansion changes the virtual row height. Wait for measurement before
// capturing so the history footer has moved below the resized card.
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}));
await expect.poll(async () => {
const outputBounds = await card.getByRole('button', { name: 'Output details' }).boundingBox();
const footerBounds = await page.getByRole('button', { name: 'Fork from here', exact: true }).boundingBox();
return !!outputBounds && !!footerBounds && footerBounds.y >= outputBounds.y + outputBounds.height;
}).toBe(true);
const screenshot = `${tool}-bounded-preview.png`;
await page.screenshot({ path: path.join(root, '.tmp/proof', screenshot) });
await page.screenshot({ path: path.join(shots, screenshot) });

await page.setViewportSize({ width: 900, height: 600 });
await card.getByRole('button', { name: 'Show more' }).scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.body.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: path.join(root, '.tmp/proof', `${tool}-bounded-narrow.png`) });
await page.screenshot({ path: path.join(shots, `${tool}-bounded-narrow.png`) });
await page.setViewportSize({ width: 1280, height: 1100 });
expect(errors).toEqual([]);
});
});
64 changes: 63 additions & 1 deletion tests/integration/ToolCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { TranscriptRow } from '../../src/renderer/components/WorkbenchControls';
import { TOOL_TEXT_PREVIEW_LIMIT, TranscriptRow } from '../../src/renderer/components/WorkbenchControls';
import type { Entry } from '../../src/renderer/timeline';

const toolEntry: Extract<Entry, { kind: 'tool' }> = {
Expand Down Expand Up @@ -115,6 +115,68 @@ describe('<TranscriptRow> tool card', () => {
expect(container.querySelector('.wb-tool-diff-line--del')).toBeNull();
});

it.each(['edit', 'write'])('bounds a single long %s line and preserves full inspection and copying', async (tool) => {
const oldText = tool === 'edit' ? `before-${'x'.repeat(5000)}-old-end` : '';
const newText = `after-${'y'.repeat(5000)}-new-end`;
const input = tool === 'edit'
? `${toolEntry.args}\n\nOld:\n${oldText}\n\nNew:\n${newText}`
: `${toolEntry.args}\n\n${newText}`;
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
const entry = { ...toolEntry, tool, input, oldText, newText };
const { container, getByRole, queryByText } = render(
<TranscriptRow entry={entry} expanded={new Map([[entry.key, true]])} />,
);
fireEvent.click(getByRole('button', { name: 'Input details' }));
const diff = container.querySelector('.wb-tool-diff')!;
expect(diff.textContent?.length).toBe(TOOL_TEXT_PREVIEW_LIMIT);
expect(diff.textContent).not.toContain('-new-end');
expect(diff.textContent).not.toContain('-old-end');
expect(queryByText('Input preview shortened. Show more or copy the full input.')).toBeTruthy();

fireEvent.click(getByRole('button', { name: 'Copy input' }));
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(input));
fireEvent.click(getByRole('button', { name: 'Show more' }));
expect(diff.textContent).toContain(newText);
if (oldText) expect(diff.textContent).toContain(oldText);
expect(queryByText('Input preview shortened. Show more or copy the full input.')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show less' }));
expect(diff.textContent?.length).toBe(TOOL_TEXT_PREVIEW_LIMIT);
});

it('bounds multiple diff rows including the final partial row', () => {
const lines = Array.from({ length: 4 }, (_, index) => `${index}-${'x'.repeat(700)}`);
const entry = { ...toolEntry, oldText: '', newText: lines.join('\n') };
const { container, getByRole } = render(
<TranscriptRow entry={entry} expanded={new Map([[entry.key, true]])} />,
);
fireEvent.click(getByRole('button', { name: 'Input details' }));
const diff = container.querySelector('.wb-tool-diff')!;
expect(diff.textContent?.length).toBe(TOOL_TEXT_PREVIEW_LIMIT);
const rows = diff.querySelectorAll('.wb-tool-diff-line');
expect(rows).toHaveLength(3);
expect(rows[0].textContent).toBe(`+${lines[0]}`);
expect(rows[2].textContent).toMatch(/^\+2-x+$/);
expect(rows[2].textContent?.length).toBeLessThan(lines[2].length);
fireEvent.click(getByRole('button', { name: 'Show more' }));
expect(diff.querySelectorAll('.wb-tool-diff-line')).toHaveLength(4);
expect(diff.textContent).toContain(lines[3]);
});

it.each([TOOL_TEXT_PREVIEW_LIMIT - 1, TOOL_TEXT_PREVIEW_LIMIT])('does not shorten a %i-character diff because of input path or labels', (length) => {
const newText = 'x'.repeat(length - 1);
const entry = { ...toolEntry, input: `${toolEntry.args}\n\n${newText}`, oldText: '', newText };
const { container, getByRole, queryByRole } = render(
<TranscriptRow entry={entry} expanded={new Map([[entry.key, true]])} />,
);
fireEvent.click(getByRole('button', { name: 'Input details' }));
expect(container.querySelector('.wb-tool-diff')?.textContent).toBe(`+${newText}`);
expect(queryByRole('button', { name: 'Show more' })).toBeNull();
});

it('copies the exact plain input text from a diff card', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
Expand Down
Loading