refactor: replace tab layout modes with per-tab proportional sizing

This commit is contained in:
Mohamed Boudra
2026-03-05 10:22:30 +07:00
parent bdfed2db4b
commit 6f2b967599
4 changed files with 99 additions and 143 deletions
@@ -13,14 +13,11 @@ type UseWorkspaceTabLayoutInput = {
actionsReservedWidth: number; actionsReservedWidth: number;
rowPaddingHorizontal: number; rowPaddingHorizontal: number;
tabGap: number; tabGap: number;
minTabWidth: number;
maxTabWidth: number; maxTabWidth: number;
tabIconWidth: number; tabIconWidth: number;
tabHorizontalPadding: number; tabHorizontalPadding: number;
estimatedCharWidth: number; estimatedCharWidth: number;
closeButtonWidth: number; closeButtonWidth: number;
compactLabelCharCap?: number;
compactDenseLabelCharCap?: number;
}; };
}; };
@@ -92,14 +92,11 @@ export function WorkspaceDesktopTabsRow({
actionsReservedWidth: Math.max(0, tabsActionsWidth), actionsReservedWidth: Math.max(0, tabsActionsWidth),
rowPaddingHorizontal: theme.spacing[2], rowPaddingHorizontal: theme.spacing[2],
tabGap: theme.spacing[1], tabGap: theme.spacing[1],
minTabWidth: 60, maxTabWidth: 150,
maxTabWidth: 260,
tabIconWidth: 14, tabIconWidth: 14,
tabHorizontalPadding: theme.spacing[3], tabHorizontalPadding: theme.spacing[3],
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 22,
compactLabelCharCap: 9,
compactDenseLabelCharCap: 7,
}), }),
[tabsActionsWidth, theme.spacing] [tabsActionsWidth, theme.spacing]
); );
@@ -118,6 +115,7 @@ export function WorkspaceDesktopTabsRow({
> >
<ScrollView <ScrollView
horizontal horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll" testID="workspace-tabs-scroll"
style={styles.tabsScroll} style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent} contentContainerStyle={styles.tabsContent}
@@ -129,7 +127,7 @@ export function WorkspaceDesktopTabsRow({
useDragHandle useDragHandle
disabled={tabs.length < 2} disabled={tabs.length < 2}
onDragEnd={onReorderTabs} onDragEnd={onReorderTabs}
renderItem={({ item: tab, dragHandleProps }) => { renderItem={({ item: tab, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey; const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isCloseHovered = hoveredCloseTabKey === tab.key; const isCloseHovered = hoveredCloseTabKey === tab.key;
@@ -143,6 +141,11 @@ export function WorkspaceDesktopTabsRow({
tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId; tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal; const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = layout.closeButtonPolicy === "all"; 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 iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted;
const tabAgentStatusBucket = tabAgent const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({ ? deriveSidebarStateBucket({
@@ -199,14 +202,11 @@ export function WorkspaceDesktopTabsRow({
enabledOnMobile={false} enabledOnMobile={false}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
layout.mode === "icon" && styles.tabIconOnly, {
layout.mode === "icon" && { minWidth: resolvedTabWidth,
minWidth: layout.tabMaxWidth, width: resolvedTabWidth,
width: layout.tabMaxWidth, maxWidth: resolvedTabWidth,
maxWidth: layout.tabMaxWidth,
}, },
layout.mode === "compact" && styles.tabCompact,
layout.mode === "compact" && { maxWidth: layout.tabMaxWidth },
isActive && styles.tabActive, isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered, (hovered || pressed || isCloseHovered) && styles.tabHovered,
]} ]}
@@ -235,7 +235,7 @@ export function WorkspaceDesktopTabsRow({
style={styles.tabHandle} style={styles.tabHandle}
> >
<View style={styles.tabIcon}>{icon}</View> <View style={styles.tabIcon}>{icon}</View>
{layout.showLabels ? ( {showLabel ? (
tab.kind === "agent" && tab.titleState === "loading" ? ( tab.kind === "agent" && tab.titleState === "loading" ? (
<View <View
style={[ style={[
@@ -252,7 +252,7 @@ export function WorkspaceDesktopTabsRow({
]} ]}
numberOfLines={1} numberOfLines={1}
> >
{tab.label} {renderedLabel}
</Text> </Text>
) )
) : null} ) : null}
@@ -424,8 +424,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
tab: { tab: {
minWidth: 60,
maxWidth: 260,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
@@ -433,15 +431,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
tabIconOnly: {
minWidth: 40,
width: 40,
maxWidth: 40,
justifyContent: "center",
},
tabCompact: {
paddingHorizontal: theme.spacing[2],
},
tabHandle: { tabHandle: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -6,87 +6,76 @@ const metrics = {
actionsReservedWidth: 120, actionsReservedWidth: 120,
rowPaddingHorizontal: 8, rowPaddingHorizontal: 8,
tabGap: 4, tabGap: 4,
minTabWidth: 60, maxTabWidth: 150,
maxTabWidth: 260,
tabIconWidth: 14, tabIconWidth: 14,
tabHorizontalPadding: 12, tabHorizontalPadding: 12,
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 22,
compactLabelCharCap: 9,
compactDenseLabelCharCap: 7,
}; };
describe("computeWorkspaceTabLayout", () => { 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({ const result = computeWorkspaceTabLayout({
viewportWidth: 1200, viewportWidth: 1200,
tabLabelLengths: [8, 10, 7], tabLabelLengths: [8, 10, 7],
metrics, metrics,
}); });
expect(result.mode).toBe("full");
expect(result.showLabels).toBe(true);
expect(result.closeButtonPolicy).toBe("all"); 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({ const result = computeWorkspaceTabLayout({
viewportWidth: 570, viewportWidth: 520,
tabLabelLengths: [24, 12, 8], tabLabelLengths: [24, 12, 8],
metrics, metrics,
}); });
expect(result.mode).toBe("compact");
expect(result.showLabels).toBe(true);
expect(result.closeButtonPolicy).toBe("all"); 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({ const result = computeWorkspaceTabLayout({
viewportWidth: 300, viewportWidth: 300,
tabLabelLengths: [14, 14, 14, 14], tabLabelLengths: [14, 14, 14, 14],
metrics, metrics,
}); });
expect(result.mode).toBe("icon");
expect(result.showLabels).toBe(false);
expect(result.closeButtonPolicy).toBe("all"); 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({ const result = computeWorkspaceTabLayout({
viewportWidth: 380, viewportWidth: 1200,
tabLabelLengths: [20, 20, 20, 20], tabLabelLengths: [],
metrics, 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.closeButtonPolicy).toBe("all");
expect(result.tabMaxWidth).toBeGreaterThan(94); expect(result.requiresHorizontalScrollFallback).toBe(false);
}); expect(result.items).toEqual([]);
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);
}); });
}); });
@@ -1,4 +1,3 @@
export type WorkspaceTabLayoutMode = "full" | "compact" | "icon";
export type WorkspaceTabCloseButtonPolicy = "all"; export type WorkspaceTabCloseButtonPolicy = "all";
export type WorkspaceTabLayoutInput = { export type WorkspaceTabLayoutInput = {
@@ -9,22 +8,24 @@ export type WorkspaceTabLayoutInput = {
actionsReservedWidth: number; actionsReservedWidth: number;
rowPaddingHorizontal: number; rowPaddingHorizontal: number;
tabGap: number; tabGap: number;
minTabWidth: number;
maxTabWidth: number; maxTabWidth: number;
tabIconWidth: number; tabIconWidth: number;
tabHorizontalPadding: number; tabHorizontalPadding: number;
estimatedCharWidth: number; estimatedCharWidth: number;
closeButtonWidth: number; closeButtonWidth: number;
compactLabelCharCap?: number;
compactDenseLabelCharCap?: number;
}; };
}; };
export type WorkspaceTabLayoutItem = {
width: number;
showLabel: boolean;
labelCharCap: number;
};
export type WorkspaceTabLayoutResult = { export type WorkspaceTabLayoutResult = {
mode: WorkspaceTabLayoutMode; items: WorkspaceTabLayoutItem[];
showLabels: boolean;
closeButtonPolicy: WorkspaceTabCloseButtonPolicy; closeButtonPolicy: WorkspaceTabCloseButtonPolicy;
tabMaxWidth: number; requiresHorizontalScrollFallback: boolean;
}; };
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
@@ -45,27 +46,15 @@ function sum(values: number[]): number {
return total; 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( export function computeWorkspaceTabLayout(
input: WorkspaceTabLayoutInput input: WorkspaceTabLayoutInput
): WorkspaceTabLayoutResult { ): WorkspaceTabLayoutResult {
const tabCount = input.tabLabelLengths.length; const tabCount = input.tabLabelLengths.length;
if (tabCount === 0) { if (tabCount === 0) {
return { return {
mode: "full", items: [],
showLabels: true,
closeButtonPolicy: "all", closeButtonPolicy: "all",
tabMaxWidth: input.metrics.maxTabWidth, requiresHorizontalScrollFallback: false,
}; };
} }
@@ -73,60 +62,52 @@ export function computeWorkspaceTabLayout(
0, 0,
input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth 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 estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
const compactLabelCharCap = Math.max(1, input.metrics.compactLabelCharCap ?? 12); const desiredTabWidths = input.tabLabelLengths.map((rawLength) => {
const compactDenseLabelCharCap = Math.max( const labelLength = Math.max(rawLength, 1);
1, const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength);
input.metrics.compactDenseLabelCharCap ?? Math.max(1, compactLabelCharCap - 2) 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 { return {
mode: "icon", items: roundedWidths.map((width) => {
showLabels: false, const rawCharCap = Math.floor((width - iconOnlyTabWidth) / input.metrics.estimatedCharWidth);
const labelCharCap = Math.max(0, rawCharCap);
return {
width,
showLabel: labelCharCap > 0,
labelCharCap,
};
}),
closeButtonPolicy: "all", closeButtonPolicy: "all",
tabMaxWidth: 58, requiresHorizontalScrollFallback,
}; };
} }