feat: workspace-level sidebar shortcuts with shortcut badges and desktop tabs extraction
This commit is contained in:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user