diff --git a/packages/app/e2e/browser/diff-performance.spec.ts b/packages/app/e2e/browser/diff-performance.spec.ts index 6e11a23dd..183abbab4 100644 --- a/packages/app/e2e/browser/diff-performance.spec.ts +++ b/packages/app/e2e/browser/diff-performance.spec.ts @@ -25,6 +25,39 @@ const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1; const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1; diffPerfDescribe("Diff canvas performance", () => { + test("opening a review stays responsive in a large diff", async ({ page }) => { + test.setTimeout(120_000); + const repo = await createLargeDiffRepository(); + const client = await connectSeedClient(); + let cdp: CDPSession | null = null; + + try { + const created = await client.createWorkspace({ + source: { kind: "directory", path: repo.path }, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to create diff performance workspace"); + } + + await configureUnwrappedUnifiedDiff(page); + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id)); + await waitForWorkspaceTabsVisible(page); + await openChangesPanel(page); + await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 }); + + cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN }); + const elapsedMs = await openFirstReviewEditor(page); + expect(elapsedMs).toBeLessThanOrEqual(200 * CPU_SLOWDOWN); + } finally { + await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined); + await cdp?.detach().catch(() => undefined); + await client.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); + } + }); + test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => { test.setTimeout(180_000); const repo = await createLargeDiffRepository(); @@ -241,6 +274,21 @@ async function configureUnwrappedUnifiedDiff(page: Page): Promise { ); } +async function openFirstReviewEditor(page: Page): Promise { + const body = page.getByTestId("diff-file-0-body"); + const canvas = page.getByTestId("git-diff-canvas"); + const [bodyBounds, fontSize] = await Promise.all([ + body.boundingBox(), + canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), + ]); + if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); + const lineHeight = Math.round(fontSize * 1.5); + const startedAt = performance.now(); + await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5); + await expect(page.getByTestId("inline-review-editor")).toBeVisible(); + return performance.now() - startedAt; +} + async function expectSingleCanvasSurface(page: Page): Promise { await expect(page.locator("canvas")).toHaveCount(1); await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index d9cff153c..dc0b24409 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -12,6 +12,7 @@ import { waitForTabWithTitle, measureTileTransition, sampleTabsDuringTransition, + expectTabTitleFits, terminalSurfaceLocator, } from "../support/helpers/launcher"; import { expectComposerVisible, composerLocator } from "../support/helpers/composer"; @@ -197,7 +198,7 @@ test.describe("Tab transitions (no flash)", () => { await gotoWorkspace(page, workspace.workspaceId); // Sample tabs at high frequency across the transition - const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30); + const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page)); // Every snapshot should have at least one tab — no blank/zero-tab frames for (const snapshot of snapshots) { @@ -207,11 +208,15 @@ test.describe("Tab transitions (no flash)", () => { // Tab count should never spike excessively (no duplicate flash from add-then-remove). // When running in-suite, previous tests may have created tabs on the shared workspace, // so we allow +2 tolerance for accumulated state and React render batching. - const counts = snapshots.map((s) => s.length); + const counts = snapshots.map((snapshot) => snapshot.length); const maxCount = Math.max(...counts); const initialCount = counts[0] ?? 0; expect(maxCount).toBeLessThanOrEqual(initialCount + 2); + expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual( + 2, + ); + await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 }); }); test("Terminal transition completes within visual budget", async ({ page }) => { diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index 2bcc1336d..17c97687f 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -158,21 +158,59 @@ export async function sampleTabsDuringTransition( page: Page, action: () => Promise, durationMs = 2_000, - intervalMs = 30, -): Promise { - const snapshots: string[][] = []; - const startSampling = async () => { - const start = Date.now(); - while (Date.now() - start < durationMs) { - snapshots.push(await getTabTestIds(page)); - await page.waitForTimeout(intervalMs); +): Promise>> { + await page.evaluate((duration) => { + const scope = globalThis as typeof globalThis & { + __paseoTabTrackFrames?: Array>; + }; + scope.__paseoTabTrackFrames = []; + const startedAt = performance.now(); + function sample() { + const tabs = Array.from( + document.querySelectorAll( + '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', + ), + ).filter((element) => element.getClientRects().length > 0); + scope.__paseoTabTrackFrames?.push( + tabs.map((element) => ({ + id: element.getAttribute("data-testid") ?? "", + width: Math.round(element.getBoundingClientRect().width), + })), + ); + if (performance.now() - startedAt < duration) { + requestAnimationFrame(sample); + } } - }; - - const samplingPromise = startSampling(); + requestAnimationFrame(sample); + }, durationMs); await action(); - await samplingPromise; - return snapshots; + await page.waitForTimeout(durationMs + 100); + return page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __paseoTabTrackFrames?: Array>; + }; + return scope.__paseoTabTrackFrames ?? []; + }); +} + +export async function expectTabTitleFits( + page: Page, + title: string, + widthRange: { min: number; max: number }, +): Promise { + const tab = page + .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])') + .filter({ hasText: title }) + .filter({ visible: true }) + .last(); + await expect(tab).toContainText(title); + const tabWidth = await tab.evaluate((element) => element.getBoundingClientRect().width); + expect(tabWidth).toBeGreaterThanOrEqual(widthRange.min); + expect(tabWidth).toBeLessThanOrEqual(widthRange.max); + const label = tab.getByText(title, { exact: true }); + await expect(label).toBeVisible(); + const labelFits = await label.evaluate((element) => element.scrollWidth <= element.clientWidth); + expect(labelFits).toBe(true); } export function terminalSurfaceLocator(page: Page) { diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index 3141b44e3..20f1cdc81 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -272,6 +272,54 @@ describe("diff document model", () => { expect(reviewRow?.kind === "line" ? reviewRow.reviewHeight : 0).toBe(148); }); + it("reflows review geometry without remeasuring unchanged text", () => { + const files = [file("a.ts"), file("b.ts")]; + let measurementCount = 0; + const countingMeasurer: TextMeasurer = { + measure(text) { + measurementCount += 1; + return Array.from(text).length * 10; + }, + }; + const base = buildDiffDocumentModel(input({ files, measureText: countingMeasurer })); + const initialMeasurementCount = measurementCount; + const reviewCell = base.rows + .filter((row) => row.kind === "line") + .flatMap((row) => row.cells) + .find((cell) => cell?.reviewTarget); + if (!reviewCell?.reviewTarget) throw new Error("Expected a reviewable cell"); + const baseReviewRow = rowForCell(base, reviewCell); + const baseSecondFileTop = base.files[1]!.top; + + const withEditor = buildDiffDocumentModel( + input({ + files, + measureText: countingMeasurer, + reviewActions: reviewActionsWithEditor(reviewCell.reviewTarget), + reuseFrom: [base], + }), + ); + const editorRow = rowForReviewTarget(withEditor, reviewCell.reviewTarget.key); + + expect(measurementCount).toBe(initialMeasurementCount); + expect(editorRow.reviewHeight).toBe(148); + expect(editorRow.cells).toBe(baseReviewRow.cells); + expect(withEditor.files[1]!.top).toBe(baseSecondFileTop + 148); + + const withoutEditor = buildDiffDocumentModel( + input({ + files, + measureText: countingMeasurer, + reviewActions: reviewActionsWithEditor(null), + reuseFrom: [base, withEditor], + }), + ); + + expect(measurementCount).toBe(initialMeasurementCount); + expect(withoutEditor.height).toBe(base.height); + expect(withoutEditor.files[1]!.top).toBe(baseSecondFileTop); + }); + it("preserves a logical source anchor through width and font metric relayout", () => { const narrow = buildDiffDocumentModel( input({ files: [fileWithChangedLine("abcdefghij")], viewportWidth: 90 }), @@ -492,3 +540,26 @@ function rowForCell( if (!row || row.kind !== "line") throw new Error("Expected the cell row"); return row; } + +function rowForReviewTarget(model: ReturnType, key: string) { + const row = model.rows.find( + (candidate) => + candidate.kind === "line" && candidate.cells.some((cell) => cell?.reviewTarget?.key === key), + ); + if (!row || row.kind !== "line") throw new Error("Expected the review row"); + return row; +} + +function reviewActionsWithEditor( + target: NonNullable["reviewTarget"]> | null, +): NonNullable { + return { + commentsByTarget: new Map(), + editor: target ? { target, body: "", commentId: null } : null, + onStartComment() {}, + onCancelEditor() {}, + onSaveEditor() {}, + onEditComment() {}, + onDeleteComment() {}, + }; +} diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index 814552734..106ca9e21 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -60,14 +60,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff if (!isCollapsed && reusableFile) { const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd); for (const reusedRow of reusedRows) { - const height = reusedRow.height; + if (reusedRow.kind === "line") { + const reviewHeight = reviewHeightForCells(reusedRow.cells, input); + const textHeight = reusedRow.height - reusedRow.reviewHeight; + const height = textHeight + reviewHeight; + rows.push({ + ...reusedRow, + index: rows.length, + fileIndex, + top: documentTop, + height, + reviewHeight, + }); + documentTop += height; + continue; + } rows.push({ ...reusedRow, index: rows.length, fileIndex, top: documentTop, }); - documentTop += height; + documentTop += reusedRow.height; } maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth); documentTop += BODY_BORDER_HEIGHT; diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx index 4b018f46d..3bd418157 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -93,7 +93,6 @@ export function DiffSurface(props: DiffSurfaceProps) { typography, measurement, props.palette, - reviewActions, t, ] as const; const previous = reusableModelRef.current; diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index da563e48c..60a160fab 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -95,7 +95,6 @@ export function DiffSurface(props: DiffSurfaceProps) { loadedTypography, measurement, props.palette, - reviewActions, t, ] as const; const previous = reusableModelRef.current; @@ -210,7 +209,15 @@ export function DiffSurface(props: DiffSurfaceProps) { }, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]); const schedulePaint = useCallback( (force = true) => { - if (force) forcePaintRef.current = true; + if (force) { + forcePaintRef.current = true; + // Fast Refresh preserves refs. A request owned by the previous module can leave a stale + // id behind, which would otherwise prevent every subsequent canvas repaint. + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + } if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint); }, [paint], @@ -260,6 +267,7 @@ export function DiffSurface(props: DiffSurfaceProps) { useEffect( () => () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; }, [], ); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index e9e792b35..6c401316a 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -2,6 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -62,6 +63,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; +import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -105,9 +107,8 @@ const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MIN_WIDTH = 96; -const TAB_MAX_WIDTH = 240; +const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; -const TAB_LABEL_FALLBACK_CHARACTER_WIDTH = 7; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -144,9 +145,12 @@ function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallback return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; } -function updateMeasuredWidth(setWidth: Dispatch>, event: LayoutChangeEvent) { +function updateMeasuredWidth( + setWidth: React.Dispatch>, + event: LayoutChangeEvent, +) { const nextWidth = Math.round(event.nativeEvent.layout.width); - setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + setWidth((current) => retainWorkspaceTabMeasuredWidth(current, nextWidth)); } function TabLabelMeasurement({ @@ -392,6 +396,46 @@ interface ResolvedWorkspaceDesktopTabRowItem extends WorkspaceDesktopTabRowItem presentation: WorkspaceTabPresentation; } +interface WorkspaceTabLabel { + key: string; + label: string; +} + +interface WorkspaceTabLabelMeasurement { + label: string; + width: number; +} + +interface WorkspaceTabTrackSnapshot { + signature: string; + tabs: ResolvedWorkspaceDesktopTabRowItem[]; + labels: WorkspaceTabLabel[]; + labelWidths: number[]; +} + +function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string { + return JSON.stringify(labels); +} + +function completeWorkspaceTabLabelWidths( + labels: WorkspaceTabLabel[], + measurements: Map, +): number[] | null { + const widths: number[] = []; + for (const { key, label } of labels) { + const measurement = measurements.get(key); + if (!measurement || measurement.label !== label || measurement.width <= 0) { + return null; + } + widths.push(measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE); + } + return widths; +} + +function sameWidths(left: number[], right: number[]): boolean { + return left.length === right.length && left.every((width, index) => width === right[index]); +} + interface WorkspaceDesktopTabsRowProps { paneId?: string; isFocused?: boolean; @@ -428,50 +472,49 @@ interface ResolvedWorkspaceDesktopTabsRowProps extends Omit React.ReactNode; + onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void; } const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = []; -function WorkspaceDesktopTabRowsResolver({ - tabs, +function WorkspaceDesktopTabPresentationSlot({ + tab, serverId, workspaceId, - index, - resolvedTabs, - children, -}: WorkspaceDesktopTabRowsResolverProps): React.ReactElement { - const item = tabs[index]; - if (!item) { - return <>{children(resolvedTabs)}; - } - + onResolve, +}: WorkspaceDesktopTabPresentationSlotProps) { return ( - - {(presentation) => { - const nextResolvedTabs = [...resolvedTabs, { ...item, presentation }]; - return ( - - {children} - - ); - }} + + {(presentation) => ( + + )} ); } +function WorkspaceDesktopTabPresentationCommit({ + tabKey, + presentation, + onResolve, +}: { + tabKey: string; + presentation: WorkspaceTabPresentation; + onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void; +}) { + useLayoutEffect(() => { + onResolve(tabKey, presentation); + }, [onResolve, presentation, tabKey]); + return null; +} + function getFallbackTabLabel( tab: WorkspaceTabDescriptor, labels: { @@ -813,16 +856,61 @@ function TabChip({ } export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { + const [presentations, setPresentations] = useState( + () => new Map(), + ); + const handlePresentation = useCallback( + (tabKey: string, presentation: WorkspaceTabPresentation) => { + setPresentations((current) => { + if (current.get(tabKey) === presentation) { + return current; + } + const next = new Map(current); + next.set(tabKey, presentation); + return next; + }); + }, + [], + ); + const currentTabKeys = useMemo( + () => new Set(props.tabs.map((item) => item.tab.key)), + [props.tabs], + ); + useEffect(() => { + setPresentations((current) => { + const removedKeys = [...current.keys()].filter((key) => !currentTabKeys.has(key)); + if (removedKeys.length === 0) { + return current; + } + const next = new Map(current); + for (const key of removedKeys) { + next.delete(key); + } + return next; + }); + }, [currentTabKeys]); + const resolvedTabs = useMemo( + () => + props.tabs.flatMap((item) => { + const presentation = presentations.get(item.tab.key); + return presentation ? [{ ...item, presentation }] : []; + }), + [presentations, props.tabs], + ); + return ( - - {(tabs) => } - + <> + + {props.tabs.map(({ tab }) => ( + + ))} + ); } @@ -864,9 +952,8 @@ function ResolvedWorkspaceDesktopTabsRow({ const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); - const [measuredTabLabels, setMeasuredTabLabels] = useState< - Record - >({}); + const labelMeasurementsRef = useRef(new Map()); + const [trackSnapshot, setTrackSnapshot] = useState(null); const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; const checkoutStatus = useCheckoutStatusQuery({ serverId: normalizedServerId, @@ -954,32 +1041,76 @@ function ResolvedWorkspaceDesktopTabsRow({ }), [fallbackTabLabels, tabs], ); - const tabLabelWidths = useMemo( - () => - tabLabels.map(({ key, label }) => { - const measurement = measuredTabLabels[key]; - return measurement?.label === label - ? measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE - : label.length * TAB_LABEL_FALLBACK_CHARACTER_WIDTH + TAB_LABEL_LAYOUT_ALLOWANCE; - }), - [measuredTabLabels, tabLabels], - ); + const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]); + const publishMeasuredTrack = useCallback(() => { + if (tabsContainerWidth <= 0) { + return; + } + const labelWidths = completeWorkspaceTabLabelWidths(tabLabels, labelMeasurementsRef.current); + if (!labelWidths) { + return; + } + + const retainedMeasurements = new Map(); + for (const { key } of tabLabels) { + const measurement = labelMeasurementsRef.current.get(key); + if (measurement) { + retainedMeasurements.set(key, measurement); + } + } + labelMeasurementsRef.current = retainedMeasurements; + + setTrackSnapshot((current) => { + if ( + current?.signature === tabLabelSignature && + sameWidths(current.labelWidths, labelWidths) + ) { + return current; + } + return { + signature: tabLabelSignature, + tabs, + labels: tabLabels, + labelWidths, + }; + }); + }, [tabLabelSignature, tabLabels, tabs, tabsContainerWidth]); + + useLayoutEffect(() => { + publishMeasuredTrack(); + }, [publishMeasuredTrack]); + const handleTabLabelLayout = useCallback( (key: string, label: string, event: LayoutChangeEvent) => { const width = Math.ceil(event.nativeEvent.layout.width); - setMeasuredTabLabels((current) => { - const existing = current[key]; - if (existing?.label === label && existing.width === width) { - return current; - } - return { ...current, [key]: { label, width } }; - }); + if (width <= 0) { + return; + } + const current = labelMeasurementsRef.current.get(key); + if (current?.label === label && current.width === width) { + return; + } + labelMeasurementsRef.current.set(key, { label, width }); + publishMeasuredTrack(); }, - [], + [publishMeasuredTrack], ); + const displayedTabs = useMemo(() => { + if (!trackSnapshot) { + return EMPTY_RESOLVED_TAB_ROWS; + } + const currentTabs = new Map( + tabs.map((tab, index) => [tab.tab.key, { tab, label: tabLabels[index]?.label }]), + ); + return trackSnapshot.tabs.map((snapshotTab, index) => { + const current = currentTabs.get(snapshotTab.tab.key); + return current?.label === trackSnapshot.labels[index]?.label ? current.tab : snapshotTab; + }); + }, [tabLabels, tabs, trackSnapshot]); + const { layout } = useWorkspaceTabLayout({ - tabLabelWidths, + tabLabelWidths: trackSnapshot?.labelWidths ?? [], viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); @@ -1058,8 +1189,8 @@ function ResolvedWorkspaceDesktopTabsRow({ const showDropIndicatorBefore = activeDragTabId !== null && tabDropPreviewIndex === index; const showDropIndicatorAfter = activeDragTabId !== null && - tabDropPreviewIndex === tabs.length && - index === tabs.length - 1; + tabDropPreviewIndex === displayedTabs.length && + index === displayedTabs.length - 1; return ( { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([119, 101, 96]); + expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -67,7 +70,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([240, 240, 240, 240]); + expect(result.items.map((item) => item.width)).toEqual([160, 160, 160, 160]); }); it("keeps every tab at the clickable minimum at the exact fit boundary", () => { @@ -118,3 +121,13 @@ describe("computeWorkspaceTabLayout", () => { expect(result.items.map((item) => item.width)).toEqual([102, 138]); }); }); + +describe("retainWorkspaceTabMeasuredWidth", () => { + it("retains the last usable width while a retained panel is hidden", () => { + expect(retainWorkspaceTabMeasuredWidth(720, 0)).toBe(720); + }); + + it("accepts the next usable layout width", () => { + expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index f4f389d15..2693e24cd 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -30,6 +30,16 @@ export interface WorkspaceTabLayoutResult { requiresHorizontalScrollFallback: boolean; } +export function retainWorkspaceTabMeasuredWidth( + currentWidth: number, + measuredWidth: number, +): number { + if (measuredWidth <= 0 || Math.abs(currentWidth - measuredWidth) <= 1) { + return currentWidth; + } + return measuredWidth; +} + function clamp(value: number, min: number, max: number): number { if (value < min) { return min;