fix(app): stabilize diff reviews and workspace tabs

Reuse measured diff geometry when review controls open, keeping the interaction responsive on large changes. Preserve complete tab-track snapshots while exact content widths settle so new tabs and retained workspaces do not flash or truncate.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:38 +02:00
parent c7b103a2cd
commit 8f7b3e8d75
10 changed files with 433 additions and 96 deletions
@@ -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<void> {
);
}
async function openFirstReviewEditor(page: Page): Promise<number> {
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<void> {
await expect(page.locator("canvas")).toHaveCount(1);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
@@ -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 }) => {
+51 -13
View File
@@ -158,21 +158,59 @@ export async function sampleTabsDuringTransition(
page: Page,
action: () => Promise<void>,
durationMs = 2_000,
intervalMs = 30,
): Promise<string[][]> {
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<Array<Array<{ id: string; width: number }>>> {
await page.evaluate((duration) => {
const scope = globalThis as typeof globalThis & {
__paseoTabTrackFrames?: Array<Array<{ id: string; width: number }>>;
};
scope.__paseoTabTrackFrames = [];
const startedAt = performance.now();
function sample() {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>(
'[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<Array<{ id: string; width: number }>>;
};
return scope.__paseoTabTrackFrames ?? [];
});
}
export async function expectTabTitleFits(
page: Page,
title: string,
widthRange: { min: number; max: number },
): Promise<void> {
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) {
@@ -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<typeof buildDiffDocumentModel>, 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<ReturnType<typeof addedCell>["reviewTarget"]> | null,
): NonNullable<BuildDiffDocumentModelInput["reviewActions"]> {
return {
commentsByTarget: new Map(),
editor: target ? { target, body: "", commentId: null } : null,
onStartComment() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
onDeleteComment() {},
};
}
+16 -2
View File
@@ -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;
@@ -93,7 +93,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
typography,
measurement,
props.palette,
reviewActions,
t,
] as const;
const previous = reusableModelRef.current;
@@ -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;
},
[],
);
@@ -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<SetStateAction<number>>, event: LayoutChangeEvent) {
function updateMeasuredWidth(
setWidth: React.Dispatch<React.SetStateAction<number>>,
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<string, WorkspaceTabLabelMeasurement>,
): 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<WorkspaceDesktopTabs
tabs: ResolvedWorkspaceDesktopTabRowItem[];
}
interface WorkspaceDesktopTabRowsResolverProps {
tabs: WorkspaceDesktopTabRowItem[];
interface WorkspaceDesktopTabPresentationSlotProps {
tab: WorkspaceTabDescriptor;
serverId: string;
workspaceId: string;
index: number;
resolvedTabs: ResolvedWorkspaceDesktopTabRowItem[];
children: (tabs: ResolvedWorkspaceDesktopTabRowItem[]) => 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 (
<WorkspaceTabPresentationResolver tab={item.tab} serverId={serverId} workspaceId={workspaceId}>
{(presentation) => {
const nextResolvedTabs = [...resolvedTabs, { ...item, presentation }];
return (
<WorkspaceDesktopTabRowsResolver
tabs={tabs}
serverId={serverId}
workspaceId={workspaceId}
index={index + 1}
resolvedTabs={nextResolvedTabs}
>
{children}
</WorkspaceDesktopTabRowsResolver>
);
}}
<WorkspaceTabPresentationResolver tab={tab} serverId={serverId} workspaceId={workspaceId}>
{(presentation) => (
<WorkspaceDesktopTabPresentationCommit
tabKey={tab.key}
presentation={presentation}
onResolve={onResolve}
/>
)}
</WorkspaceTabPresentationResolver>
);
}
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<string, WorkspaceTabPresentation>(),
);
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 (
<WorkspaceDesktopTabRowsResolver
tabs={props.tabs}
serverId={props.normalizedServerId}
workspaceId={props.normalizedWorkspaceId}
index={0}
resolvedTabs={EMPTY_RESOLVED_TAB_ROWS}
>
{(tabs) => <ResolvedWorkspaceDesktopTabsRow {...props} tabs={tabs} />}
</WorkspaceDesktopTabRowsResolver>
<>
<ResolvedWorkspaceDesktopTabsRow {...props} tabs={resolvedTabs} />
{props.tabs.map(({ tab }) => (
<WorkspaceDesktopTabPresentationSlot
key={`${tab.key}:${tab.kind}`}
tab={tab}
serverId={props.normalizedServerId}
workspaceId={props.normalizedWorkspaceId}
onResolve={handlePresentation}
/>
))}
</>
);
}
@@ -864,9 +952,8 @@ function ResolvedWorkspaceDesktopTabsRow({
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const [measuredTabLabels, setMeasuredTabLabels] = useState<
Record<string, { label: string; width: number }>
>({});
const labelMeasurementsRef = useRef(new Map<string, WorkspaceTabLabelMeasurement>());
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(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<string, WorkspaceTabLabelMeasurement>();
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 (
<ResolvedDesktopTabChip
@@ -1068,7 +1199,7 @@ function ResolvedWorkspaceDesktopTabsRow({
isFocused={isFocused}
isDragging={isActive}
index={index}
tabCount={tabs.length}
tabCount={displayedTabs.length}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
@@ -1110,7 +1241,7 @@ function ResolvedWorkspaceDesktopTabsRow({
setHoveredCloseTabKey,
tabMenuLabels,
tabDropPreviewIndex,
tabs.length,
displayedTabs.length,
],
);
@@ -1179,10 +1310,10 @@ function ResolvedWorkspaceDesktopTabsRow({
showsHorizontalScrollIndicator={false}
>
<SortableInlineList
data={tabs}
data={displayedTabs}
keyExtractor={tabKeyExtractor}
useDragHandle
disabled={!externalDndContext && tabs.length < 2}
disabled={!externalDndContext && displayedTabs.length < 2}
onDragEnd={handleDragEnd}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { computeWorkspaceTabLayout } from "@/screens/workspace/workspace-tab-layout";
import {
computeWorkspaceTabLayout,
retainWorkspaceTabMeasuredWidth,
} from "@/screens/workspace/workspace-tab-layout";
const metrics = {
rowHorizontalInset: 0,
@@ -7,7 +10,7 @@ const metrics = {
rowPaddingHorizontal: 8,
tabGap: 4,
minTabWidth: 96,
maxTabWidth: 240,
maxTabWidth: 160,
tabIconWidth: 14,
tabContentGap: 4,
tabHorizontalPadding: 8,
@@ -49,7 +52,7 @@ describe("computeWorkspaceTabLayout", () => {
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);
});
});
@@ -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;