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
@@ -58,6 +58,7 @@ import {
type SortOption,
} from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
@@ -258,9 +259,17 @@ export function FileExplorerPane({
[handleOpenFile, handleToggleDirectory]
);
const handleCopyPath = useCallback(async (path: string) => {
await Clipboard.setStringAsync(path);
}, []);
const handleCopyPath = useCallback(
async (path: string) => {
await Clipboard.setStringAsync(
buildAbsoluteExplorerPath({
workspaceRoot: normalizedWorkspaceRoot,
entryPath: path,
})
);
},
[normalizedWorkspaceRoot]
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
@@ -18,7 +18,6 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { Combobox } from '@/components/ui/combobox'
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
@@ -146,13 +145,6 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
}
}, [isRevalidating, isManualRefresh])
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.setSidebarShortcutAgentKeys
)
useEffect(() => {
setSidebarShortcutAgentKeys([])
}, [setSidebarShortcutAgentKeys])
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
@@ -15,6 +15,7 @@ import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture
import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { getIsTauri } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import {
buildHostWorkspaceRoute,
@@ -28,6 +29,7 @@ import {
} from '@/hooks/use-sidebar-agents-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { formatTimeAgo } from '@/utils/time'
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
import { confirmDialog } from '@/utils/confirm-dialog'
@@ -38,20 +40,11 @@ import {
ContextMenuTrigger,
useContextMenu,
} from '@/components/ui/context-menu'
type SidebarTreeRow =
| {
kind: 'project'
rowKey: string
project: SidebarProjectEntry
displayName: string
}
| {
kind: 'workspace'
rowKey: string
projectKey: string
workspace: SidebarWorkspaceEntry
}
import { deriveProjectDisplayName } from '@/utils/agent-grouping'
import {
buildSidebarWorkspaceViewModel,
type SidebarWorkspaceTreeRow,
} from '@/utils/sidebar-shortcuts'
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -84,38 +77,12 @@ interface ProjectRowProps {
interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry
selected: boolean
shortcutNumber: number | null
showShortcutBadge: boolean
onPress: () => void
drag: () => void
}
function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string {
const githubPrefix = 'remote:github.com/'
if (input.projectKey.startsWith(githubPrefix)) {
return input.projectKey.slice(githubPrefix.length)
}
if (input.projectKey.startsWith('remote:')) {
const withoutPrefix = input.projectKey.slice('remote:'.length)
const slashIdx = withoutPrefix.indexOf('/')
if (slashIdx >= 0) {
const remotePath = withoutPrefix.slice(slashIdx + 1).trim()
if (remotePath.length > 0) {
return remotePath
}
}
return withoutPrefix
}
const trimmedProjectName = input.projectName.trim()
if (trimmedProjectName.length > 0) {
return trimmedProjectName
}
const normalized = input.projectKey.replace(/\\/g, '/').replace(/\/+$/, '')
const segments = normalized.split('/').filter(Boolean)
return segments[segments.length - 1] ?? input.projectKey
}
function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string {
const branch = workspace.branchName?.trim()
if (branch && branch.length > 0) {
@@ -225,7 +192,14 @@ function ProjectRow({
)
}
function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps) {
function WorkspaceRow({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
drag,
}: WorkspaceRowProps) {
const { theme } = useUnistyles()
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
@@ -412,11 +386,18 @@ function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps)
{resolveWorkspaceBranchLabel(workspace)}
</Text>
</View>
{createdAtLabel ? (
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{createdAtLabel}
</Text>
) : null}
<View style={styles.workspaceRowRight}>
{createdAtLabel ? (
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{createdAtLabel}
</Text>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</ContextMenuTrigger>
)
@@ -453,17 +434,28 @@ function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps)
function WorkspaceRowWithMenu({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
drag,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
shortcutNumber: number | null
showShortcutBadge: boolean
onPress: () => void
drag: () => void
}) {
return (
<ContextMenu>
<WorkspaceRow workspace={workspace} selected={selected} onPress={onPress} drag={drag} />
<WorkspaceRow
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
/>
</ContextMenu>
)
}
@@ -507,6 +499,13 @@ export function SidebarAgentList({
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
const isTauri = getIsTauri()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets
)
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
@@ -610,34 +609,29 @@ export function SidebarAgentList({
return byProject
}, [projectIconQueries, projectIconRequests, projects, serverId])
const rows = useMemo(() => {
const next: SidebarTreeRow[] = []
for (const project of projects) {
next.push({
kind: 'project',
rowKey: `project:${project.projectKey}`,
project,
displayName: deriveProjectDisplayName({
projectKey: project.projectKey,
projectName: project.projectName,
}),
})
const viewModel = useMemo(
() =>
buildSidebarWorkspaceViewModel({
projects,
collapsedProjectKeys,
getProjectDisplayName: (project) =>
deriveProjectDisplayName({
projectKey: project.projectKey,
projectName: project.projectName,
}),
}),
[canonicalResyncNonce, collapsedProjectKeys, projects]
)
if (collapsedProjectKeys.has(project.projectKey)) {
continue
}
useEffect(() => {
setSidebarShortcutWorkspaceTargets(viewModel.shortcutTargets)
}, [setSidebarShortcutWorkspaceTargets, viewModel.shortcutTargets])
for (const workspace of project.workspaces) {
next.push({
kind: 'workspace',
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
projectKey: project.projectKey,
workspace,
})
}
useEffect(() => {
return () => {
setSidebarShortcutWorkspaceTargets([])
}
return next
}, [canonicalResyncNonce, collapsedProjectKeys, projects])
}, [setSidebarShortcutWorkspaceTargets])
const toggleProjectCollapsed = useCallback((projectKey: string) => {
setCollapsedProjectKeys((prev) => {
@@ -652,7 +646,7 @@ export function SidebarAgentList({
}, [])
const renderRow = useCallback(
({ item, drag }: DraggableRenderItemInfo<SidebarTreeRow>) => {
({ item, drag }: DraggableRenderItemInfo<SidebarWorkspaceTreeRow>) => {
if (item.kind === 'project') {
return (
<ProjectRow
@@ -677,6 +671,8 @@ export function SidebarAgentList({
<WorkspaceRowWithMenu
workspace={item.workspace}
selected={isSelected}
shortcutNumber={item.shortcutNumber}
showShortcutBadge={showShortcutBadges}
onPress={() => {
if (!serverId) {
return
@@ -691,19 +687,19 @@ export function SidebarAgentList({
[
activeWorkspaceSelection,
collapsedProjectKeys,
isMobile,
onWorkspacePress,
projectIconByProjectKey,
serverId,
showShortcutBadges,
shouldReplaceWorkspaceNavigation,
toggleProjectCollapsed,
]
)
const keyExtractor = useCallback((entry: SidebarTreeRow) => entry.rowKey, [])
const keyExtractor = useCallback((entry: SidebarWorkspaceTreeRow) => entry.rowKey, [])
const handleDragEnd = useCallback(
(reorderedRows: SidebarTreeRow[]) => {
(reorderedRows: SidebarWorkspaceTreeRow[]) => {
if (!serverId) {
return
}
@@ -711,7 +707,8 @@ export function SidebarAgentList({
let didPersistOrderChange = false
const reorderedProjectKeys = reorderedRows
.filter(
(row): row is Extract<SidebarTreeRow, { kind: 'project' }> => row.kind === 'project'
(row): row is Extract<SidebarWorkspaceTreeRow, { kind: 'project' }> =>
row.kind === 'project'
)
.map((row) => row.project.projectKey)
@@ -776,7 +773,7 @@ export function SidebarAgentList({
return (
<View style={styles.container}>
<DraggableList
data={rows}
data={viewModel.rows}
style={styles.list}
contentContainerStyle={styles.listContent}
testID="sidebar-project-workspace-list-scroll"
@@ -879,6 +876,12 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceRowRight: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
flexShrink: 0,
},
workspaceRowHovered: {
backgroundColor: theme.colors.surface1,
},
@@ -913,4 +916,21 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
shortcutBadge: {
minWidth: 18,
height: 18,
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
alignItems: 'center',
justifyContent: 'center',
},
shortcutBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: '600',
lineHeight: 14,
},
}))
+7 -84
View File
@@ -48,7 +48,7 @@ import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
selectedTerminalId?: string | null;
selectedTerminalId: string | null;
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
hideHeader?: boolean;
manageTerminalDirectorySubscription?: boolean;
@@ -142,7 +142,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
export function TerminalPane({
serverId,
cwd,
selectedTerminalId: selectedTerminalIdProp,
selectedTerminalId,
onSelectedTerminalIdChange,
hideHeader = false,
manageTerminalDirectorySubscription = true,
@@ -165,19 +165,10 @@ export function TerminalPane({
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
const isSelectedTerminalControlled =
typeof selectedTerminalIdProp !== "undefined";
const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] =
useState<string | null>(null);
const selectedTerminalId = isSelectedTerminalControlled
? selectedTerminalIdProp ?? null
: uncontrolledSelectedTerminalId;
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
sequence: 0,
text: "",
@@ -203,25 +194,10 @@ export function TerminalPane({
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const updateSelectedTerminalId = useCallback(
(
next:
| string
| null
| ((current: string | null) => string | null)
) => {
const current = selectedTerminalIdRef.current;
const resolved =
typeof next === "function"
? (next as (value: string | null) => string | null)(current)
: next;
selectedTerminalIdRef.current = resolved;
if (isSelectedTerminalControlled) {
onSelectedTerminalIdChange?.(resolved);
return;
}
setUncontrolledSelectedTerminalId(resolved);
(next: string | null) => {
onSelectedTerminalIdChange?.(next);
},
[isSelectedTerminalControlled, onSelectedTerminalIdChange]
[onSelectedTerminalIdChange]
);
useEffect(() => {
@@ -469,9 +445,6 @@ export function TerminalPane({
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
if (!isSelectedTerminalControlled) {
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
}
updateSelectedTerminalId(createdTerminal.id);
requestTerminalFocus();
}
@@ -496,9 +469,7 @@ export function TerminalPane({
setHoveredTerminalId((current) => (current === terminalId ? null : current));
outputPumpRef.current?.clearTerminal({ terminalId });
if (selectedTerminalIdRef.current === terminalId) {
updateSelectedTerminalId((current) =>
current === terminalId ? null : current
);
updateSelectedTerminalId(null);
setModifiers({ ...EMPTY_MODIFIERS });
}
void queryClient.invalidateQueries({
@@ -512,56 +483,8 @@ export function TerminalPane({
});
useEffect(() => {
if (!isSelectedTerminalControlled) {
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
}
lastReportedSizeRef.current = null;
}, [isSelectedTerminalControlled, scopeKey, updateSelectedTerminalId]);
useEffect(() => {
if (isSelectedTerminalControlled) {
return;
}
if (selectedTerminalId) {
selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId);
}
}, [isSelectedTerminalControlled, scopeKey, selectedTerminalId]);
useEffect(() => {
if (terminals.length === 0) {
updateSelectedTerminalId(null);
return;
}
const has = (id: string | null | undefined) =>
Boolean(id && terminals.some((terminal) => terminal.id === id));
if (has(selectedTerminalId)) {
return;
}
const stored = isSelectedTerminalControlled
? null
: selectedTerminalByScopeRef.current.get(scopeKey);
if (has(stored)) {
updateSelectedTerminalId(stored!);
return;
}
const fallback = terminals[0]?.id ?? null;
if (fallback) {
if (!isSelectedTerminalControlled) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
}
updateSelectedTerminalId(fallback);
}
}, [
isSelectedTerminalControlled,
scopeKey,
terminals,
selectedTerminalId,
updateSelectedTerminalId,
]);
}, [scopeKey]);
useEffect(() => {
const terminalIds = terminals.map((terminal) => terminal.id);
@@ -4,7 +4,6 @@ import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
@@ -17,8 +16,9 @@ import {
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
buildHostWorkspaceRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import {
@@ -70,20 +70,16 @@ export function useKeyboardShortcuts({
const navigateToSidebarShortcut = (digit: number): boolean => {
const state = useKeyboardShortcutsStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
const target = state.sidebarShortcutWorkspaceTargets[digit - 1] ?? null;
if (!target) {
return false;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return false;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const shouldReplace =
Boolean(parseHostWorkspaceRouteFromPathname(pathname)) ||
Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
return true;
};
@@ -200,7 +200,7 @@ describe("keyboard-shortcut help sections", () => {
});
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
"alt",
"1-9",
]);
@@ -213,7 +213,7 @@ describe("keyboard-shortcut help sections", () => {
});
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
"mod",
"1-9",
]);
@@ -318,9 +318,9 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
},
when: (context) => !context.commandCenterOpen,
help: {
id: "quick-open-agent",
id: "quick-open-workspace",
section: "global",
label: "Open sidebar agent shortcut",
label: "Open sidebar workspace shortcut",
keys: ["alt", "1-9"],
when: (context) => !context.isTauri,
},
@@ -335,9 +335,9 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
},
when: (context) => context.isTauri && !context.commandCenterOpen,
help: {
id: "quick-open-agent",
id: "quick-open-workspace",
section: "global",
label: "Open sidebar agent shortcut",
label: "Open sidebar workspace shortcut",
keys: ["mod", "1-9"],
when: (context) => context.isTauri,
},
@@ -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;
};
@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
export type MessageInputActionRequest = {
id: number;
@@ -12,8 +13,8 @@ interface KeyboardShortcutsState {
shortcutsDialogOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
messageInputActionRequest: MessageInputActionRequest | null;
nextMessageInputActionRequestId: number;
@@ -21,7 +22,7 @@ interface KeyboardShortcutsState {
setShortcutsDialogOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
resetModifiers: () => void;
requestMessageInputAction: (input: {
@@ -37,7 +38,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
shortcutsDialogOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
sidebarShortcutWorkspaceTargets: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
@@ -45,7 +46,8 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
setSidebarShortcutWorkspaceTargets: (targets) =>
set({ sidebarShortcutWorkspaceTargets: targets }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
+34 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { deriveRemoteProjectKey, groupAgents } from "./agent-grouping";
import {
deriveProjectDisplayName,
deriveRemoteProjectKey,
groupAgents,
} from "./agent-grouping";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
@@ -35,6 +39,35 @@ describe("deriveRemoteProjectKey", () => {
});
});
describe("deriveProjectDisplayName", () => {
it("shows owner/repo for GitHub remote keys", () => {
expect(
deriveProjectDisplayName({
projectKey: "remote:github.com/getpaseo/paseo",
projectName: "paseo",
})
).toBe("getpaseo/paseo");
});
it("shows remote path for non-GitHub remote keys", () => {
expect(
deriveProjectDisplayName({
projectKey: "remote:gitlab.example.com/group/repo",
projectName: "repo",
})
).toBe("group/repo");
});
it("falls back to projectName for local keys", () => {
expect(
deriveProjectDisplayName({
projectKey: "/Users/me/dev/paseo",
projectName: "paseo",
})
).toBe("paseo");
});
});
describe("groupAgents", () => {
it("groups active agents by remote URL when available", () => {
const agents = [
+38
View File
@@ -156,6 +156,44 @@ export function deriveProjectName(projectKey: string): string {
return segments[segments.length - 1] || projectKey;
}
/**
* Formats a project name for display in the UI.
*
* - GitHub remotes show owner/repo
* - Other remotes show the remote path when possible
* - Local projects prefer the provided projectName, then fallback to cwd tail
*/
export function deriveProjectDisplayName(input: {
projectKey: string;
projectName: string;
}): string {
const githubPrefix = "remote:github.com/";
if (input.projectKey.startsWith(githubPrefix)) {
return input.projectKey.slice(githubPrefix.length);
}
if (input.projectKey.startsWith("remote:")) {
const withoutPrefix = input.projectKey.slice("remote:".length);
const slashIdx = withoutPrefix.indexOf("/");
if (slashIdx >= 0) {
const remotePath = withoutPrefix.slice(slashIdx + 1).trim();
if (remotePath.length > 0) {
return remotePath;
}
}
return withoutPrefix;
}
const trimmedProjectName = input.projectName.trim();
if (trimmedProjectName.length > 0) {
return trimmedProjectName;
}
const normalized = input.projectKey.replace(/\\/g, "/").replace(/\/+$/, "");
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] ?? input.projectKey;
}
/**
* Determines the date group label for an agent based on lastActivityAt.
*/
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { buildAbsoluteExplorerPath } from "./explorer-paths";
describe("buildAbsoluteExplorerPath", () => {
it("builds a POSIX absolute path from a relative explorer path", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
})
).toBe("/Users/moboudra/dev/paseo/packages/app/src/components/file-explorer-pane.tsx");
});
it("returns workspace root when entry path points to explorer root", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: ".",
})
).toBe("/Users/moboudra/dev/paseo");
});
it("trims trailing separators from workspace root before joining", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo/",
entryPath: "README.md",
})
).toBe("/Users/moboudra/dev/paseo/README.md");
});
it("builds a Windows absolute path with backslash separators", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "C:\\repo\\paseo",
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
})
).toBe("C:\\repo\\paseo\\packages\\app\\src\\components\\file-explorer-pane.tsx");
});
it("passes through an already-absolute entry path", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: "/tmp/another/location.txt",
})
).toBe("/tmp/another/location.txt");
});
});
+40
View File
@@ -0,0 +1,40 @@
interface BuildAbsoluteExplorerPathInput {
workspaceRoot: string;
entryPath: string;
}
function isAbsolutePath(pathValue: string): boolean {
return (
pathValue.startsWith("/") ||
pathValue.startsWith("\\\\") ||
/^[A-Za-z]:[\\/]/.test(pathValue)
);
}
export function buildAbsoluteExplorerPath({
workspaceRoot,
entryPath,
}: BuildAbsoluteExplorerPathInput): string {
const normalizedWorkspaceRoot = workspaceRoot.trim().replace(/[\\/]+$/, "");
const normalizedEntryPath = entryPath.trim();
if (!normalizedWorkspaceRoot) {
return normalizedEntryPath;
}
if (!normalizedEntryPath || normalizedEntryPath === ".") {
return normalizedWorkspaceRoot;
}
if (isAbsolutePath(normalizedEntryPath)) {
return normalizedEntryPath;
}
const separator = normalizedWorkspaceRoot.includes("\\") ? "\\" : "/";
const segments = normalizedEntryPath.split(/[\\/]+/).filter(Boolean);
if (segments.length === 0) {
return normalizedWorkspaceRoot;
}
return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`;
}
@@ -1,56 +1,69 @@
import { describe, expect, it } from "vitest";
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-agents-list";
import { deriveSidebarShortcutAgentKeys, parseSidebarAgentKey } from "./sidebar-shortcuts";
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
describe("parseSidebarAgentKey", () => {
it("parses serverId and agentId", () => {
expect(parseSidebarAgentKey("server:agent")).toEqual({ serverId: "server", agentId: "agent" });
});
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
return {
workspaceKey: `${serverId}:${cwd}`,
serverId,
cwd,
branchName: null,
createdAt: null,
isMainCheckout: false,
isPaseoOwnedWorktree: false,
statusBucket: "done",
};
}
it("returns null for invalid keys", () => {
expect(parseSidebarAgentKey("")).toBeNull();
expect(parseSidebarAgentKey("no-separator")).toBeNull();
expect(parseSidebarAgentKey(":agent")).toBeNull();
expect(parseSidebarAgentKey("server:")).toBeNull();
});
});
function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry {
return {
projectKey,
projectName: projectKey,
iconWorkingDir: workspaces[0]?.cwd ?? "",
statusBucket: "done",
activeCount: 0,
totalCount: workspaces.length,
latestCreatedAt: null,
workspaces,
};
}
describe("deriveSidebarShortcutAgentKeys", () => {
it("skips collapsed projects and preserves visual order", () => {
const sections = [
{
projectKey: "p1",
agents: [
{ serverId: "s", id: "a1" },
{ serverId: "s", id: "a2" },
],
},
{
projectKey: "p2",
agents: [
{ serverId: "s", id: "b1" },
{ serverId: "s", id: "b2" },
],
},
describe("buildSidebarWorkspaceViewModel", () => {
it("builds visible rows and shortcut targets in visual order", () => {
const projects = [
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
project("p2", [workspace("s1", "/repo2/main")]),
];
expect(deriveSidebarShortcutAgentKeys(sections, new Set(["p2"]), 9)).toEqual([
"s:a1",
"s:a2",
const model = buildSidebarWorkspaceViewModel({
projects,
collapsedProjectKeys: new Set<string>(["p2"]),
getProjectDisplayName: (entry) => entry.projectName,
});
expect(model.rows.map((row) => row.kind)).toEqual(["project", "workspace", "workspace", "project"]);
expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" },
]);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/main")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/feat-a")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo2/main")).toBeUndefined();
});
it("limits to 9", () => {
const sections = [
{
projectKey: "p1",
agents: Array.from({ length: 20 }, (_, i) => ({ serverId: "s", id: `a${i + 1}` })),
},
];
it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
const projects = [project("p", workspaces)];
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)).toHaveLength(9);
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[0]).toBe("s:a1");
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[8]).toBe("s:a9");
const model = buildSidebarWorkspaceViewModel({
projects,
collapsedProjectKeys: new Set<string>(),
getProjectDisplayName: (entry) => entry.projectName,
});
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
});
});
+69 -38
View File
@@ -1,46 +1,77 @@
export interface SidebarShortcutSection {
projectKey: string;
agents: Array<{ serverId: string; id: string }>;
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-agents-list'
export interface SidebarShortcutWorkspaceTarget {
serverId: string
workspaceId: string
}
export function parseSidebarAgentKey(
key: string
): { serverId: string; agentId: string } | null {
const sep = key.indexOf(":");
if (sep === -1) {
return null;
}
const serverId = key.slice(0, sep);
const agentId = key.slice(sep + 1);
if (!serverId || !agentId) {
return null;
}
return { serverId, agentId };
}
export function deriveSidebarShortcutAgentKeys(
sections: SidebarShortcutSection[],
collapsedProjectKeys: ReadonlySet<string>,
limit = 9
): string[] {
const keys: string[] = [];
const max = Math.max(0, Math.floor(limit));
if (max === 0) {
return keys;
}
for (const section of sections) {
if (collapsedProjectKeys.has(section.projectKey)) {
continue;
export type SidebarWorkspaceTreeRow =
| {
kind: 'project'
rowKey: string
project: SidebarProjectEntry
displayName: string
}
for (const agent of section.agents) {
keys.push(`${agent.serverId}:${agent.id}`);
if (keys.length >= max) {
return keys;
| {
kind: 'workspace'
rowKey: string
projectKey: string
workspace: SidebarWorkspaceEntry
shortcutNumber: number | null
}
export interface SidebarWorkspaceViewModel {
rows: SidebarWorkspaceTreeRow[]
shortcutTargets: SidebarShortcutWorkspaceTarget[]
shortcutIndexByWorkspaceKey: Map<string, number>
}
export function buildSidebarWorkspaceViewModel(input: {
projects: SidebarProjectEntry[]
collapsedProjectKeys: ReadonlySet<string>
getProjectDisplayName: (project: SidebarProjectEntry) => string
shortcutLimit?: number
}): SidebarWorkspaceViewModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
const rows: SidebarWorkspaceTreeRow[] = []
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
const shortcutIndexByWorkspaceKey = new Map<string, number>()
for (const project of input.projects) {
rows.push({
kind: 'project',
rowKey: `project:${project.projectKey}`,
project,
displayName: input.getProjectDisplayName(project),
})
if (input.collapsedProjectKeys.has(project.projectKey)) {
continue
}
for (const workspace of project.workspaces) {
const shortcutNumber =
shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null
if (shortcutNumber !== null) {
shortcutTargets.push({
serverId: workspace.serverId,
workspaceId: workspace.cwd,
})
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
}
rows.push({
kind: 'workspace',
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
projectKey: project.projectKey,
workspace,
shortcutNumber,
})
}
}
return keys;
return { rows, shortcutTargets, shortcutIndexByWorkspaceKey }
}