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..5912be600e --- /dev/null +++ b/propr-ui/e2e/planner-scope-estimate.pw.ts @@ -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([]); + }); + } +} diff --git a/propr-ui/e2e/planner-setup.fixture.ts b/propr-ui/e2e/planner-setup.fixture.ts new file mode 100644 index 0000000000..03c95bac80 --- /dev/null +++ b/propr-ui/e2e/planner-setup.fixture.ts @@ -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 | 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); +} diff --git a/propr-ui/e2e/planner-whole-canvas.pw.ts b/propr-ui/e2e/planner-whole-canvas.pw.ts new file mode 100644 index 0000000000..764be7a7ff --- /dev/null +++ b/propr-ui/e2e/planner-whole-canvas.pw.ts @@ -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', 'Execute'); + pane.querySelector('[data-testid="composer-footer"]')!.insertAdjacentHTML('beforeend', 'Claude Opus 5.5 (Default)
Granular
'); + }); + 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([]); + }); + } +} diff --git a/propr-ui/src/components/RepositorySelector.tsx b/propr-ui/src/components/RepositorySelector.tsx index 837d200176..5c57349a24 100644 --- a/propr-ui/src/components/RepositorySelector.tsx +++ b/propr-ui/src/components/RepositorySelector.tsx @@ -154,9 +154,11 @@ const BreadcrumbTrigger: React.FC<{ labelSuffix?: React.ReactNode; }> = ({ selectedRepoData, selectedRepo, placeholder, reposCount, disabled, isOpen, onClick, labelSuffix }) => ( <> - 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. */} +
{/* 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 ( -