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>(new Map()); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); const outputPumpRef = useRef(null); const outputDeliveryQueueRef = useRef(null); - const isSelectedTerminalControlled = - typeof selectedTerminalIdProp !== "undefined"; - - const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] = - useState(null); - const selectedTerminalId = isSelectedTerminalControlled - ? selectedTerminalIdProp ?? null - : uncontrolledSelectedTerminalId; const [selectedOutputChunk, setSelectedOutputChunk] = useState({ sequence: 0, text: "", @@ -203,25 +194,10 @@ export function TerminalPane({ const keyboardRefitTimeoutsRef = useRef>>([]); 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); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index c60c33c78..4de792dba 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -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; }; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index e161a17db..f331f699c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -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", ]); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 6ef0906f7..ecc1042f8 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -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, }, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 012739dd2..8ca8b40e7 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -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 } diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts new file mode 100644 index 000000000..f76047efd --- /dev/null +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -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, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx new file mode 100644 index 000000000..49c1d3ada --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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; + normalizedServerId: string; + hoveredCloseTabKey: string | null; + setHoveredTabKey: Dispatch>; + setHoveredCloseTabKey: Dispatch>; + isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean; + killTerminalPending: boolean; + killTerminalId: string | null; + onNavigateTab: (tabId: string) => void; + onCloseTab: (tabId: string) => Promise | void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCloseTabsToRight: (tabId: string) => Promise | void; + onSelectNewTabOption: (optionId: NewTabOptionId) => void; + newTabAgentOptionId: NewTabOptionId; + newTabTerminalOptionId: NewTabOptionId; + createTerminalPending: boolean; + isNewTerminalHovered: boolean; + setIsNewTerminalHovered: Dispatch>; + 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 ( + + + 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" ? ( + + {tab.provider === "claude" ? ( + + ) : tab.provider === "codex" ? ( + + ) : ( + + )} + {tabAgentStatusColor ? ( + + ) : null} + + ) : tab.kind === "draft" ? ( + + ) : tab.kind === "file" ? ( + + ) : ( + + ); + + const contextMenuTestId = `workspace-tab-context-${tab.key}`; + + return ( + + [ + 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} + > + { + dragHandleProps?.setActivatorNodeRef?.(node); + }} + style={styles.tabHandle} + > + {icon} + {layout.showLabels ? ( + + {tab.label} + + ) : null} + + + {shouldShowCloseButton ? ( + { + 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 ? ( + + ) : ( + + )} + + ) : null} + + + + {tab.kind === "agent" ? ( + <> + { + void onCopyResumeCommand(tab.agentId); + }} + > + Copy resume command + + { + void onCopyAgentId(tab.agentId); + }} + > + Copy agent id + + + ) : null} + + + + t.key === tab.key) === tabs.length - 1} + onSelect={() => { + void onCloseTabsToRight(tab.tabId); + }} + > + Close to the right + + { + void onCloseTab(tab.tabId); + }} + > + Close + + + + ); + }} + /> + + + + onSelectNewTabOption(newTabAgentOptionId)} + accessibilityRole="button" + accessibilityLabel="New agent tab" + style={({ hovered, pressed }) => [ + styles.newTabActionButton, + (hovered || pressed) && styles.newTabActionButtonHovered, + ]} + > + + + + New agent tab + + + + 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 ? ( + + ) : ( + + + + + + + )} + + + New terminal tab + + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 8723ed478..b0d9474d7 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -273,7 +273,6 @@ export function WorkspaceDraftAgentTab({ return next; }); - void client.refreshAgent(agentId).catch(() => undefined); onCreated(result); return; } catch (error) { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3d120abd5..8962f2908 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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={ <> - - {headerTitle} - + + + {workspaceName} + + + {headerProjectName} + + } right={ @@ -1745,287 +1711,30 @@ function WorkspaceScreenContent({ /> ) : ( - - - 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 } } -