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
85 changes: 85 additions & 0 deletions propr-ui/e2e/planner-scope-estimate.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { capture } from './planner-studio.fixture';
import { modes, openSetup, routeSetup, viewports } from './planner-setup.fixture';

// #3257: the full Context Scope header kept `≤490k tokens · ~1-2m scan` on one line beside the title, so at md
// widths the 35% right pane pushed the setup canvas sideways (833px of content at a 768px viewport, 906px at 880px).
const estimatePattern = /^≤\d+(\.\d+)?[kM] tokens · (<1m|~1-2m|~3-5m) scan$/;

// Every scroll container from the slider up to the document, any of which would let the canvas scroll sideways,
// plus every element of the scope block that reaches past the right pane.
const horizontalOverflow = (scope: Locator) => scope.evaluate(element => {
const problems: string[] = [];
for (let node: Element | null = element; node; node = node.parentElement) {
const style = getComputedStyle(node);
const scrolls = node === document.documentElement || ['auto', 'scroll'].includes(style.overflowX);
if (scrolls && node.scrollWidth > node.clientWidth) problems.push(`${node.tagName.toLowerCase()}.${node.getAttribute('class') ?? ''} ${node.clientWidth}/${node.scrollWidth}`);
}
const pane = element.closest('[data-testid="setup-wizard-right-pane"]') ?? document.documentElement;
const { left, right } = pane.getBoundingClientRect();
for (const node of [element, ...Array.from(element.querySelectorAll('*'))]) {
const box = node.getBoundingClientRect();
if (box.width && (box.right > right + 0.5 || box.left < left - 0.5)) problems.push(`${node.tagName.toLowerCase()} ${node.textContent?.slice(0, 30)} ${box.left}-${box.right} outside ${left}-${right}`);
}
return problems;
});

async function expectReadableScope(page: Page, width: number, level: number, typed: string) {
const estimate = page.getByTestId('context-scope-estimate');
const slider = page.getByRole('slider');
const scope = width >= 768 ? page.getByTestId('setup-wizard-right-pane').locator(':scope > div').first() : page.getByTestId('setup-settings-group').locator(':scope > div', { has: slider });
await scope.scrollIntoViewIfNeeded();
expect(await horizontalOverflow(scope)).toEqual([]);
// The whole budget and scan time are there, unclipped, in their own box and at a readable size.
// The separator is held to the token budget by a no-break space; read as plain spaces it is the same estimate.
const text = (await estimate.textContent())!.replace(/\u00a0/g, ' ');
expect(width >= 768 ? text : text.replace(/^.+? · /, '')).toMatch(estimatePattern);
expect(await estimate.evaluate(element => element.scrollWidth <= element.clientWidth && getComputedStyle(element).textOverflow !== 'ellipsis')).toBe(true);
expect(await estimate.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11);
await expect(estimate).toBeInViewport({ ratio: 1 });
await expect(slider).toHaveValue(String(level));
await expect(slider).toBeInViewport({ ratio: 1 });
if (width >= 768) {
await expect(scope.getByText('Context Scope', { exact: true })).toBeInViewport({ ratio: 1 });
await expect(scope.getByText(`${level}%`, { exact: true })).toBeInViewport({ ratio: 1 });
for (const shortcut of ['Focused', 'Expanded', 'Full Scan']) await expect(scope.getByRole('button', { name: shortcut, exact: true })).toBeInViewport({ ratio: 1 });
} else {
await expect(page.getByTestId('context-scope-descriptor')).toHaveText(new RegExp(`^${level}% \\(`));
await expect(page.getByTestId('context-scope-descriptor')).toBeInViewport({ ratio: 1 });
}
await expect(page.getByPlaceholder(/Describe the feature/)).toHaveValue(typed);
}

for (const mode of modes) {
test(`the ${mode.name}-plan scope estimate stays inside the canvas at every width`, async ({ page }) => {
const writes = await routeSetup(page);
await openSetup(page, mode);
let level = mode.level;
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await expectReadableScope(page, viewport.width, level, mode.typed);
if (viewport.width === 768 || viewport.width === 880) await capture(page, `planner-scope-estimate-${mode.name}-${viewport.width}`);
// The slider still steps from the keyboard at this width, and the header keeps fitting at the new value.
await page.getByRole('slider').focus();
await page.keyboard.press(level > 10 ? 'ArrowLeft' : 'ArrowRight');
level += level > 10 ? -10 : 10;
await expectReadableScope(page, viewport.width, level, mode.typed);
}
expect(writes).toEqual([]);
});

for (const scale of [150, 200]) {
test(`the ${mode.name}-plan scope estimate stays inside the canvas with ${scale}% text`, async ({ page }) => {
const writes = await routeSetup(page);
await openSetup(page, mode, scale);
for (const viewport of [viewports[1], viewports[2], viewports[3], viewports[4], viewports[5]]) {
await page.setViewportSize(viewport);
// No scroll container from the scope block up to the document may scroll sideways, enlarged text included;
// planner-whole-canvas.pw.ts holds the rest of the canvas to the same rule.
await expectReadableScope(page, viewport.width, mode.level, mode.typed);
if (scale === 200 && viewport.width === 768) await capture(page, `planner-scope-estimate-${mode.name}-768-200pct`);
}
expect(writes).toEqual([]);
});
}
}
54 changes: 54 additions & 0 deletions propr-ui/e2e/planner-setup.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, type Page } from '@playwright/test';
import { fixture } from './planner-studio.fixture';

// Shared by the planner setup layout specs: a successful branch lookup and context preview, so no header error is in
// play, and a record of every write the page makes.
export const repository = 'integry/propr';
export const prompt = 'Keep the planner scope estimate inside medium-width layouts.';
const preview = {
success: true, warnings: [],
stats: { totalTokens: 312_000, costEstimate: 1.2, contextLength: 900_000, fileCount: 2, modelMaxContextTokens: 1_000_000 },
smartSelection: [
{ path: 'packages/core/src/services/planning/previewService.ts', reason: 'Matched prompt keywords', source: 'auto', score: 92 },
{ path: 'propr-ui/src/components/TaskPlanner/ContextLevelSlider.tsx', reason: 'Matched prompt keywords', source: 'auto', score: 88 },
],
};

export async function routeSetup(page: Page) {
await fixture(page);
await page.route(`**/api/github/repos/${repository}/branches`, route => route.fulfill({ json: { defaultBranch: 'main', branches: ['main'] } }));
await page.route('**/api/planner/preview', route => route.fulfill({ json: preview }));
const writes: string[] = [];
page.on('request', request => {
const path = new URL(request.url()).pathname;
// The context preview is a read the planner POSTs; anything else that is not a GET would be a write.
if (request.method() === 'GET' || !path.startsWith('/api/') || path === '/api/planner/preview') return;
// An existing draft autosaves its typed prompt, and only that: resizing and the slider must add nothing.
const body = request.postDataJSON() as Record<string, unknown> | null;
if (request.method() === 'PUT' && path === '/api/planner/drafts/plan-setup' && body?.initial_prompt === prompt && Object.keys(body).every(key => ['initial_prompt', 'name'].includes(key))) return;
writes.push(`${request.method()} ${path} ${request.postData()}`);
});
return writes;
}

// Typing into a new plan creates its draft a second later, turning it into an edit, so the new plan keeps an empty
// prompt (and sends nothing at all), while the existing draft carries an edited, not yet generated prompt through every step.
export const modes = [
{ name: 'new', path: '/studio/new', level: 50, estimate: '≤490k tokens · ~1-2m scan', typed: '' },
{ name: 'edit', path: '/studio/plan-setup', level: 100, estimate: '≤980k tokens · ~3-5m scan', typed: prompt },
];
export const viewports = [
{ width: 320, height: 640 }, { width: 390, height: 844 }, { width: 768, height: 900 }, { width: 842, height: 680 },
{ width: 880, height: 620 }, { width: 1280, height: 800 }, { width: 1390, height: 864 },
];

/** Opens the setup canvas with its preview and model metadata resolved, at the given root text size. */
export async function openSetup(page: Page, mode: typeof modes[number], scale = 100) {
await page.setViewportSize(viewports[2]);
await page.goto(mode.path);
await expect(page.getByTestId('context-scope-estimate')).toHaveText(mode.estimate);
// The footer is measured with the model name it really shows, not the loading placeholder it starts with.
await expect(page.getByTestId('planner-model-selector')).toHaveText('Claude Opus 5.5 (Default)');
if (scale !== 100) await page.addStyleTag({ content: `html { font-size: ${scale}% !important; }` });
if (mode.typed) await page.getByPlaceholder(/Describe the feature/).fill(mode.typed);
}
119 changes: 119 additions & 0 deletions propr-ui/e2e/planner-whole-canvas.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { capture } from './planner-studio.fixture';
import { modes, openSetup, routeSetup, viewports } from './planner-setup.fixture';

// #3259: with the scope estimate fitting its pane (#3257), the rest of the setup canvas still ran sideways with enlarged
// text: the phase pill and the Break plan / model / Generate footer ran under the context pane, "(optional)" past it,
// and on a phone the context repos picker past the screen (768/862 at 200%, 800/883 at 1280px/200%, 390/405 on a phone).

// Everything that would make the planner unreadable sideways: any scroll container in the document that scrolls
// horizontally, any visible element of the canvas (or the phone's Generate dock) that reaches past its pane or the
// screen, and any element that cuts its own text off. Hover-only tooltips (opacity 0) are not laid out content.
const canvasProblems = (page: Page) => page.evaluate(() => {
const problems: string[] = [];
const name = (node: Element) => `${node.tagName.toLowerCase()} "${(node.textContent || node.getAttribute('aria-label') || '').trim().slice(0, 40)}"`;
const width = document.documentElement.clientWidth;
for (const node of [document.documentElement, ...Array.from(document.body.querySelectorAll('*'))]) {
const scrolls = node === document.documentElement || ['auto', 'scroll'].includes(getComputedStyle(node).overflowX);
if (scrolls && node.scrollWidth > node.clientWidth) problems.push(`${name(node).slice(0, 30)} scrolls sideways ${node.clientWidth}/${node.scrollWidth}`);
}
const canvas = document.querySelector('[data-testid="setup-wizard-left-pane"]')!.parentElement!;
const dock = document.querySelector('[data-testid="mobile-generate-dock"]');
for (const node of [canvas, dock].flatMap(root => root ? Array.from(root.querySelectorAll('*')) : [])) {
const box = node.getBoundingClientRect();
const visible = (node as Element & { checkVisibility(options: object): boolean }).checkVisibility({ opacityProperty: true, visibilityProperty: true });
if (!visible || box.width <= 1 || box.height <= 1) continue;
const region = (node.closest('[data-testid="setup-wizard-left-pane"], [data-testid="setup-wizard-right-pane"]') ?? (canvas.contains(node) ? canvas : dock!)).getBoundingClientRect();
const left = Math.max(region.left, 0), right = Math.min(region.right, width);
if (box.left < left - 0.5 || box.right > right + 0.5) problems.push(`${name(node)} at ${Math.round(box.left)}-${Math.round(box.right)} outside ${Math.round(left)}-${Math.round(right)}`);
const style = getComputedStyle(node);
if (['hidden', 'clip'].includes(style.overflowX) && !['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName) && node.scrollWidth > node.clientWidth + 1) {
problems.push(`${name(node)} cut to ${node.clientWidth}/${node.scrollWidth}`);
}
}
return problems;
});

// Measure the layout the planner settles on at this width: crossing 768px swaps the phone and desktop layouts.
async function settle(page: Page, width: number) {
await expect(page.getByTestId(width >= 768 ? 'setup-wizard-right-pane' : 'setup-settings-group')).toBeVisible();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
}

async function expectReachable(...targets: Locator[]) {
for (const target of targets) {
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport({ ratio: 1 });
}
}

// Every label and control of the setup canvas, whole and on screen once scrolled to (the canvas scrolls vertically).
async function expectReadableCanvas(page: Page, width: number) {
const model = page.getByTestId('planner-model-selector');
await expect(page.getByTestId('scope-pill')).toHaveText('propr/main');
await expect(model).toHaveText('Claude Opus 5.5 (Default)');
if (width >= 768) {
const phases = page.getByRole('navigation', { name: 'Plan phase' });
const right = page.getByTestId('setup-wizard-right-pane');
// Fitting comes from wrapping inside the panes, not from moving the 65/35 split between them.
const split = await page.getByTestId('setup-wizard-left-pane').evaluate(pane => pane.getBoundingClientRect().width / pane.parentElement!.clientWidth);
expect(split).toBeCloseTo(0.65, 2);
// From md the pill names the repository and the chip the branch; the pill's `/main` suffix is for phones only.
await expect(page.getByTestId('scope-pill-branch')).toBeHidden();
await expect(page.getByTestId('branch-chip')).toHaveText('main');
await expectReachable(
page.getByTestId('scope-pill'), page.getByTestId('branch-chip'),
...['Define', 'Review', 'Execute'].map(phase => phases.getByText(phase, { exact: true })),
page.getByText('Break plan:', { exact: true }),
...['Single', 'Balanced', 'Granular'].map(pill => page.getByRole('button', { name: pill, exact: true })),
page.getByText('Model:', { exact: true }), model,
page.getByRole('button', { name: 'Export Context' }), page.getByRole('button', { name: /^Generate Plan/ }),
right.getByText('Additional Context Repositories', { exact: true }), right.getByText('(optional)', { exact: true }),
);
} else {
const settings = page.getByTestId('setup-settings-group');
await expect(page.getByTestId('phase-step-badge')).toHaveText('Step 1/3');
await expectReachable(
page.getByTestId('scope-pill'), page.getByTestId('phase-step-badge'),
settings.getByText('Break into', { exact: true }),
...['1', '3-5', '7+'].map(pill => settings.getByRole('button', { name: pill, exact: true })),
settings.getByText('Model', { exact: true }), model,
settings.getByText('Context repos', { exact: true }), settings.getByRole('button', { name: 'Context repos: None indexed' }),
page.getByTestId('mobile-generate-dock').getByRole('button', { name: /^Generate Plan/ }),
);
}
}

test('the whole-canvas check reports content that runs past a pane or is cut short', async ({ page }) => {
await routeSetup(page);
await openSetup(page, modes[0]);
expect(await canvasProblems(page)).toEqual([]);
// The kinds of failure seen before the fix: a nowrap label under the context pane, a squeezed control that cuts
// its own text, and a row that scrolls sideways inside the canvas.
await page.getByTestId('setup-wizard-left-pane').evaluate(pane => {
pane.querySelector('nav')!.insertAdjacentHTML('beforeend', '<span id="probe-wide" style="display: inline-block; width: 900px">Execute</span>');
pane.querySelector('[data-testid="composer-footer"]')!.insertAdjacentHTML('beforeend', '<span id="probe-cut" style="display: block; width: 40px; overflow: hidden; white-space: nowrap">Claude Opus 5.5 (Default)</span><div id="probe-scroll" style="overflow-x: auto"><span style="display: inline-block; width: 2000px">Granular</span></div>');
});
const problems = (await canvasProblems(page)).join('\n');
expect(problems).toMatch(/span "Execute" at \d+-\d+ outside/);
expect(problems).toContain('span "Claude Opus 5.5 (Default)" cut to 40/');
expect(problems).toMatch(/div "Granular" scrolls sideways/);
});

for (const mode of modes) {
for (const scale of [100, 150, 200]) {
test(`the whole ${mode.name}-plan setup canvas fits and stays readable with ${scale}% text`, async ({ page }) => {
const writes = await routeSetup(page);
await openSetup(page, mode, scale);
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await settle(page, viewport.width);
if (scale === 200 && [390, 768, 1280].includes(viewport.width)) await capture(page, `planner-whole-canvas-${mode.name}-${viewport.width}-${scale}pct`);
expect(await canvasProblems(page), `${viewport.width}px`).toEqual([]);
await expectReadableCanvas(page, viewport.width);
await expect(page.getByPlaceholder(/Describe the feature/)).toHaveValue(mode.typed);
}
expect(writes).toEqual([]);
});
}
}
6 changes: 4 additions & 2 deletions propr-ui/src/components/RepositorySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,11 @@ const BreadcrumbTrigger: React.FC<{
labelSuffix?: React.ReactNode;
}> = ({ selectedRepoData, selectedRepo, placeholder, reposCount, disabled, isOpen, onClick, labelSuffix }) => (
<>
<button type="button" onClick={onClick} disabled={disabled || reposCount === 0} className="appearance-none bg-transparent border-none text-sm pr-5 py-0.5 font-mono text-gray-700 hover:text-indigo-600 focus:outline-none cursor-pointer transition-colors truncate max-w-full min-w-0 flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-50" title={getBreadcrumbTitle(selectedRepoData, selectedRepo, placeholder, reposCount)}>
{/* From md the breadcrumb shares its row with the branch chip and truncates; a phone's pill has a row of its own
(the phase badge wraps under it), so with enlarged text its repository/branch label wraps rather than being cut. */}
<button type="button" onClick={onClick} disabled={disabled || reposCount === 0} className="appearance-none bg-transparent border-none text-sm pr-5 py-0.5 font-mono text-left text-gray-700 hover:text-indigo-600 focus:outline-none cursor-pointer transition-colors md:truncate max-w-full min-w-0 flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-50" title={getBreadcrumbTitle(selectedRepoData, selectedRepo, placeholder, reposCount)}>
{selectedRepoData ? <RepositoryIcon repository={selectedRepoData.name} iconPath={selectedRepoData.iconPath} revision={selectedRepoData.iconRevision || selectedRepoData.baseBranch} /> : <Github className="w-4 h-4 text-gray-500 flex-shrink-0" />}
<span className="truncate">{getBreadcrumbLabel(selectedRepoData, selectedRepo, placeholder, reposCount)}{labelSuffix}</span>
<span className="min-w-0 whitespace-normal [overflow-wrap:anywhere] md:truncate">{getBreadcrumbLabel(selectedRepoData, selectedRepo, placeholder, reposCount)}{labelSuffix}</span>
</button>
<ChevronDown className={`w-3.5 h-3.5 text-gray-400 absolute right-0 top-1/2 -translate-y-1/2 pointer-events-none transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</>
Expand Down
14 changes: 14 additions & 0 deletions propr-ui/src/components/TaskPlanner/ContextLevelSlider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,17 @@ describe('ContextLevelSlider token estimate', () => {
expect(screen.getByTestId('context-scope-estimate')).not.toHaveTextContent('tokens');
});
});

describe('ContextLevelSlider full header layout', () => {
it('lets the estimate wrap after the token budget instead of holding one line', () => {
render(<ContextLevelSlider value={50} onChange={vi.fn()} modelMaxTokens={1_000_000} />);
const estimate = screen.getByTestId('context-scope-estimate');
expect(estimate).toHaveTextContent('≤490k tokens · ~1-2m scan');
expect(estimate).not.toHaveClass('whitespace-nowrap');
expect(estimate.parentElement).toHaveClass('flex-wrap');
expect(Array.from(estimate.children, part => [part.textContent, part.className])).toEqual([
['≤490k tokens\u00a0·', 'inline-block'],
['~1-2m scan', 'inline-block'],
]);
});
});
Loading
Loading