From c7cbf89f92812cabf9f57c474b97c6ddd6ce7b00 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Mar 2026 20:42:55 +0700 Subject: [PATCH] Polish workspace header and unify workspace tab creation --- .../screens/workspace/workspace-screen.tsx | 363 +++++++++++------- 1 file changed, 215 insertions(+), 148 deletions(-) diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3c8cbc131..cbec29838 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - ActivityIndicator, BackHandler, Platform, Pressable, @@ -10,13 +9,14 @@ import { } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; -import { ChevronDown, Plus, Terminal } from "lucide-react-native"; +import { Bot, ChevronDown, Plus, Terminal } from "lucide-react-native"; import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { ScreenHeader } from "@/components/headers/screen-header"; import { Combobox } from "@/components/ui/combobox"; -import { AgentStatusDot } from "@/components/agent-status-dot"; +import { ClaudeIcon } from "@/components/icons/claude-icon"; +import { CodexIcon } from "@/components/icons/codex-icon"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { TerminalPane } from "@/components/terminal-pane"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; @@ -38,13 +38,15 @@ import { checkoutStatusQueryKey, type CheckoutStatusPayload, } from "@/hooks/use-checkout-status-query"; -import { deriveBranchLabel } from "@/utils/agent-display-info"; import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; const TERMINALS_QUERY_STALE_TIME = 5_000; -const CREATE_TERMINAL_SWITCHER_ID = "__create_terminal__"; +const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; +const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__"; + +type TabAvailability = "available" | "invalid" | "unknown"; type RouteTabTarget = WorkspaceTabTarget | null; @@ -59,6 +61,7 @@ type WorkspaceTabDescriptor = key: string; kind: "agent"; agentId: string; + provider: Agent["provider"]; label: string; subtitle: string; } @@ -93,6 +96,35 @@ function deriveWorkspaceName(workspaceId: string): string { return last ?? workspaceId; } +function deriveWorkspaceHeaderTitle(input: { + workspaceName: 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; +} + +function formatProviderLabel(provider: Agent["provider"]): string { + if (provider === "claude") { + return "Claude"; + } + if (provider === "codex") { + return "Codex"; + } + if (!provider) { + return "Agent"; + } + return provider.charAt(0).toUpperCase() + provider.slice(1); +} + function normalizeWorkspaceTab( value: WorkspaceTabTarget | null | undefined ): WorkspaceTabTarget | null { @@ -151,15 +183,23 @@ function buildTabRoute(input: { ); } -function isTabAvailable(input: { +function resolveTabAvailability(input: { tab: WorkspaceTabTarget; + agentsHydrated: boolean; + terminalsHydrated: boolean; agentsById: Map; terminalIds: Set; -}): boolean { +}): TabAvailability { if (input.tab.kind === "agent") { - return input.agentsById.has(input.tab.agentId); + if (!input.agentsHydrated) { + return "unknown"; + } + return input.agentsById.has(input.tab.agentId) ? "available" : "invalid"; } - return input.terminalIds.has(input.tab.terminalId); + if (!input.terminalsHydrated) { + return "unknown"; + } + return input.terminalIds.has(input.tab.terminalId) ? "available" : "invalid"; } function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { @@ -330,13 +370,20 @@ function WorkspaceScreenContent({ () => deriveWorkspaceName(normalizedWorkspaceId), [normalizedWorkspaceId] ); - const branchLabel = useMemo( - () => deriveBranchLabel(checkoutQuery.data ?? null), - [checkoutQuery.data] + const headerTitle = useMemo( + () => + deriveWorkspaceHeaderTitle({ + workspaceName, + checkout: checkoutQuery.data ?? null, + }), + [checkoutQuery.data, workspaceName] ); - const headerTitle = branchLabel ?? workspaceName; const isGitCheckout = checkoutQuery.data?.isGit ?? false; + const areWorkspaceAgentsHydrated = useSessionStore( + (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false + ); + const areWorkspaceTerminalsHydrated = terminalsQuery.isSuccess; const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore( @@ -411,8 +458,9 @@ function WorkspaceScreenContent({ key: `agent:${agent.id}`, kind: "agent", agentId: agent.id, + provider: agent.provider, label: agent.title?.trim() || "New agent", - subtitle: "Agent", + subtitle: `${formatProviderLabel(agent.provider)} agent`, }); } @@ -475,31 +523,75 @@ function WorkspaceScreenContent({ return { kind: "terminal", terminalId: first.terminalId }; }, [tabs]); + const requestedTabAvailability = useMemo(() => { + if (!requestedTab) { + return null; + } + return resolveTabAvailability({ + tab: requestedTab, + agentsHydrated: areWorkspaceAgentsHydrated, + terminalsHydrated: areWorkspaceTerminalsHydrated, + agentsById, + terminalIds, + }); + }, [ + agentsById, + areWorkspaceAgentsHydrated, + areWorkspaceTerminalsHydrated, + requestedTab, + terminalIds, + ]); + + const storedTabAvailability = useMemo(() => { + if (!storedTab) { + return null; + } + return resolveTabAvailability({ + tab: storedTab, + agentsHydrated: areWorkspaceAgentsHydrated, + terminalsHydrated: areWorkspaceTerminalsHydrated, + agentsById, + terminalIds, + }); + }, [ + agentsById, + areWorkspaceAgentsHydrated, + areWorkspaceTerminalsHydrated, + storedTab, + terminalIds, + ]); + const resolvedTab = useMemo(() => { - if ( - requestedTab && - isTabAvailable({ - tab: requestedTab, - agentsById, - terminalIds, - }) - ) { + if (requestedTab && requestedTabAvailability !== "invalid") { return requestedTab; } - if ( - storedTab && - isTabAvailable({ - tab: storedTab, - agentsById, - terminalIds, - }) - ) { + if (storedTab && storedTabAvailability !== "invalid") { return storedTab; } return fallbackTab; - }, [agentsById, fallbackTab, requestedTab, storedTab, terminalIds]); + }, [fallbackTab, requestedTab, requestedTabAvailability, storedTab, storedTabAvailability]); + + const resolvedTabAvailability = useMemo(() => { + if (!resolvedTab) { + return null; + } + + return resolveTabAvailability({ + tab: resolvedTab, + agentsHydrated: areWorkspaceAgentsHydrated, + terminalsHydrated: areWorkspaceTerminalsHydrated, + agentsById, + terminalIds, + }); + }, [ + agentsById, + areWorkspaceAgentsHydrated, + areWorkspaceTerminalsHydrated, + resolvedTab, + terminalIds, + ]); const navigateToTab = useCallback( (tab: WorkspaceTabTarget) => { @@ -531,6 +623,9 @@ function WorkspaceScreenContent({ if (!resolvedTab) { return; } + if (resolvedTabAvailability !== "available") { + return; + } setLastFocusedTab({ serverId: normalizedServerId, @@ -541,6 +636,7 @@ function WorkspaceScreenContent({ normalizedServerId, normalizedWorkspaceId, resolvedTab, + resolvedTabAvailability, setLastFocusedTab, ]); @@ -569,7 +665,9 @@ function WorkspaceScreenContent({ ]); const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false); + const [isNewTabMenuOpen, setIsNewTabMenuOpen] = useState(false); const tabSwitcherAnchorRef = useRef(null); + const newTabAnchorRef = useRef(null); const tabByKey = useMemo(() => { const map = new Map(); @@ -595,21 +693,30 @@ function WorkspaceScreenContent({ const tabSwitcherOptions = useMemo( () => - [ - ...tabs.map((tab) => ({ - id: tab.key, - label: tab.label, - description: tab.subtitle, - })), - { - id: CREATE_TERMINAL_SWITCHER_ID, - label: "New terminal", - description: "Create", - }, - ], + tabs.map((tab) => ({ + id: tab.key, + label: tab.label, + description: tab.subtitle, + })), [tabs] ); + const newTabOptions = useMemo( + () => [ + { + id: NEW_TAB_AGENT_OPTION_ID, + label: "Agent tab", + description: "Open the draft agent flow for this workspace", + }, + { + id: NEW_TAB_TERMINAL_OPTION_ID, + label: "Terminal tab", + description: "Create a new terminal in this workspace", + }, + ], + [] + ); + const activeTabLabel = useMemo(() => { const active = tabs.find((tab) => tab.key === activeTabKey); return active?.label ?? "Select tab"; @@ -625,6 +732,9 @@ function WorkspaceScreenContent({ }, [normalizedServerId, normalizedWorkspaceId, router]); const handleCreateTerminal = useCallback(() => { + if (createTerminalMutation.isPending) { + return; + } if (!normalizedWorkspaceId.startsWith("/")) { return; } @@ -633,11 +743,6 @@ function WorkspaceScreenContent({ const handleSelectSwitcherTab = useCallback( (key: string) => { - if (key === CREATE_TERMINAL_SWITCHER_ID) { - setIsTabSwitcherOpen(false); - handleCreateTerminal(); - return; - } const tab = tabByKey.get(key); if (!tab) { return; @@ -645,7 +750,21 @@ function WorkspaceScreenContent({ setIsTabSwitcherOpen(false); navigateToTab(tab); }, - [handleCreateTerminal, navigateToTab, tabByKey] + [navigateToTab, tabByKey] + ); + + const handleSelectNewTabOption = useCallback( + (key: string) => { + setIsNewTabMenuOpen(false); + if (key === NEW_TAB_AGENT_OPTION_ID) { + handleCreateAgent(); + return; + } + if (key === NEW_TAB_TERMINAL_OPTION_ID) { + handleCreateTerminal(); + } + }, + [handleCreateAgent, handleCreateTerminal] ); const renderContent = () => { @@ -653,31 +772,8 @@ function WorkspaceScreenContent({ return ( - No agent or terminal tabs are available for this workspace. + No tabs are available yet. Use New tab to create an agent or terminal. - [ - styles.emptyStateButton, - (hovered || pressed) && styles.emptyStateButtonActive, - ]} - onPress={handleCreateAgent} - > - Create agent - - [ - styles.emptyStateButton, - (hovered || pressed) && styles.emptyStateButtonActive, - ]} - onPress={handleCreateTerminal} - disabled={createTerminalMutation.isPending} - > - {createTerminalMutation.isPending ? ( - - ) : ( - Create terminal - )} - ); } @@ -741,16 +837,19 @@ function WorkspaceScreenContent({ ) : null} [ - styles.plusButton, - (hovered || pressed) && styles.plusButtonActive, + styles.newTabButton, + (hovered || pressed) && styles.newTabButtonActive, ]} - onPress={handleCreateAgent} + onPress={() => setIsNewTabMenuOpen(true)} accessibilityRole="button" - accessibilityLabel="Create agent" + accessibilityLabel="New tab" > - + + New tab + {isMobile ? ( @@ -766,6 +865,18 @@ function WorkspaceScreenContent({ anchorRef={tabSwitcherAnchorRef} /> ) : null} + + } /> @@ -780,14 +891,20 @@ function WorkspaceScreenContent({ > {tabs.map((tab) => { const isActive = tab.key === activeTabKey; + const iconColor = isActive + ? theme.colors.foreground + : theme.colors.foregroundMuted; const icon = tab.kind === "agent" ? ( - + tab.provider === "claude" ? ( + + ) : tab.provider === "codex" ? ( + + ) : ( + + ) ) : ( - + ); return ( @@ -820,28 +937,6 @@ function WorkspaceScreenContent({ ); })} - [ - styles.createTerminalButton, - (hovered || pressed) && styles.createTerminalButtonActive, - ]} - > - {createTerminalMutation.isPending ? ( - - ) : ( - - - - - - - )} - ) : null} @@ -884,16 +979,23 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - plusButton: { - padding: { - xs: theme.spacing[3], - md: theme.spacing[2], - }, - borderRadius: theme.borderRadius.lg, + newTabButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, }, - plusButtonActive: { + newTabButtonActive: { backgroundColor: theme.colors.surface2, }, + newTabButtonText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, switcherTrigger: { maxWidth: 220, flexDirection: "row", @@ -918,8 +1020,6 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, backgroundColor: theme.colors.surface0, - flexDirection: "row", - alignItems: "center", }, tabsScroll: { flex: 1, @@ -962,25 +1062,6 @@ const styles = StyleSheet.create((theme) => ({ tabLabelActive: { color: theme.colors.foreground, }, - createTerminalButton: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - marginRight: theme.spacing[2], - borderRadius: theme.borderRadius.md, - }, - createTerminalButtonActive: { - backgroundColor: theme.colors.surface2, - }, - createTerminalIcon: { - position: "relative", - justifyContent: "center", - alignItems: "center", - }, - createTerminalPlusIcon: { - position: "absolute", - top: -6, - right: -6, - }, content: { flex: 1, minHeight: 0, @@ -996,18 +1077,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, textAlign: "center", }, - emptyStateButton: { - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[2], - }, - emptyStateButtonActive: { - backgroundColor: theme.colors.surface2, - }, - emptyStateButtonText: { - color: theme.colors.foreground, - fontWeight: theme.fontWeight.medium, - }, }));