From 6f2b967599a756f4817e5117ea8f6ea992498c79 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Mar 2026 10:22:30 +0700 Subject: [PATCH] refactor: replace tab layout modes with per-tab proportional sizing --- .../workspace/use-workspace-tab-layout.ts | 3 - .../workspace/workspace-desktop-tabs-row.tsx | 39 ++---- .../workspace/workspace-tab-layout.test.ts | 77 +++++------ .../screens/workspace/workspace-tab-layout.ts | 123 ++++++++---------- 4 files changed, 99 insertions(+), 143 deletions(-) diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index b66d4ba57..e76c7a2c0 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -13,14 +13,11 @@ type UseWorkspaceTabLayoutInput = { actionsReservedWidth: number; rowPaddingHorizontal: number; tabGap: number; - minTabWidth: number; maxTabWidth: number; tabIconWidth: number; tabHorizontalPadding: number; estimatedCharWidth: number; closeButtonWidth: number; - compactLabelCharCap?: number; - compactDenseLabelCharCap?: number; }; }; 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 799d9c510..95acd017c 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -92,14 +92,11 @@ export function WorkspaceDesktopTabsRow({ actionsReservedWidth: Math.max(0, tabsActionsWidth), rowPaddingHorizontal: theme.spacing[2], tabGap: theme.spacing[1], - minTabWidth: 60, - maxTabWidth: 260, + maxTabWidth: 150, tabIconWidth: 14, tabHorizontalPadding: theme.spacing[3], estimatedCharWidth: 7, closeButtonWidth: 22, - compactLabelCharCap: 9, - compactDenseLabelCharCap: 7, }), [tabsActionsWidth, theme.spacing] ); @@ -118,6 +115,7 @@ export function WorkspaceDesktopTabsRow({ > { + renderItem={({ item: tab, index, dragHandleProps }) => { const isActive = tab.key === activeTabKey; const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; const isCloseHovered = hoveredCloseTabKey === tab.key; @@ -143,6 +141,11 @@ export function WorkspaceDesktopTabsRow({ tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId; const isClosingTab = isClosingAgent || isClosingTerminal; const shouldShowCloseButton = layout.closeButtonPolicy === "all"; + const layoutItem = layout.items[index] ?? null; + const resolvedTabWidth = layoutItem?.width ?? 150; + const showLabel = layoutItem?.showLabel ?? true; + const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length; + const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : ""; const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted; const tabAgentStatusBucket = tabAgent ? deriveSidebarStateBucket({ @@ -199,14 +202,11 @@ export function WorkspaceDesktopTabsRow({ enabledOnMobile={false} style={({ hovered, pressed }) => [ styles.tab, - layout.mode === "icon" && styles.tabIconOnly, - layout.mode === "icon" && { - minWidth: layout.tabMaxWidth, - width: layout.tabMaxWidth, - maxWidth: layout.tabMaxWidth, + { + minWidth: resolvedTabWidth, + width: resolvedTabWidth, + maxWidth: resolvedTabWidth, }, - layout.mode === "compact" && styles.tabCompact, - layout.mode === "compact" && { maxWidth: layout.tabMaxWidth }, isActive && styles.tabActive, (hovered || pressed || isCloseHovered) && styles.tabHovered, ]} @@ -235,7 +235,7 @@ export function WorkspaceDesktopTabsRow({ style={styles.tabHandle} > {icon} - {layout.showLabels ? ( + {showLabel ? ( tab.kind === "agent" && tab.titleState === "loading" ? ( - {tab.label} + {renderedLabel} ) ) : null} @@ -424,8 +424,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], }, tab: { - minWidth: 60, - maxWidth: 260, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, @@ -433,15 +431,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1], }, - tabIconOnly: { - minWidth: 40, - width: 40, - maxWidth: 40, - justifyContent: "center", - }, - tabCompact: { - paddingHorizontal: theme.spacing[2], - }, tabHandle: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 5743e49c7..198895024 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -6,87 +6,76 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - minTabWidth: 60, - maxTabWidth: 260, + maxTabWidth: 150, tabIconWidth: 14, tabHorizontalPadding: 12, estimatedCharWidth: 7, closeButtonWidth: 22, - compactLabelCharCap: 9, - compactDenseLabelCharCap: 7, }; describe("computeWorkspaceTabLayout", () => { - it("keeps full width tabs when space is available", () => { + it("caps tab width to the fixed maximum when enough space is available", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 1200, tabLabelLengths: [8, 10, 7], metrics, }); - expect(result.mode).toBe("full"); - expect(result.showLabels).toBe(true); expect(result.closeButtonPolicy).toBe("all"); + expect(result.requiresHorizontalScrollFallback).toBe(false); + expect(result.items).toHaveLength(3); + expect(result.items.every((item) => item.showLabel)).toBe(true); + expect(Math.max(...result.items.map((item) => item.width))).toBeLessThanOrEqual(150); }); - it("uses compact mode before icon-only", () => { + it("shrinks tab widths proportionally before hiding labels", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 570, + viewportWidth: 520, tabLabelLengths: [24, 12, 8], metrics, }); - expect(result.mode).toBe("compact"); - expect(result.showLabels).toBe(true); expect(result.closeButtonPolicy).toBe("all"); - expect(result.tabMaxWidth).toBeGreaterThan(108); + expect(result.requiresHorizontalScrollFallback).toBe(false); + expect(result.items.map((item) => item.width)).toEqual([137, 132, 108]); + expect(result.items.every((item) => item.showLabel)).toBe(true); }); - it("falls back to icon mode when compact labels still cannot fit", () => { + it("collapses to icon-only before allowing horizontal scroll fallback", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 388, + tabLabelLengths: [14, 14, 14, 14], + metrics, + }); + + expect(result.closeButtonPolicy).toBe("all"); + expect(result.requiresHorizontalScrollFallback).toBe(false); + expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]); + expect(result.items.every((item) => item.showLabel === false)).toBe(true); + }); + + it("allows horizontal scroll only when icon-only tabs still cannot fit", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 300, tabLabelLengths: [14, 14, 14, 14], metrics, }); - expect(result.mode).toBe("icon"); - expect(result.showLabels).toBe(false); expect(result.closeButtonPolicy).toBe("all"); + expect(result.requiresHorizontalScrollFallback).toBe(true); + expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]); + expect(result.items.every((item) => item.showLabel === false)).toBe(true); }); - it("keeps icon mode without scroll when icons can fit", () => { + it("returns empty layout details when there are no tabs", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 380, - tabLabelLengths: [20, 20, 20, 20], + viewportWidth: 1200, + tabLabelLengths: [], metrics, }); - expect(result.mode).toBe("icon"); - }); - - it("keeps compact-with-label mode for realistic desktop width with nine tabs", () => { - const result = computeWorkspaceTabLayout({ - viewportWidth: 1180, - tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20], - metrics, - }); - - expect(result.mode).toBe("compact"); - expect(result.showLabels).toBe(true); expect(result.closeButtonPolicy).toBe("all"); - expect(result.tabMaxWidth).toBeGreaterThan(94); - }); - - it("falls back to icon mode sooner at tighter widths when close buttons stay visible", () => { - const result = computeWorkspaceTabLayout({ - viewportWidth: 1100, - tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20], - metrics, - }); - - expect(result.mode).toBe("icon"); - expect(result.showLabels).toBe(false); - expect(result.closeButtonPolicy).toBe("all"); - expect(result.tabMaxWidth).toBe(58); + expect(result.requiresHorizontalScrollFallback).toBe(false); + expect(result.items).toEqual([]); }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 64616d127..150387316 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,4 +1,3 @@ -export type WorkspaceTabLayoutMode = "full" | "compact" | "icon"; export type WorkspaceTabCloseButtonPolicy = "all"; export type WorkspaceTabLayoutInput = { @@ -9,22 +8,24 @@ export type WorkspaceTabLayoutInput = { actionsReservedWidth: number; rowPaddingHorizontal: number; tabGap: number; - minTabWidth: number; maxTabWidth: number; tabIconWidth: number; tabHorizontalPadding: number; estimatedCharWidth: number; closeButtonWidth: number; - compactLabelCharCap?: number; - compactDenseLabelCharCap?: number; }; }; +export type WorkspaceTabLayoutItem = { + width: number; + showLabel: boolean; + labelCharCap: number; +}; + export type WorkspaceTabLayoutResult = { - mode: WorkspaceTabLayoutMode; - showLabels: boolean; + items: WorkspaceTabLayoutItem[]; closeButtonPolicy: WorkspaceTabCloseButtonPolicy; - tabMaxWidth: number; + requiresHorizontalScrollFallback: boolean; }; function clamp(value: number, min: number, max: number): number { @@ -45,27 +46,15 @@ function sum(values: number[]): number { return total; } -function computeTotalRowWidth(input: { itemWidths: number[]; tabGap: number; rowPaddingHorizontal: number }): number { - if (input.itemWidths.length === 0) { - return 0; - } - return ( - input.rowPaddingHorizontal * 2 + - sum(input.itemWidths) + - Math.max(input.itemWidths.length - 1, 0) * input.tabGap - ); -} - export function computeWorkspaceTabLayout( input: WorkspaceTabLayoutInput ): WorkspaceTabLayoutResult { const tabCount = input.tabLabelLengths.length; if (tabCount === 0) { return { - mode: "full", - showLabels: true, + items: [], closeButtonPolicy: "all", - tabMaxWidth: input.metrics.maxTabWidth, + requiresHorizontalScrollFallback: false, }; } @@ -73,60 +62,52 @@ export function computeWorkspaceTabLayout( 0, input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth ); - const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2; + const rowOverhead = + input.metrics.rowPaddingHorizontal * 2 + Math.max(tabCount - 1, 0) * input.metrics.tabGap; + const availableTabsWidth = Math.max(0, availableWidth - rowOverhead); + const iconOnlyTabWidth = + input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth; const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth; - const compactLabelCharCap = Math.max(1, input.metrics.compactLabelCharCap ?? 12); - const compactDenseLabelCharCap = Math.max( - 1, - input.metrics.compactDenseLabelCharCap ?? Math.max(1, compactLabelCharCap - 2) + const desiredTabWidths = input.tabLabelLengths.map((rawLength) => { + const labelLength = Math.max(rawLength, 1); + const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength); + return clamp(estimatedWidth, iconOnlyTabWidth, input.metrics.maxTabWidth); + }); + + const desiredTotalTabsWidth = sum(desiredTabWidths); + const iconOnlyTotalTabsWidth = iconOnlyTabWidth * tabCount; + const requiresHorizontalScrollFallback = availableTabsWidth < iconOnlyTotalTabsWidth; + + let resolvedWidths: number[]; + if (desiredTotalTabsWidth <= availableTabsWidth) { + resolvedWidths = desiredTabWidths; + } else if (availableTabsWidth <= iconOnlyTotalTabsWidth) { + resolvedWidths = new Array(tabCount).fill(iconOnlyTabWidth); + } else { + const desiredExpandableWidth = desiredTotalTabsWidth - iconOnlyTotalTabsWidth; + const availableExpandableWidth = availableTabsWidth - iconOnlyTotalTabsWidth; + const proportionalRatio = + desiredExpandableWidth > 0 ? availableExpandableWidth / desiredExpandableWidth : 0; + resolvedWidths = desiredTabWidths.map( + (desiredWidth) => iconOnlyTabWidth + (desiredWidth - iconOnlyTabWidth) * proportionalRatio + ); + } + + const roundedWidths = resolvedWidths.map((width) => + Math.round(clamp(width, iconOnlyTabWidth, input.metrics.maxTabWidth)) ); - const effectiveCompactLabelCharCap = tabCount >= 8 ? compactDenseLabelCharCap : compactLabelCharCap; - - const fullTabWidths = input.tabLabelLengths.map((rawLength) => { - const labelLength = Math.max(rawLength, 1); - const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength) + input.metrics.closeButtonWidth; - return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth); - }); - const fullTotal = computeTotalRowWidth({ - itemWidths: fullTabWidths, - tabGap: input.metrics.tabGap, - rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, - }); - if (fullTotal <= availableWidth) { - return { - mode: "full", - showLabels: true, - closeButtonPolicy: "all", - tabMaxWidth: input.metrics.maxTabWidth, - }; - } - - const compactTabWidths = input.tabLabelLengths.map((rawLength) => { - const labelLength = Math.max(rawLength, 1); - const estimatedWidth = - baseTabWidth + - estimateLabelWidth(Math.min(labelLength, effectiveCompactLabelCharCap)) + - input.metrics.closeButtonWidth; - return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth); - }); - const compactTotal = computeTotalRowWidth({ - itemWidths: compactTabWidths, - tabGap: input.metrics.tabGap, - rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, - }); - if (compactTotal <= availableWidth) { - return { - mode: "compact", - showLabels: true, - closeButtonPolicy: "all", - tabMaxWidth: Math.max(...compactTabWidths), - }; - } return { - mode: "icon", - showLabels: false, + items: roundedWidths.map((width) => { + const rawCharCap = Math.floor((width - iconOnlyTabWidth) / input.metrics.estimatedCharWidth); + const labelCharCap = Math.max(0, rawCharCap); + return { + width, + showLabel: labelCharCap > 0, + labelCharCap, + }; + }), closeButtonPolicy: "all", - tabMaxWidth: 58, + requiresHorizontalScrollFallback, }; }