feat: simplify tab layout to CSS-driven sizing and throttle terminal state broadcasts
This commit is contained in:
@@ -12,11 +12,12 @@ type UseWorkspaceTabLayoutInput = {
|
||||
actionsReservedWidth: number;
|
||||
rowPaddingHorizontal: number;
|
||||
tabGap: number;
|
||||
minTabWidth: number;
|
||||
maxTabWidth: number;
|
||||
iconOnlyTabWidth: number;
|
||||
tabBaseWidthWithClose: number;
|
||||
minLabelChars: number;
|
||||
charWidth: number;
|
||||
tabIconWidth: number;
|
||||
tabHorizontalPadding: number;
|
||||
estimatedCharWidth: number;
|
||||
closeButtonWidth: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -80,11 +80,12 @@ export function WorkspaceDesktopTabsRow({
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: theme.spacing[2],
|
||||
tabGap: theme.spacing[1],
|
||||
minTabWidth: 60,
|
||||
maxTabWidth: 260,
|
||||
iconOnlyTabWidth: 40,
|
||||
tabBaseWidthWithClose: 84,
|
||||
minLabelChars: 4,
|
||||
charWidth: 7,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: theme.spacing[3],
|
||||
estimatedCharWidth: 7,
|
||||
closeButtonWidth: 22,
|
||||
}),
|
||||
[theme.spacing]
|
||||
);
|
||||
@@ -98,7 +99,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
<View style={styles.tabsContainer} testID="workspace-tabs-row">
|
||||
<ScrollView
|
||||
horizontal
|
||||
scrollEnabled={layout.shouldScroll}
|
||||
testID="workspace-tabs-scroll"
|
||||
style={styles.tabsScroll}
|
||||
contentContainerStyle={styles.tabsContent}
|
||||
@@ -110,7 +110,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
useDragHandle
|
||||
disabled={tabs.length < 2}
|
||||
onDragEnd={onReorderTabs}
|
||||
renderItem={({ item: tab, dragHandleProps, index }) => {
|
||||
renderItem={({ item: tab, dragHandleProps }) => {
|
||||
const isActive = tab.key === activeTabKey;
|
||||
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
||||
@@ -125,7 +125,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
const isClosingTab = isClosingAgent || isClosingTerminal;
|
||||
const shouldShowCloseButton = layout.showCloseButtons;
|
||||
const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const tabWidth = layout.tabWidths[index] ?? layoutMetrics.maxTabWidth;
|
||||
const tabAgentStatusBucket = tabAgent
|
||||
? deriveSidebarStateBucket({
|
||||
status: tabAgent.status,
|
||||
@@ -181,7 +180,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
{ width: tabWidth, minWidth: tabWidth, maxWidth: tabWidth },
|
||||
layout.mode === "icon" && styles.tabIconOnly,
|
||||
isActive && styles.tabActive,
|
||||
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||
@@ -200,9 +198,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={(node: unknown) => {
|
||||
dragHandleProps?.setActivatorNodeRef?.(node);
|
||||
}}
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
>
|
||||
<View style={styles.tabIcon}>{icon}</View>
|
||||
@@ -386,6 +382,8 @@ 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,
|
||||
@@ -394,6 +392,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tabIconOnly: {
|
||||
minWidth: 40,
|
||||
width: 40,
|
||||
maxWidth: 40,
|
||||
justifyContent: "center",
|
||||
},
|
||||
tabHandle: {
|
||||
|
||||
@@ -575,6 +575,9 @@ function WorkspaceScreenContent({
|
||||
if (normalized.startsWith("agent_")) {
|
||||
const agentId = normalized.slice("agent_".length).trim();
|
||||
if (agentId) {
|
||||
if (areWorkspaceAgentsHydrated && !agentsById.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
const tabId = openOrFocusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -593,6 +596,9 @@ function WorkspaceScreenContent({
|
||||
if (normalized.startsWith("terminal_")) {
|
||||
const terminalId = normalized.slice("terminal_".length).trim();
|
||||
if (terminalId) {
|
||||
if (areWorkspaceTerminalsHydrated && !terminalIds.has(terminalId)) {
|
||||
return;
|
||||
}
|
||||
const tabId = openOrFocusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -624,6 +630,9 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
}, [
|
||||
focusTab,
|
||||
agentsById,
|
||||
areWorkspaceAgentsHydrated,
|
||||
areWorkspaceTerminalsHydrated,
|
||||
openDraftTab,
|
||||
openOrFocusTab,
|
||||
openTabs,
|
||||
@@ -631,6 +640,7 @@ function WorkspaceScreenContent({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
routeTabId,
|
||||
terminalIds,
|
||||
]);
|
||||
|
||||
const activeTabId = useMemo(() => {
|
||||
|
||||
@@ -6,11 +6,12 @@ const metrics = {
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: 8,
|
||||
tabGap: 4,
|
||||
minTabWidth: 60,
|
||||
maxTabWidth: 260,
|
||||
iconOnlyTabWidth: 40,
|
||||
tabBaseWidthWithClose: 84,
|
||||
minLabelChars: 4,
|
||||
charWidth: 7,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: 12,
|
||||
estimatedCharWidth: 7,
|
||||
closeButtonWidth: 22,
|
||||
};
|
||||
|
||||
describe("computeWorkspaceTabLayout", () => {
|
||||
@@ -22,23 +23,20 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("full");
|
||||
expect(result.shouldScroll).toBe(false);
|
||||
expect(result.showLabels).toBe(true);
|
||||
expect(result.showCloseButtons).toBe(true);
|
||||
});
|
||||
|
||||
it("shrinks proportionally in compact mode before icon-only", () => {
|
||||
it("uses compact mode before icon-only", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 500,
|
||||
viewportWidth: 570,
|
||||
tabLabelLengths: [24, 12, 8],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("compact");
|
||||
expect(result.shouldScroll).toBe(false);
|
||||
expect(result.showLabels).toBe(true);
|
||||
expect(result.tabWidths[0]).toBeGreaterThan(result.tabWidths[1]);
|
||||
expect(result.tabWidths[1]).toBeGreaterThan(result.tabWidths[2]);
|
||||
expect(result.showCloseButtons).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to icon mode when compact labels still cannot fit", () => {
|
||||
@@ -51,7 +49,6 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.mode).toBe("icon");
|
||||
expect(result.showLabels).toBe(false);
|
||||
expect(result.showCloseButtons).toBe(false);
|
||||
expect(result.shouldScroll).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps icon mode without scroll when icons can fit", () => {
|
||||
@@ -62,6 +59,5 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("icon");
|
||||
expect(result.shouldScroll).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,18 +8,17 @@ export type WorkspaceTabLayoutInput = {
|
||||
actionsReservedWidth: number;
|
||||
rowPaddingHorizontal: number;
|
||||
tabGap: number;
|
||||
minTabWidth: number;
|
||||
maxTabWidth: number;
|
||||
iconOnlyTabWidth: number;
|
||||
tabBaseWidthWithClose: number;
|
||||
minLabelChars: number;
|
||||
charWidth: number;
|
||||
tabIconWidth: number;
|
||||
tabHorizontalPadding: number;
|
||||
estimatedCharWidth: number;
|
||||
closeButtonWidth: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkspaceTabLayoutResult = {
|
||||
mode: WorkspaceTabLayoutMode;
|
||||
tabWidths: number[];
|
||||
shouldScroll: boolean;
|
||||
showLabels: boolean;
|
||||
showCloseButtons: boolean;
|
||||
};
|
||||
@@ -42,18 +41,14 @@ function sum(values: number[]): number {
|
||||
return total;
|
||||
}
|
||||
|
||||
function computeTotalRowWidth(input: {
|
||||
tabWidths: number[];
|
||||
tabGap: number;
|
||||
rowPaddingHorizontal: number;
|
||||
}): number {
|
||||
if (input.tabWidths.length === 0) {
|
||||
function computeTotalRowWidth(input: { itemWidths: number[]; tabGap: number; rowPaddingHorizontal: number }): number {
|
||||
if (input.itemWidths.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
input.rowPaddingHorizontal * 2 +
|
||||
sum(input.tabWidths) +
|
||||
Math.max(input.tabWidths.length - 1, 0) * input.tabGap
|
||||
sum(input.itemWidths) +
|
||||
Math.max(input.itemWidths.length - 1, 0) * input.tabGap
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,8 +59,6 @@ export function computeWorkspaceTabLayout(
|
||||
if (tabCount === 0) {
|
||||
return {
|
||||
mode: "full",
|
||||
tabWidths: [],
|
||||
shouldScroll: false,
|
||||
showLabels: true,
|
||||
showCloseButtons: true,
|
||||
};
|
||||
@@ -75,68 +68,36 @@ export function computeWorkspaceTabLayout(
|
||||
0,
|
||||
input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth
|
||||
);
|
||||
const minLabelWidth =
|
||||
input.metrics.tabBaseWidthWithClose + input.metrics.charWidth * input.metrics.minLabelChars;
|
||||
const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2;
|
||||
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
||||
|
||||
const preferredTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||
const fullTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||
const labelLength = Math.max(rawLength, 1);
|
||||
const preferred = input.metrics.tabBaseWidthWithClose + labelLength * input.metrics.charWidth;
|
||||
return clamp(preferred, minLabelWidth, input.metrics.maxTabWidth);
|
||||
const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength) + input.metrics.closeButtonWidth;
|
||||
return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
|
||||
});
|
||||
|
||||
const preferredTotal = computeTotalRowWidth({
|
||||
tabWidths: preferredTabWidths,
|
||||
const fullTotal = computeTotalRowWidth({
|
||||
itemWidths: fullTabWidths,
|
||||
tabGap: input.metrics.tabGap,
|
||||
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
|
||||
});
|
||||
|
||||
if (preferredTotal <= availableWidth) {
|
||||
return {
|
||||
mode: "full",
|
||||
tabWidths: preferredTabWidths,
|
||||
shouldScroll: false,
|
||||
showLabels: true,
|
||||
showCloseButtons: true,
|
||||
};
|
||||
if (fullTotal <= availableWidth) {
|
||||
return { mode: "full", showLabels: true, showCloseButtons: true };
|
||||
}
|
||||
|
||||
const compactMinTabWidths = preferredTabWidths.map(() => minLabelWidth);
|
||||
const compactTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||
const labelLength = Math.max(rawLength, 1);
|
||||
const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength);
|
||||
return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
|
||||
});
|
||||
const compactTotal = computeTotalRowWidth({
|
||||
tabWidths: compactMinTabWidths,
|
||||
itemWidths: compactTabWidths,
|
||||
tabGap: input.metrics.tabGap,
|
||||
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
|
||||
});
|
||||
|
||||
if (compactTotal <= availableWidth) {
|
||||
const totalShrinkCapacity = sum(preferredTabWidths.map((width) => width - minLabelWidth));
|
||||
const targetShrink = preferredTotal - availableWidth;
|
||||
const shrinkRatio = totalShrinkCapacity > 0 ? targetShrink / totalShrinkCapacity : 1;
|
||||
const tabWidths = preferredTabWidths.map((preferred) => {
|
||||
const shrinkCapacity = preferred - minLabelWidth;
|
||||
return preferred - shrinkCapacity * shrinkRatio;
|
||||
});
|
||||
|
||||
return {
|
||||
mode: "compact",
|
||||
tabWidths,
|
||||
shouldScroll: false,
|
||||
showLabels: true,
|
||||
showCloseButtons: true,
|
||||
};
|
||||
return { mode: "compact", showLabels: true, showCloseButtons: false };
|
||||
}
|
||||
|
||||
const iconTabWidths = preferredTabWidths.map(() => input.metrics.iconOnlyTabWidth);
|
||||
const iconTotal = computeTotalRowWidth({
|
||||
tabWidths: iconTabWidths,
|
||||
tabGap: input.metrics.tabGap,
|
||||
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
|
||||
});
|
||||
|
||||
return {
|
||||
mode: "icon",
|
||||
tabWidths: iconTabWidths,
|
||||
shouldScroll: iconTotal > availableWidth,
|
||||
showLabels: false,
|
||||
showCloseButtons: false,
|
||||
};
|
||||
return { mode: "icon", showLabels: false, showCloseButtons: false };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user