refactor: improve tab layout sizing and always show close buttons

This commit is contained in:
Mohamed Boudra
2026-03-04 21:32:57 +07:00
parent 26ea214d64
commit ee8e2ab691
7 changed files with 99 additions and 51 deletions
@@ -139,15 +139,11 @@ export function DraggableList<T>({
nestable: _nestable = false, nestable: _nestable = false,
}: DraggableListProps<T>) { }: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(data); const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbarMetrics = useWebDesktopScrollbarMetrics();
// Sync items with data prop
if (data !== items && !activeId) {
setItems(data);
}
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@@ -159,16 +155,21 @@ export function DraggableList<T>({
}) })
); );
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback(
setActiveId(String(event.active.id)); (event: DragStartEvent) => {
onDragBegin?.(); setDragItems(data);
}, [onDragBegin]); setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, onDragBegin]
);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
setActiveId(null); setActiveId(null);
setDragItems(null);
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
const oldIndex = items.findIndex( const oldIndex = items.findIndex(
@@ -178,9 +179,10 @@ export function DraggableList<T>({
(item, i) => keyExtractor(item, i) === over.id (item, i) => keyExtractor(item, i) === over.id
); );
const newItems = arrayMove(items, oldIndex, newIndex); if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
setItems(newItems); const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd(newItems); onDragEnd(newItems);
}
} }
}, },
[items, keyExtractor, onDragEnd] [items, keyExtractor, onDragEnd]
@@ -20,7 +20,7 @@ import {
type ReactElement, type ReactElement,
type MutableRefObject, type MutableRefObject,
} from 'react' } from 'react'
import { router, usePathname, useSegments } from 'expo-router' import { router, usePathname } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler' import { type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native' import { ChevronDown, ChevronRight } from 'lucide-react-native'
@@ -736,7 +736,6 @@ function ProjectBlock({
iconDataUri, iconDataUri,
serverId, serverId,
activeWorkspaceSelection, activeWorkspaceSelection,
shouldReplaceWorkspaceNavigation,
showShortcutBadges, showShortcutBadges,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
parentGestureRef, parentGestureRef,
@@ -756,7 +755,6 @@ function ProjectBlock({
iconDataUri: string | null iconDataUri: string | null
serverId: string | null serverId: string | null
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null activeWorkspaceSelection: { serverId: string; workspaceId: string } | null
shouldReplaceWorkspaceNavigation: boolean
showShortcutBadges: boolean showShortcutBadges: boolean
shortcutIndexByWorkspaceKey: Map<string, number> shortcutIndexByWorkspaceKey: Map<string, number>
parentGestureRef?: MutableRefObject<GestureType | undefined> parentGestureRef?: MutableRefObject<GestureType | undefined>
@@ -778,7 +776,6 @@ function ProjectBlock({
dragHandleProps: workspaceDragHandleProps, dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => { }: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId) const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
const isSelected = const isSelected =
Boolean(serverId) && Boolean(serverId) &&
activeWorkspaceSelection?.serverId === serverId && activeWorkspaceSelection?.serverId === serverId &&
@@ -795,7 +792,7 @@ function ProjectBlock({
return return
} }
onWorkspacePress?.() onWorkspacePress?.()
navigate(workspaceRoute as any) router.push(workspaceRoute as any)
}} }}
drag={workspaceDrag} drag={workspaceDrag}
isDragging={isActive} isDragging={isActive}
@@ -807,7 +804,6 @@ function ProjectBlock({
activeWorkspaceSelection, activeWorkspaceSelection,
onWorkspacePress, onWorkspacePress,
serverId, serverId,
shouldReplaceWorkspaceNavigation,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
] ]
@@ -861,9 +857,7 @@ export function SidebarWorkspaceList({
parentGestureRef, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const segments = useSegments()
const pathname = usePathname() const pathname = usePathname()
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set()) const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [outerScrollEnabled, setOuterScrollEnabled] = useState(true) const [outerScrollEnabled, setOuterScrollEnabled] = useState(true)
const isTauri = getIsTauri() const isTauri = getIsTauri()
@@ -1081,7 +1075,6 @@ export function SidebarWorkspaceList({
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId} serverId={serverId}
activeWorkspaceSelection={activeWorkspaceSelection} activeWorkspaceSelection={activeWorkspaceSelection}
shouldReplaceWorkspaceNavigation={shouldReplaceWorkspaceNavigation}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutModel.shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutModel.shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef} parentGestureRef={parentGestureRef}
@@ -1106,7 +1099,6 @@ export function SidebarWorkspaceList({
projectIconByProjectKey, projectIconByProjectKey,
serverId, serverId,
shortcutModel.shortcutIndexByWorkspaceKey, shortcutModel.shortcutIndexByWorkspaceKey,
shouldReplaceWorkspaceNavigation,
showShortcutBadges, showShortcutBadges,
toggleProjectCollapsed, toggleProjectCollapsed,
lockOuterScrollForDrag, lockOuterScrollForDrag,
@@ -165,7 +165,6 @@ export function SortableInlineList<T>({
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex); const newItems = arrayMove(items, oldIndex, newIndex);
setDragItems(newItems);
onDragEnd?.(newItems); onDragEnd?.(newItems);
} }
} }
@@ -7,6 +7,7 @@ import {
type UseWorkspaceTabLayoutInput = { type UseWorkspaceTabLayoutInput = {
tabLabels: string[]; tabLabels: string[];
viewportWidthOverride?: number | null;
metrics: { metrics: {
rowHorizontalInset: number; rowHorizontalInset: number;
actionsReservedWidth: number; actionsReservedWidth: number;
@@ -29,15 +30,19 @@ type UseWorkspaceTabLayoutResult = {
export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWorkspaceTabLayoutResult { export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWorkspaceTabLayoutResult {
const { width: viewportWidth } = useWindowDimensions(); const { width: viewportWidth } = useWindowDimensions();
const resolvedViewportWidth =
typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0
? input.viewportWidthOverride
: viewportWidth;
const layout = useMemo( const layout = useMemo(
() => () =>
computeWorkspaceTabLayout({ computeWorkspaceTabLayout({
viewportWidth, viewportWidth: resolvedViewportWidth,
tabLabelLengths: input.tabLabels.map((label) => label.length), tabLabelLengths: input.tabLabels.map((label) => label.length),
metrics: input.metrics, metrics: input.metrics,
}), }),
[input.metrics, input.tabLabels, viewportWidth] [input.metrics, input.tabLabels, resolvedViewportWidth]
); );
return { return {
@@ -1,5 +1,5 @@
import { useMemo, type Dispatch, type SetStateAction } from "react"; import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native"; import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list"; import { SortableInlineList } from "@/components/sortable-inline-list";
@@ -73,11 +73,23 @@ export function WorkspaceDesktopTabsRow({
onReorderTabs, onReorderTabs,
}: WorkspaceDesktopTabsRowProps) { }: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}, []);
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}, []);
const layoutMetrics = useMemo( const layoutMetrics = useMemo(
() => ({ () => ({
rowHorizontalInset: 0, rowHorizontalInset: 0,
actionsReservedWidth: 120, actionsReservedWidth: Math.max(0, tabsActionsWidth),
rowPaddingHorizontal: theme.spacing[2], rowPaddingHorizontal: theme.spacing[2],
tabGap: theme.spacing[1], tabGap: theme.spacing[1],
minTabWidth: 60, minTabWidth: 60,
@@ -86,19 +98,24 @@ export function WorkspaceDesktopTabsRow({
tabHorizontalPadding: theme.spacing[3], tabHorizontalPadding: theme.spacing[3],
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 22,
compactLabelCharCap: 10, compactLabelCharCap: 9,
compactDenseLabelCharCap: 8, compactDenseLabelCharCap: 7,
}), }),
[theme.spacing] [tabsActionsWidth, theme.spacing]
); );
const { layout } = useWorkspaceTabLayout({ const { layout } = useWorkspaceTabLayout({
tabLabels: tabs.map((tab) => tab.label), tabLabels: tabs.map((tab) => tab.label),
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics, metrics: layoutMetrics,
}); });
return ( return (
<View style={styles.tabsContainer} testID="workspace-tabs-row"> <View
style={styles.tabsContainer}
testID="workspace-tabs-row"
onLayout={handleTabsContainerLayout}
>
<ScrollView <ScrollView
horizontal horizontal
testID="workspace-tabs-scroll" testID="workspace-tabs-scroll"
@@ -125,7 +142,7 @@ export function WorkspaceDesktopTabsRow({
const isClosingTerminal = const isClosingTerminal =
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.showCloseButtons; const shouldShowCloseButton = layout.closeButtonPolicy === "all";
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({
@@ -183,6 +200,13 @@ export function WorkspaceDesktopTabsRow({
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
layout.mode === "icon" && styles.tabIconOnly, layout.mode === "icon" && styles.tabIconOnly,
layout.mode === "icon" && {
minWidth: layout.tabMaxWidth,
width: layout.tabMaxWidth,
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,
]} ]}
@@ -320,7 +344,7 @@ export function WorkspaceDesktopTabsRow({
}} }}
/> />
</ScrollView> </ScrollView>
<View style={styles.tabsActions}> <View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
testID="workspace-new-agent-tab" testID="workspace-new-agent-tab"
@@ -415,6 +439,9 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: 40, maxWidth: 40,
justifyContent: "center", justifyContent: "center",
}, },
tabCompact: {
paddingHorizontal: theme.spacing[2],
},
tabHandle: { tabHandle: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -12,8 +12,8 @@ const metrics = {
tabHorizontalPadding: 12, tabHorizontalPadding: 12,
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 22,
compactLabelCharCap: 10, compactLabelCharCap: 9,
compactDenseLabelCharCap: 8, compactDenseLabelCharCap: 7,
}; };
describe("computeWorkspaceTabLayout", () => { describe("computeWorkspaceTabLayout", () => {
@@ -26,7 +26,7 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.mode).toBe("full"); expect(result.mode).toBe("full");
expect(result.showLabels).toBe(true); expect(result.showLabels).toBe(true);
expect(result.showCloseButtons).toBe(true); expect(result.closeButtonPolicy).toBe("all");
}); });
it("uses compact mode before icon-only", () => { it("uses compact mode before icon-only", () => {
@@ -38,7 +38,8 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.mode).toBe("compact"); expect(result.mode).toBe("compact");
expect(result.showLabels).toBe(true); expect(result.showLabels).toBe(true);
expect(result.showCloseButtons).toBe(false); expect(result.closeButtonPolicy).toBe("all");
expect(result.tabMaxWidth).toBeGreaterThan(108);
}); });
it("falls back to icon mode when compact labels still cannot fit", () => { it("falls back to icon mode when compact labels still cannot fit", () => {
@@ -50,7 +51,7 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.mode).toBe("icon"); expect(result.mode).toBe("icon");
expect(result.showLabels).toBe(false); expect(result.showLabels).toBe(false);
expect(result.showCloseButtons).toBe(false); expect(result.closeButtonPolicy).toBe("all");
}); });
it("keeps icon mode without scroll when icons can fit", () => { it("keeps icon mode without scroll when icons can fit", () => {
@@ -72,18 +73,20 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.mode).toBe("compact"); expect(result.mode).toBe("compact");
expect(result.showLabels).toBe(true); expect(result.showLabels).toBe(true);
expect(result.showCloseButtons).toBe(false); expect(result.closeButtonPolicy).toBe("all");
expect(result.tabMaxWidth).toBeGreaterThan(94);
}); });
it("uses dense compact labels for larger tab counts before icon-only", () => { it("falls back to icon mode sooner at tighter widths when close buttons stay visible", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 1100, viewportWidth: 1100,
tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20], tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20],
metrics, metrics,
}); });
expect(result.mode).toBe("compact"); expect(result.mode).toBe("icon");
expect(result.showLabels).toBe(true); expect(result.showLabels).toBe(false);
expect(result.showCloseButtons).toBe(false); expect(result.closeButtonPolicy).toBe("all");
expect(result.tabMaxWidth).toBe(58);
}); });
}); });
@@ -1,4 +1,5 @@
export type WorkspaceTabLayoutMode = "full" | "compact" | "icon"; export type WorkspaceTabLayoutMode = "full" | "compact" | "icon";
export type WorkspaceTabCloseButtonPolicy = "all";
export type WorkspaceTabLayoutInput = { export type WorkspaceTabLayoutInput = {
viewportWidth: number; viewportWidth: number;
@@ -22,7 +23,8 @@ export type WorkspaceTabLayoutInput = {
export type WorkspaceTabLayoutResult = { export type WorkspaceTabLayoutResult = {
mode: WorkspaceTabLayoutMode; mode: WorkspaceTabLayoutMode;
showLabels: boolean; showLabels: boolean;
showCloseButtons: boolean; closeButtonPolicy: WorkspaceTabCloseButtonPolicy;
tabMaxWidth: number;
}; };
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
@@ -62,7 +64,8 @@ export function computeWorkspaceTabLayout(
return { return {
mode: "full", mode: "full",
showLabels: true, showLabels: true,
showCloseButtons: true, closeButtonPolicy: "all",
tabMaxWidth: input.metrics.maxTabWidth,
}; };
} }
@@ -90,13 +93,20 @@ export function computeWorkspaceTabLayout(
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
}); });
if (fullTotal <= availableWidth) { if (fullTotal <= availableWidth) {
return { mode: "full", showLabels: true, showCloseButtons: true }; return {
mode: "full",
showLabels: true,
closeButtonPolicy: "all",
tabMaxWidth: input.metrics.maxTabWidth,
};
} }
const compactTabWidths = input.tabLabelLengths.map((rawLength) => { const compactTabWidths = input.tabLabelLengths.map((rawLength) => {
const labelLength = Math.max(rawLength, 1); const labelLength = Math.max(rawLength, 1);
const estimatedWidth = const estimatedWidth =
baseTabWidth + estimateLabelWidth(Math.min(labelLength, effectiveCompactLabelCharCap)); baseTabWidth +
estimateLabelWidth(Math.min(labelLength, effectiveCompactLabelCharCap)) +
input.metrics.closeButtonWidth;
return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth); return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
}); });
const compactTotal = computeTotalRowWidth({ const compactTotal = computeTotalRowWidth({
@@ -105,8 +115,18 @@ export function computeWorkspaceTabLayout(
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
}); });
if (compactTotal <= availableWidth) { if (compactTotal <= availableWidth) {
return { mode: "compact", showLabels: true, showCloseButtons: false }; return {
mode: "compact",
showLabels: true,
closeButtonPolicy: "all",
tabMaxWidth: Math.max(...compactTabWidths),
};
} }
return { mode: "icon", showLabels: false, showCloseButtons: false }; return {
mode: "icon",
showLabels: false,
closeButtonPolicy: "all",
tabMaxWidth: 58,
};
} }