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:
@@ -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 }) => {
|
||||
|
||||
@@ -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() {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user