From ad1bb09febc51e8395655fa1261f3843c946a908 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:02:28 +0000 Subject: [PATCH 1/2] I reproduced the overflow and fixed it in the scope-estimate component. At 768, 842 and 880px the planner canvas no longer scrolls sideways. Everything was tested in headless Chromium with a stubbed backend, not in the signed native app; the 842px case only stands in for the native workspace width. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduction (before any change): I stubbed the branch lookup as successful, so the header error from #3253 never appears. Viewport height was 900px. The canvas that scrolls is the setup wizard's `flex-1 flex flex-col md:flex-row min-h-0 overflow-auto` element, not the document. The document itself never overflowed. | Viewport | New plan canvas, before → after | Edit plan canvas, before → after | |---|---|---| | 768px | 768/833 → 768/768 | 768/841 → 768/768 | | 842px | 842/881 → 842/842 | 842/889 → 842/842 | | 880px | 880/906 → 880/880 | 880/914 → 880/880 | | 1280px, 1390px | no overflow either way | no overflow either way | Cause: In the full layout, the header row in `ContextLevelSlider.tsx` didn't wrap, and the estimate was `whitespace-nowrap` (180.6px wide). The right pane is 35% wide, so at 768px it has about 228px for content. The title group and the estimate couldn't fit side by side, so the estimate ran past the pane. At 1280/1390px it only fitted because the "Context Scope" label was squeezed onto two lines. Fix: only `ContextLevelSlider.tsx`, full layout. Budget calculations, slider steps, callbacks, labels and the compact mobile layout are unchanged. - The header row and the title group can now wrap, so the estimate drops under the title when the pane is narrow. - The estimate breaks only after the token budget ("≤490k tokens ·" then "~1-2m scan"). A no-break space keeps the "·" with "tokens", and each half wraps internally only if it can't fit on its own. - The Focused / Expanded / Full Scan shortcut row can wrap too. With 200% text at 1280px, "Full Scan" was running past the pane. - At 1280/1390px the row is the same 40px tall as before, but the label stays on one line and the estimate sits under it. Tests: - New `e2e/planner-scope-estimate.pw.ts`: covers new and edit plans at 320, 390, 768, 842, 880, 1280 and 1390px, plus 150% and 200% text. - It checks every horizontal scroll container up to the document, and that nothing in the scope block reaches past the pane. - It checks the full estimate, percentage, label, slider and shortcuts are readable and on screen, and steps the slider from the keyboard at each width. - It checks the typed prompt and the slider value survive resizing (including switching between the compact and full layouts). - It fails on any write except edit mode's existing autosave of its prompt; new mode sends nothing. - New mode keeps an empty prompt, because typing there creates a draft after a second. - Negative control: with the original component restored, all 6 new tests fail (for example `canvas 768/833`). With the fix all 6 pass. - New unit test in `ContextLevelSlider.test.tsx`. - Final run: `npx playwright test e2e/planner-scope-estimate.pw.ts e2e/planner-studio.pw.ts e2e/planner-studio-mobile.pw.ts` gives 42 passed. Vitest (`ContextLevelSlider`, `SetupWizardRightPane`, `SetupWizard`): 19 passed. `eslint --max-warnings 0` and `tsc --noEmit` are clean. Still overflowing with enlarged text, outside this component: the canvas still measures 768/772 at 150%, 768/862 at 200% and 800/883 at 1280px/200%. That comes from other controls: the "(optional)" label in Additional Context Repositories, the docked Generate Plan label, and the mobile settings chevron at 390px/200%. The left-pane header is also clipped at 200%. These are not in the scope estimate, so I didn't touch them. For enlarged text the tests check only that the scope block stays inside the pane, not the whole canvas. I kept to `ContextLevelSlider.tsx`, its test, and the new spec, so none of the files touched by PR3254, PR3255 or PR3256 are changed. Previews include before/after screenshots at 768px and of the 1280px pane, plus 880px and 200%-text views after the fix. --- propr-ui/e2e/planner-scope-estimate.pw.ts | 130 ++++++++++++++++++ .../TaskPlanner/ContextLevelSlider.test.tsx | 14 ++ .../TaskPlanner/ContextLevelSlider.tsx | 24 +++- 3 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 propr-ui/e2e/planner-scope-estimate.pw.ts diff --git a/propr-ui/e2e/planner-scope-estimate.pw.ts b/propr-ui/e2e/planner-scope-estimate.pw.ts new file mode 100644 index 0000000000..792ab8d6ac --- /dev/null +++ b/propr-ui/e2e/planner-scope-estimate.pw.ts @@ -0,0 +1,130 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { capture, fixture } from './planner-studio.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 repository = 'integry/propr'; +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 }, + ], +}; +const estimatePattern = /^≤\d+(\.\d+)?[kM] tokens · (<1m|~1-2m|~3-5m) scan$/; +const prompt = 'Keep the planner scope estimate inside medium-width layouts.'; + +// A successful branch lookup and preview, so no header error is in play: the only wide content is the scope estimate. +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 | 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; +} + +// 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, wholeCanvas: boolean) => scope.evaluate((element, wholeCanvas) => { + const problems: string[] = []; + for (let node: Element | null = element; wholeCanvas && 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; +}, wholeCanvas); + +async function expectReadableScope(page: Page, width: number, level: number, typed: string, wholeCanvas = true) { + 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, wholeCanvas)).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); +} + +// 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. +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 }, +]; +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 }, +]; + +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 page.setViewportSize(viewports[2]); + await page.goto(mode.path); + await expect(page.getByTestId('context-scope-estimate')).toHaveText(mode.estimate); + if (mode.typed) await page.getByPlaceholder(/Describe the feature/).fill(mode.typed); + 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 page.setViewportSize(viewports[2]); + await page.goto(mode.path); + await expect(page.getByTestId('context-scope-estimate')).toHaveText(mode.estimate); + await page.addStyleTag({ content: `html { font-size: ${scale}% !important; }` }); + if (mode.typed) await page.getByPlaceholder(/Describe the feature/).fill(mode.typed); + for (const viewport of [viewports[1], viewports[2], viewports[3], viewports[4], viewports[5]]) { + await page.setViewportSize(viewport); + // Other setup controls (context repositories, the docked Generate label) still overflow with enlarged text on + // their own; that is outside the scope block, so here only the scope block's own containment is asserted. + await expectReadableScope(page, viewport.width, mode.level, mode.typed, false); + if (scale === 200 && viewport.width === 768) await capture(page, `planner-scope-estimate-${mode.name}-768-200pct`); + } + expect(writes).toEqual([]); + }); + } +} diff --git a/propr-ui/src/components/TaskPlanner/ContextLevelSlider.test.tsx b/propr-ui/src/components/TaskPlanner/ContextLevelSlider.test.tsx index d1c88b7107..ff80c6c3a2 100644 --- a/propr-ui/src/components/TaskPlanner/ContextLevelSlider.test.tsx +++ b/propr-ui/src/components/TaskPlanner/ContextLevelSlider.test.tsx @@ -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(); + 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'], + ]); + }); +}); diff --git a/propr-ui/src/components/TaskPlanner/ContextLevelSlider.tsx b/propr-ui/src/components/TaskPlanner/ContextLevelSlider.tsx index 12e330f985..25e0a46a8e 100644 --- a/propr-ui/src/components/TaskPlanner/ContextLevelSlider.tsx +++ b/propr-ui/src/components/TaskPlanner/ContextLevelSlider.tsx @@ -73,6 +73,9 @@ export const ContextLevelSlider: React.FC = ({ value, o const analysisDetail = hideCostLabels ? config.analysis : `${config.analysis} · ${config.costLabel}`; const tokenEstimate = `≤${formatTokenAmount(getContextTokenBudget(value, modelMaxTokens || DEFAULT_MODEL_MAX_TOKENS))} tokens`; const estimate = hideCostLabels ? config.latencyEstimate : `${tokenEstimate} · ${config.latencyEstimate}`; + // The full header breaks between the token budget and the scan time first; each part wraps inside only when it + // cannot fit the pane on its own (very large text in the narrow md column). + const estimateParts = hideCostLabels ? [config.latencyEstimate] : [tokenEstimate, config.latencyEstimate]; // Handle slider change - no snapping, moves at 10% increments const handleSliderChange = useCallback((e: React.ChangeEvent) => { @@ -114,10 +117,11 @@ export const ContextLevelSlider: React.FC = ({ value, o return (
- {/* Header Row: Title on left, token / latency estimate on right */} -
-
- + {/* Header Row: Title on left, token / latency estimate on right. In a narrow pane (the 35% column + at md widths) or with enlarged text the estimate wraps under the title instead of widening the canvas. */} +
+
+ @@ -125,8 +129,13 @@ export const ContextLevelSlider: React.FC = ({ value, o {value}%
- - {estimate} + + {estimateParts.map((part, index) => ( + + {index > 0 && ' '} + {index < estimateParts.length - 1 ? `${part}\u00a0·` : part} + + ))}
@@ -141,7 +150,8 @@ export const ContextLevelSlider: React.FC = ({ value, o onChange={handleSliderChange} className="context-slider w-full h-2 rounded-lg cursor-pointer" /> -
+ {/* Enlarged text can make the three shortcuts wider than the pane; they wrap rather than run off its edge. */} +
diff --git a/propr-ui/src/components/TaskPlanner/ContextRepositoriesSection.tsx b/propr-ui/src/components/TaskPlanner/ContextRepositoriesSection.tsx index ad7cbe24f6..412e1fe1c7 100644 --- a/propr-ui/src/components/TaskPlanner/ContextRepositoriesSection.tsx +++ b/propr-ui/src/components/TaskPlanner/ContextRepositoriesSection.tsx @@ -172,7 +172,8 @@ export const ContextRepositoriesSection: React.FC -
+ {/* Enlarged text on a phone can make label and picker wider than the row; the picker then drops under the label. */} +
Context repos
{/* Tooltip explaining the refresh logic */} -
+
Context Refresh
{getTooltipText()} diff --git a/propr-ui/src/components/TaskPlanner/SetupComposerFooter.tsx b/propr-ui/src/components/TaskPlanner/SetupComposerFooter.tsx index fa397120ad..d9d2b32a3c 100644 --- a/propr-ui/src/components/TaskPlanner/SetupComposerFooter.tsx +++ b/propr-ui/src/components/TaskPlanner/SetupComposerFooter.tsx @@ -26,7 +26,7 @@ interface SetupComposerFooterProps extends GenerateActionProps { /** A labelled row puts its control on the right; an unlabelled row lays out its own label and control. */ const SettingsRow: React.FC<{ label?: string; children: React.ReactNode }> = ({ label, children }) => ( label ? ( -
+
{label}
{children}
@@ -40,7 +40,7 @@ const GenerateAction: React.FC = onClick={onGenerate} disabled={generateDisabled} title={generateTitle} - className={`flex max-w-full items-center justify-center gap-1.5 whitespace-nowrap text-white font-medium bg-[rgb(29,138,138)] hover:bg-[rgb(24,118,118)] disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex-shrink-0 ${fullWidth ? 'w-full h-11 px-4 text-sm rounded-lg' : 'px-3 py-1.5 text-xs rounded-md'}`} + className={`flex max-w-full items-center justify-center gap-1.5 text-center text-white font-medium bg-[rgb(29,138,138)] hover:bg-[rgb(24,118,118)] disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors ${fullWidth ? 'w-full min-h-11 px-4 py-2 text-sm rounded-lg' : 'min-w-0 px-3 py-1.5 text-xs rounded-md'}`} > {generateLabel} @@ -80,14 +80,16 @@ export const SetupComposerFooter: React.FC = ({ ); } + // The prompt box is the narrow 65% column at md widths, and enlarged text widens every control, so the three groups + // wrap onto further lines rather than squeezing the model name to nothing or running Generate past the pane. return ( -
-
+
+
Break plan:
{modelSelector} -
+
{position && createPortal( diff --git a/propr-ui/src/components/TaskPlanner/SetupWizardHeaders.tsx b/propr-ui/src/components/TaskPlanner/SetupWizardHeaders.tsx index b9c572c31e..8f00b4f974 100644 --- a/propr-ui/src/components/TaskPlanner/SetupWizardHeaders.tsx +++ b/propr-ui/src/components/TaskPlanner/SetupWizardHeaders.tsx @@ -60,12 +60,13 @@ export const BranchBadge: React.FC<{ baseBranch: string; fallbackBranch?: string // Phones have no room for a separate branch chip, so the repo pill reads `propr/main` instead. const MobileBranchSuffix: React.FC<{ branch: string; failed: boolean }> = ({ branch, failed }) => ( branch ? ( - /{branch} + // The break opportunity lets a wrapping pill put `/branch` on its own line before splitting either name. + <>/{branch} ) : null ); // On a phone the repo trigger is the one scope pill; from md up it is a breadcrumb beside the branch chip. -const SCOPE_PILL_CLASS = 'relative inline-flex min-w-0 items-center whitespace-nowrap rounded-md border border-slate-200 bg-slate-50 py-0.5 pl-2 pr-1.5 md:shrink-0 md:max-w-[50%] md:border-0 md:bg-transparent md:p-0'; +const SCOPE_PILL_CLASS = 'relative inline-flex min-w-0 items-center md:whitespace-nowrap rounded-md border border-slate-200 bg-slate-50 py-0.5 pl-2 pr-1.5 md:shrink-0 md:max-w-[50%] md:border-0 md:bg-transparent md:p-0'; // A failed repository/branch lookup gets its own full-width line under the scope row: inline, a long // recovery instruction ran across the phase switcher and off the canvas. It wraps, even inside an diff --git a/propr-ui/src/components/TaskPlanner/SetupWizardLeftPane.tsx b/propr-ui/src/components/TaskPlanner/SetupWizardLeftPane.tsx index 862c1bf124..2177d8031b 100644 --- a/propr-ui/src/components/TaskPlanner/SetupWizardLeftPane.tsx +++ b/propr-ui/src/components/TaskPlanner/SetupWizardLeftPane.tsx @@ -171,12 +171,14 @@ export const SetupWizardLeftPane: React.FC = ({ attachButtonPressed, composerFooter, }) => ( -
+
{/* Header with repo/branch - Toolbar border for alignment with right pane */}
-
- {/* Repo and branch share the row with the phase pill, so their percentage widths are of this box. */} -
+ {/* With enlarged text the phase pill wraps under repo and branch instead of running beneath the context pane. */} +
+ {/* Repo and branch share the row with the phase pill, so their percentage widths are of this box. Sized by + their content, they push the pill to its own line before either has to be cut short. */} +
{isNewMode ? ( = ({ /> )}
- +
diff --git a/propr-ui/src/components/TaskPlanner/StudioStepper.tsx b/propr-ui/src/components/TaskPlanner/StudioStepper.tsx index 1804278511..5816808c7e 100644 --- a/propr-ui/src/components/TaskPlanner/StudioStepper.tsx +++ b/propr-ui/src/components/TaskPlanner/StudioStepper.tsx @@ -32,6 +32,9 @@ interface StudioPhaseSwitcherProps { /** Shown next to a phase label, e.g. the number of tasks under review. */ counts?: Partial>; className?: string; + /** Never wider than its row: in a narrow column or with enlarged text the phases wrap inside the pill instead of + * running under whatever sits beside the row. */ + wrap?: boolean; } /** @@ -39,13 +42,13 @@ interface StudioPhaseSwitcherProps { * Below `md` the breadcrumb collapses to a `Step 2/3` badge so it fits next to the scope pill * on a phone. Renders nothing outside a studio page (no stage in context). */ -export const StudioPhaseSwitcher: React.FC = ({ counts, className = '' }) => { +export const StudioPhaseSwitcher: React.FC = ({ counts, className = '', wrap = false }) => { const currentStage = useContext(StudioStageContext); if (!currentStage) return null; const current = STEPS.find((step) => step.id === currentStage)!; return ( -