diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index ee05a72ff..e725a7888 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -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(
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 5657dc213..d42045cb1 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -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])
diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx
index 3579b788a..688777e0c 100644
--- a/packages/app/src/components/sidebar-agent-list.tsx
+++ b/packages/app/src/components/sidebar-agent-list.tsx
@@ -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)}
- {createdAtLabel ? (
-
- {createdAtLabel}
-
- ) : null}
+
+ {createdAtLabel ? (
+
+ {createdAtLabel}
+
+ ) : null}
+ {showShortcutBadge && shortcutNumber !== null ? (
+
+ {shortcutNumber}
+
+ ) : null}
+
)
@@ -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 (
-
+
)
}
@@ -507,6 +499,13 @@ export function SidebarAgentList({
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(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) => {
+ ({ item, drag }: DraggableRenderItemInfo) => {
if (item.kind === 'project') {
return (
{
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 => row.kind === 'project'
+ (row): row is Extract =>
+ row.kind === 'project'
)
.map((row) => row.project.projectKey)
@@ -776,7 +773,7 @@ export function SidebarAgentList({
return (
({
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,
+ },
}))
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index b10ed4c84..f6200542d 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -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
) : (
-
-
- 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" ? (
-
- {tab.provider === "claude" ? (
-
- ) : tab.provider === "codex" ? (
-
- ) : (
-
- )}
- {tabAgentStatusColor ? (
-
- ) : null}
-
- ) : tab.kind === "draft" ? (
-
- ) : tab.kind === "file" ? (
-
- ) : (
-
- );
-
- const contextMenuTestId = `workspace-tab-context-${tab.key}`;
-
- return (
-
- [
- 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);
- }}
- >
- {
- dragHandleProps?.setActivatorNodeRef?.(node);
- }}
- style={styles.tabHandle}
- >
- {icon}
-
- {tab.label}
-
-
-
- {
- 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 ? (
-
- ) : (
-
- )}
-
-
-
-
- {tab.kind === "agent" ? (
- <>
- {
- void handleCopyResumeCommand(tab.agentId);
- }}
- >
- Copy resume command
-
- {
- void handleCopyAgentId(tab.agentId);
- }}
- >
- Copy agent id
-
- >
- ) : null}
-
-
-
- t.key === tab.key) === tabs.length - 1
- }
- onSelect={() => {
- void handleCloseTabsToRight(tab.tabId);
- }}
- >
- Close to the right
-
- {
- void handleCloseTabById(tab.tabId);
- }}
- >
- Close
-
-
-
- );
- }}
- />
-
-
-
- handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
- accessibilityRole="button"
- accessibilityLabel="New agent tab"
- style={({ hovered, pressed }) => [
- styles.newTabActionButton,
- (hovered || pressed) && styles.newTabActionButtonHovered,
- ]}
- >
-
-
-
- New agent tab
-
-
-
- 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 ? (
-
- ) : (
-
-
-
-
-
-
- )}
-
-
- New terminal tab
-
-
-
-
+
)}
@@ -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",
diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts
new file mode 100644
index 000000000..534808cca
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts
@@ -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);
+ });
+});
diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts
new file mode 100644
index 000000000..7e9dd3385
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts
@@ -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,
+ };
+}
diff --git a/packages/app/src/screens/workspace/workspace-tabs-types.ts b/packages/app/src/screens/workspace/workspace-tabs-types.ts
new file mode 100644
index 000000000..1ccc06a65
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-tabs-types.ts
@@ -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;
+ };
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts
index 5ad0d46ff..79341e7d9 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.ts
@@ -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(
shortcutsDialogOpen: false,
altDown: false,
cmdOrCtrlDown: false,
- sidebarShortcutAgentKeys: [],
+ sidebarShortcutWorkspaceTargets: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
@@ -45,7 +46,8 @@ export const useKeyboardShortcutsStore = create(
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 }) => {
diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts
index fdb96ad4d..24b73d380 100644
--- a/packages/app/src/utils/agent-grouping.test.ts
+++ b/packages/app/src/utils/agent-grouping.test.ts
@@ -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 {
@@ -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 = [
diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts
index 067a20193..f651d48fc 100644
--- a/packages/app/src/utils/agent-grouping.ts
+++ b/packages/app/src/utils/agent-grouping.ts
@@ -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.
*/
diff --git a/packages/app/src/utils/explorer-paths.test.ts b/packages/app/src/utils/explorer-paths.test.ts
new file mode 100644
index 000000000..28c4adf8e
--- /dev/null
+++ b/packages/app/src/utils/explorer-paths.test.ts
@@ -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");
+ });
+});
diff --git a/packages/app/src/utils/explorer-paths.ts b/packages/app/src/utils/explorer-paths.ts
new file mode 100644
index 000000000..d9e474acc
--- /dev/null
+++ b/packages/app/src/utils/explorer-paths.ts
@@ -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)}`;
+}
diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts
index a7e931bf6..e1b6a77cf 100644
--- a/packages/app/src/utils/sidebar-shortcuts.test.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.test.ts
@@ -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(["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(),
+ 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" });
});
});
-
diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts
index 0f8250220..9f4d5d881 100644
--- a/packages/app/src/utils/sidebar-shortcuts.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.ts
@@ -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,
- 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
+}
+
+export function buildSidebarWorkspaceViewModel(input: {
+ projects: SidebarProjectEntry[]
+ collapsedProjectKeys: ReadonlySet
+ 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()
+
+ 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 }
}
-