feat: workspace-level sidebar shortcuts with shortcut badges and desktop tabs extraction

This commit is contained in:
Mohamed Boudra
2026-03-03 20:56:42 +07:00
parent 73eabd2eea
commit 74ac17f910
22 changed files with 1287 additions and 629 deletions
@@ -1032,7 +1032,6 @@ function DraftAgentScreenContent({
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
return next
})
void createAgentClient.refreshAgent(agentId).catch(() => undefined)
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
return
}
@@ -0,0 +1,55 @@
import { useCallback, useMemo, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import {
computeWorkspaceTabLayout,
type WorkspaceTabLayoutResult,
} from "@/screens/workspace/workspace-tab-layout";
type UseWorkspaceTabLayoutInput = {
tabLabels: string[];
metrics: {
rowPaddingHorizontal: number;
tabGap: number;
maxTabWidth: number;
iconOnlyTabWidth: number;
tabBaseWidthWithClose: number;
minLabelChars: number;
charWidth: number;
};
};
type UseWorkspaceTabLayoutResult = {
layout: WorkspaceTabLayoutResult;
onContainerLayout: (event: LayoutChangeEvent) => void;
onActionsLayout: (event: LayoutChangeEvent) => void;
};
export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWorkspaceTabLayoutResult {
const [containerWidth, setContainerWidth] = useState(0);
const [actionsWidth, setActionsWidth] = useState(0);
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
setContainerWidth(event.nativeEvent.layout.width);
}, []);
const onActionsLayout = useCallback((event: LayoutChangeEvent) => {
setActionsWidth(event.nativeEvent.layout.width);
}, []);
const layout = useMemo(
() =>
computeWorkspaceTabLayout({
containerWidth,
actionsWidth,
tabLabelLengths: input.tabLabels.map((label) => label.length),
metrics: input.metrics,
}),
[actionsWidth, containerWidth, input.metrics, input.tabLabels]
);
return {
layout,
onContainerLayout,
onActionsLayout,
};
}
@@ -0,0 +1,495 @@
import { useMemo, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
import type { Agent } from "@/stores/session-store";
const DROPDOWN_WIDTH = 220;
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
type WorkspaceDesktopTabsRowProps = {
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
agentsById: Map<string, Agent>;
normalizedServerId: string;
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
killTerminalPending: boolean;
killTerminalId: string | null;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
newTabAgentOptionId: NewTabOptionId;
newTabTerminalOptionId: NewTabOptionId;
createTerminalPending: boolean;
isNewTerminalHovered: boolean;
setIsNewTerminalHovered: Dispatch<SetStateAction<boolean>>;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
};
export function WorkspaceDesktopTabsRow({
tabs,
activeTabKey,
agentsById,
normalizedServerId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToRight,
onSelectNewTabOption,
newTabAgentOptionId,
newTabTerminalOptionId,
createTerminalPending,
isNewTerminalHovered,
setIsNewTerminalHovered,
onReorderTabs,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const layoutMetrics = useMemo(
() => ({
rowPaddingHorizontal: theme.spacing[2],
tabGap: theme.spacing[1],
maxTabWidth: 260,
iconOnlyTabWidth: 40,
tabBaseWidthWithClose: 84,
minLabelChars: 4,
charWidth: 7,
}),
[theme.spacing]
);
const { layout, onContainerLayout, onActionsLayout } = useWorkspaceTabLayout({
tabLabels: tabs.map((tab) => tab.label),
metrics: layoutMetrics,
});
return (
<View style={styles.tabsContainer} testID="workspace-tabs-row" onLayout={onContainerLayout}>
<ScrollView
horizontal
scrollEnabled={layout.shouldScroll}
testID="workspace-tabs-scroll"
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
<SortableInlineList
data={tabs}
keyExtractor={(tab) => tab.key}
useDragHandle
disabled={tabs.length < 2}
onDragEnd={onReorderTabs}
renderItem={({ item: tab, dragHandleProps, index }) => {
const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.agentId,
});
const isClosingTerminal =
tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId;
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,
pendingPermissionCount: tabAgent.pendingPermissions.length,
requiresAttention: tabAgent.requiresAttention,
attentionReason: tabAgent.attentionReason,
})
: null;
const tabAgentStatusColor =
tabAgentStatusBucket === null
? null
: getStatusDotColor({
theme,
bucket: tabAgentStatusBucket,
showDoneAsInactive: false,
});
const icon =
tab.kind === "agent" ? (
<View style={styles.tabAgentIconWrapper}>
{tab.provider === "claude" ? (
<ClaudeIcon size={14} color={iconColor} />
) : tab.provider === "codex" ? (
<CodexIcon size={14} color={iconColor} />
) : (
<Bot size={14} color={iconColor} />
)}
{tabAgentStatusColor ? (
<View
style={[
styles.tabStatusDot,
{
backgroundColor: tabAgentStatusColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
</View>
) : tab.kind === "draft" ? (
<Pencil size={14} color={iconColor} />
) : tab.kind === "file" ? (
<FileText size={14} color={iconColor} />
) : (
<Terminal size={14} color={iconColor} />
);
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
return (
<ContextMenu key={tab.key}>
<ContextMenuTrigger
testID={`workspace-tab-${tab.key}`}
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,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
}}
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tab.label}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={(node: unknown) => {
dragHandleProps?.setActivatorNodeRef?.(node);
}}
style={styles.tabHandle}
>
<View style={styles.tabIcon}>{icon}</View>
{layout.showLabels ? (
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
numberOfLines={1}
>
{tab.label}
</Text>
) : null}
</View>
{shouldShowCloseButton ? (
<Pressable
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: tab.kind === "terminal"
? `workspace-terminal-close-${tab.terminalId}`
: tab.kind === "draft"
? `workspace-draft-close-${tab.draftId}`
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
}
disabled={isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{isClosingTab ? (
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
) : (
<X size={12} color={theme.colors.foregroundMuted} />
)}
</Pressable>
) : null}
</ContextMenuTrigger>
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{tab.kind === "agent" ? (
<>
<ContextMenuItem
testID={`${contextMenuTestId}-copy-resume-command`}
onSelect={() => {
void onCopyResumeCommand(tab.agentId);
}}
>
Copy resume command
</ContextMenuItem>
<ContextMenuItem
testID={`${contextMenuTestId}-copy-agent-id`}
onSelect={() => {
void onCopyAgentId(tab.agentId);
}}
>
Copy agent id
</ContextMenuItem>
</>
) : null}
<ContextMenuSeparator />
<ContextMenuItem
testID={`${contextMenuTestId}-close-right`}
disabled={tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1}
onSelect={() => {
void onCloseTabsToRight(tab.tabId);
}}
>
Close to the right
</ContextMenuItem>
<ContextMenuItem
testID={`${contextMenuTestId}-close`}
onSelect={() => {
void onCloseTab(tab.tabId);
}}
>
Close
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
</ScrollView>
<View style={styles.tabsActions} onLayout={onActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => onSelectNewTabOption(newTabAgentOptionId)}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => onSelectNewTabOption(newTabTerminalOptionId)}
onHoverIn={() => setIsNewTerminalHovered(true)}
onHoverOut={() => setIsNewTerminalHovered(false)}
disabled={createTerminalPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
createTerminalPending && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
{createTerminalPending ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.terminalPlusIcon}>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
tabsContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
flexDirection: "row",
alignItems: "center",
},
tabsScroll: {
flex: 1,
minWidth: 0,
},
tabsContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
tabsActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
tab: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
tabIconOnly: {
justifyContent: "center",
},
tabHandle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
tabIcon: {
flexShrink: 0,
},
tabAgentIconWrapper: {
position: "relative",
width: 14,
height: 14,
alignItems: "center",
justifyContent: "center",
},
tabStatusDot: {
position: "absolute",
right: -2,
bottom: -2,
width: 7,
height: 7,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
tabActive: {
backgroundColor: theme.colors.surface2,
},
tabHovered: {
backgroundColor: theme.colors.surface2,
},
tabLabel: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tabLabelWithCloseButton: {
paddingRight: 0,
},
tabLabelActive: {
color: theme.colors.foreground,
},
tabCloseButton: {
width: 18,
height: 18,
marginLeft: 0,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
tabCloseButtonShown: {
opacity: 1,
},
tabCloseButtonActive: {
backgroundColor: theme.colors.surface3,
},
newTabActionButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
newTabActionButtonDisabled: {
opacity: 0.5,
},
terminalPlusIcon: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
terminalPlusBadge: {
position: "absolute",
right: -7,
bottom: -7,
width: 12,
height: 12,
borderRadius: 6,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: theme.colors.surface0,
backgroundColor: theme.colors.surface0,
},
terminalPlusBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
newTabTooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));
@@ -273,7 +273,6 @@ export function WorkspaceDraftAgentTab({
return next;
});
void client.refreshAgent(agentId).catch(() => undefined);
onCreated(result);
return;
} catch (error) {
@@ -4,7 +4,6 @@ import {
BackHandler,
Platform,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
@@ -23,7 +22,6 @@ import {
Pencil,
SquareTerminal,
Terminal,
X,
} from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -40,17 +38,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FilePane } from "@/components/file-pane";
import { TerminalPane } from "@/components/terminal-pane";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -69,7 +63,6 @@ import {
buildHostWorkspaceFileRoute,
buildHostWorkspaceTabRoute,
buildHostWorkspaceTerminalRoute,
encodeFilePathForPathSegment,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
@@ -87,6 +80,14 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
deriveProjectDisplayName,
deriveProjectKey,
deriveProjectName,
deriveRemoteProjectKey,
} from "@/utils/agent-grouping";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const DROPDOWN_WIDTH = 220;
@@ -106,41 +107,6 @@ type WorkspaceScreenProps = {
routeTabId?: string | null;
};
type WorkspaceTabDescriptor =
| {
key: string;
tabId: string;
kind: "draft";
draftId: string;
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "agent";
agentId: string;
provider: Agent["provider"];
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "terminal";
terminalId: string;
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "file";
filePath: string;
label: string;
subtitle: string;
};
function applyWorkspaceTabOrder(input: {
tabs: WorkspaceTabDescriptor[];
keys: string[];
@@ -199,20 +165,15 @@ function deriveWorkspaceName(workspaceId: string): string {
return last ?? workspaceId;
}
function deriveWorkspaceHeaderTitle(input: {
workspaceName: string;
function deriveWorkspaceProjectDisplayName(input: {
workspaceId: string;
checkout: CheckoutStatusPayload | null;
}): string {
if (!input.checkout?.isGit) {
return input.workspaceName;
}
const branch = trimNonEmpty(input.checkout.currentBranch ?? null);
if (!branch || branch === "HEAD") {
return input.workspaceName;
}
return branch;
const projectKey =
deriveRemoteProjectKey(input.checkout?.remoteUrl ?? null) ??
deriveProjectKey(input.workspaceId);
const projectName = deriveProjectName(projectKey);
return deriveProjectDisplayName({ projectKey, projectName });
}
function formatProviderLabel(provider: Agent["provider"]): string {
@@ -566,13 +527,13 @@ function WorkspaceScreenContent({
() => deriveWorkspaceName(normalizedWorkspaceId),
[normalizedWorkspaceId]
);
const headerTitle = useMemo(
const headerProjectName = useMemo(
() =>
deriveWorkspaceHeaderTitle({
workspaceName,
deriveWorkspaceProjectDisplayName({
workspaceId: normalizedWorkspaceId,
checkout: checkoutQuery.data ?? null,
}),
[checkoutQuery.data, workspaceName]
[checkoutQuery.data, normalizedWorkspaceId]
);
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
@@ -1507,9 +1468,14 @@ function WorkspaceScreenContent({
left={
<>
<SidebarMenuToggle />
<Text style={styles.headerTitle} numberOfLines={1}>
{headerTitle}
</Text>
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
{workspaceName}
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{headerProjectName}
</Text>
</View>
</>
}
right={
@@ -1745,287 +1711,30 @@ function WorkspaceScreenContent({
/>
</View>
) : (
<View style={styles.tabsContainer} testID="workspace-tabs-row">
<ScrollView
horizontal
testID="workspace-tabs-scroll"
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
<SortableInlineList
data={tabs}
keyExtractor={(tab) => tab.key}
useDragHandle
disabled={tabs.length < 2}
onDragEnd={handleReorderTabs}
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;
const isClosingAgent =
tab.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.agentId,
});
const isClosingTerminal =
tab.kind === "terminal" &&
killTerminalMutation.isPending &&
killTerminalMutation.variables === tab.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = true;
const iconColor = isActive
? theme.colors.foreground
: theme.colors.foregroundMuted;
const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({
status: tabAgent.status,
pendingPermissionCount: tabAgent.pendingPermissions.length,
requiresAttention: tabAgent.requiresAttention,
attentionReason: tabAgent.attentionReason,
})
: null;
const tabAgentStatusColor =
tabAgentStatusBucket === null
? null
: getStatusDotColor({
theme,
bucket: tabAgentStatusBucket,
showDoneAsInactive: false,
});
const icon =
tab.kind === "agent" ? (
<View style={styles.tabAgentIconWrapper}>
{tab.provider === "claude" ? (
<ClaudeIcon size={14} color={iconColor} />
) : tab.provider === "codex" ? (
<CodexIcon size={14} color={iconColor} />
) : (
<Bot size={14} color={iconColor} />
)}
{tabAgentStatusColor ? (
<View
style={[
styles.tabStatusDot,
{
backgroundColor: tabAgentStatusColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
</View>
) : tab.kind === "draft" ? (
<Pencil size={14} color={iconColor} />
) : tab.kind === "file" ? (
<FileText size={14} color={iconColor} />
) : (
<Terminal size={14} color={iconColor} />
);
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
return (
<ContextMenu key={tab.key}>
<ContextMenuTrigger
testID={`workspace-tab-${tab.key}`}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
}}
onPress={() => {
navigateToTabId(tab.tabId);
}}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={(node: unknown) => {
dragHandleProps?.setActivatorNodeRef?.(node);
}}
style={styles.tabHandle}
>
<View style={styles.tabIcon}>{icon}</View>
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
numberOfLines={1}
>
{tab.label}
</Text>
</View>
<Pressable
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: tab.kind === "terminal"
? `workspace-terminal-close-${tab.terminalId}`
: tab.kind === "draft"
? `workspace-draft-close-${tab.draftId}`
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
setHoveredCloseTabKey((current) =>
current === tab.key ? null : current
);
}}
onPress={(event) => {
event.stopPropagation?.();
void handleCloseTabById(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
shouldShowCloseButton
? styles.tabCloseButtonShown
: styles.tabCloseButtonHidden,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{isClosingTab ? (
<ActivityIndicator
size={12}
color={theme.colors.foregroundMuted}
/>
) : (
<X size={12} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</ContextMenuTrigger>
<ContextMenuContent
align="start"
width={DROPDOWN_WIDTH}
testID={contextMenuTestId}
>
{tab.kind === "agent" ? (
<>
<ContextMenuItem
testID={`${contextMenuTestId}-copy-resume-command`}
onSelect={() => {
void handleCopyResumeCommand(tab.agentId);
}}
>
Copy resume command
</ContextMenuItem>
<ContextMenuItem
testID={`${contextMenuTestId}-copy-agent-id`}
onSelect={() => {
void handleCopyAgentId(tab.agentId);
}}
>
Copy agent id
</ContextMenuItem>
</>
) : null}
<ContextMenuSeparator />
<ContextMenuItem
testID={`${contextMenuTestId}-close-right`}
disabled={
tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1
}
onSelect={() => {
void handleCloseTabsToRight(tab.tabId);
}}
>
Close to the right
</ContextMenuItem>
<ContextMenuItem
testID={`${contextMenuTestId}-close`}
onSelect={() => {
void handleCloseTabById(tab.tabId);
}}
>
Close
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
</ScrollView>
<View style={styles.tabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
onHoverIn={() => setIsNewTerminalHovered(true)}
onHoverOut={() => setIsNewTerminalHovered(false)}
disabled={createTerminalMutation.isPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
{createTerminalMutation.isPending ? (
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
) : (
<View style={styles.terminalPlusIcon}>
<SquareTerminal
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
</View>
</View>
<WorkspaceDesktopTabsRow
tabs={tabs}
activeTabKey={activeTabKey}
agentsById={agentsById}
normalizedServerId={normalizedServerId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalMutation.isPending}
killTerminalId={killTerminalMutation.variables ?? null}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCloseTabsToRight={handleCloseTabsToRight}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
newTabTerminalOptionId={NEW_TAB_TERMINAL_OPTION_ID}
createTerminalPending={createTerminalMutation.isPending}
isNewTerminalHovered={isNewTerminalHovered}
setIsNewTerminalHovered={setIsNewTerminalHovered}
onReorderTabs={handleReorderTabs}
/>
)}
<View style={styles.centerContent}>
@@ -2067,13 +1776,25 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
headerTitle: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
flexShrink: 1,
},
headerTitleContainer: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
flexShrink: 1,
},
headerRight: {
flexDirection: "row",
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { computeWorkspaceTabLayout } from "@/screens/workspace/workspace-tab-layout";
const metrics = {
rowPaddingHorizontal: 8,
tabGap: 4,
maxTabWidth: 260,
iconOnlyTabWidth: 40,
tabBaseWidthWithClose: 84,
minLabelChars: 4,
charWidth: 7,
};
describe("computeWorkspaceTabLayout", () => {
it("keeps full width tabs when space is available", () => {
const result = computeWorkspaceTabLayout({
containerWidth: 1200,
actionsWidth: 100,
tabLabelLengths: [8, 10, 7],
metrics,
});
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", () => {
const result = computeWorkspaceTabLayout({
containerWidth: 500,
actionsWidth: 110,
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]);
});
it("falls back to icon mode when compact labels still cannot fit", () => {
const result = computeWorkspaceTabLayout({
containerWidth: 300,
actionsWidth: 120,
tabLabelLengths: [14, 14, 14, 14],
metrics,
});
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", () => {
const result = computeWorkspaceTabLayout({
containerWidth: 380,
actionsWidth: 120,
tabLabelLengths: [20, 20, 20, 20],
metrics,
});
expect(result.mode).toBe("icon");
expect(result.shouldScroll).toBe(false);
});
});
@@ -0,0 +1,138 @@
export type WorkspaceTabLayoutMode = "full" | "compact" | "icon";
export type WorkspaceTabLayoutInput = {
containerWidth: number;
actionsWidth: number;
tabLabelLengths: number[];
metrics: {
rowPaddingHorizontal: number;
tabGap: number;
maxTabWidth: number;
iconOnlyTabWidth: number;
tabBaseWidthWithClose: number;
minLabelChars: number;
charWidth: number;
};
};
export type WorkspaceTabLayoutResult = {
mode: WorkspaceTabLayoutMode;
tabWidths: number[];
shouldScroll: boolean;
showLabels: boolean;
showCloseButtons: boolean;
};
function clamp(value: number, min: number, max: number): number {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
function sum(values: number[]): number {
let total = 0;
for (const value of values) {
total += value;
}
return total;
}
function computeTotalRowWidth(input: {
tabWidths: number[];
tabGap: number;
rowPaddingHorizontal: number;
}): number {
if (input.tabWidths.length === 0) {
return 0;
}
return (
input.rowPaddingHorizontal * 2 +
sum(input.tabWidths) +
Math.max(input.tabWidths.length - 1, 0) * input.tabGap
);
}
export function computeWorkspaceTabLayout(
input: WorkspaceTabLayoutInput
): WorkspaceTabLayoutResult {
const tabCount = input.tabLabelLengths.length;
if (tabCount === 0) {
return {
mode: "full",
tabWidths: [],
shouldScroll: false,
showLabels: true,
showCloseButtons: true,
};
}
const availableWidth = Math.max(0, input.containerWidth - input.actionsWidth);
const minLabelWidth =
input.metrics.tabBaseWidthWithClose + input.metrics.charWidth * input.metrics.minLabelChars;
const preferredTabWidths = 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 preferredTotal = computeTotalRowWidth({
tabWidths: preferredTabWidths,
tabGap: input.metrics.tabGap,
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
});
if (preferredTotal <= availableWidth) {
return {
mode: "full",
tabWidths: preferredTabWidths,
shouldScroll: false,
showLabels: true,
showCloseButtons: true,
};
}
const compactMinTabWidths = preferredTabWidths.map(() => minLabelWidth);
const compactTotal = computeTotalRowWidth({
tabWidths: compactMinTabWidths,
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,
};
}
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,
};
}
@@ -0,0 +1,36 @@
import type { Agent } from "@/stores/session-store";
export type WorkspaceTabDescriptor =
| {
key: string;
tabId: string;
kind: "draft";
draftId: string;
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "agent";
agentId: string;
provider: Agent["provider"];
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "terminal";
terminalId: string;
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "file";
filePath: string;
label: string;
subtitle: string;
};