From cdf59e8315f142213e3f41152813f083e71b5089 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Mar 2026 22:43:14 +0700 Subject: [PATCH] Restore workspace header controls and terminal tab close --- .../e2e/workspace-header-tabs-restore.spec.ts | 93 ++++ .../screens/workspace/workspace-screen.tsx | 405 ++++++++++++++++-- 2 files changed, 451 insertions(+), 47 deletions(-) create mode 100644 packages/app/e2e/workspace-header-tabs-restore.spec.ts diff --git a/packages/app/e2e/workspace-header-tabs-restore.spec.ts b/packages/app/e2e/workspace-header-tabs-restore.spec.ts new file mode 100644 index 000000000..3c835d4b8 --- /dev/null +++ b/packages/app/e2e/workspace-header-tabs-restore.spec.ts @@ -0,0 +1,93 @@ +import { test, expect, type Page } from "./fixtures"; +import { + createAgent, + ensureHostSelected, + gotoHome, + setWorkingDirectory, +} from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; + +function buildWorkspaceRoute(serverId: string, workspacePath: string): string { + return `/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent(workspacePath)}`; +} + +async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + await gotoHome(page); + await ensureHostSelected(page); + await setWorkingDirectory(page, workspacePath); + await createAgent(page, `workspace header restore ${Date.now()}`); + + await page.goto(buildWorkspaceRoute(serverId, workspacePath)); + await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), { + timeout: 30000, + }); + await expect(page.getByTestId("workspace-new-tab").first()).toBeVisible({ + timeout: 30000, + }); +} + +test("workspace new-tab menu opens on-screen", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-"); + + try { + await openWorkspaceWithAgent(page, repo.path); + + const trigger = page.getByTestId("workspace-new-tab").first(); + await expect(trigger).toBeVisible({ timeout: 30000 }); + await trigger.click(); + + const menu = page.getByTestId("workspace-new-tab-content").first(); + await expect(menu).toBeVisible({ timeout: 10000 }); + + const menuBounds = await menu.boundingBox(); + const viewport = page.viewportSize(); + + expect(menuBounds).not.toBeNull(); + expect(viewport).not.toBeNull(); + + if (!menuBounds || !viewport) { + return; + } + + expect(menuBounds.x).toBeGreaterThanOrEqual(0); + expect(menuBounds.y).toBeGreaterThanOrEqual(0); + expect(menuBounds.x + menuBounds.width).toBeLessThanOrEqual(viewport.width); + expect(menuBounds.y + menuBounds.height).toBeLessThanOrEqual(viewport.height); + } finally { + await repo.cleanup(); + } +}); + +test("workspace explorer toggle opens and closes explorer", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-"); + + try { + await openWorkspaceWithAgent(page, repo.path); + + const toggle = page.getByTestId("workspace-explorer-toggle").first(); + const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first(); + await expect(toggle).toBeVisible({ timeout: 30000 }); + + const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true"; + if (initiallyExpanded) { + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(explorerHeader).not.toBeVisible({ timeout: 10000 }); + } + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(explorerHeader).toBeVisible({ timeout: 10000 }); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(explorerHeader).not.toBeVisible({ timeout: 10000 }); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index cbec29838..4ad4c22f2 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ActivityIndicator, BackHandler, Platform, Pressable, @@ -9,14 +10,31 @@ import { } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; -import { Bot, ChevronDown, Plus, Terminal } from "lucide-react-native"; +import { + Bot, + ChevronDown, + Folder, + GitBranch, + MoreVertical, + PanelRight, + Plus, + Terminal, + X, +} 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 { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { Combobox } from "@/components/ui/combobox"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { TerminalPane } from "@/components/terminal-pane"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; @@ -29,6 +47,8 @@ import { type WorkspaceTabTarget, } from "@/stores/workspace-tabs-store"; import { + buildHostAgentDetailRoute, + buildHostWorkspaceRoute, buildHostWorkspaceAgentRoute, buildHostWorkspaceTerminalRoute, } from "@/utils/host-routes"; @@ -41,8 +61,10 @@ import { import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; +import { confirmDialog } from "@/utils/confirm-dialog"; const TERMINALS_QUERY_STALE_TIME = 5_000; +const DROPDOWN_WIDTH = 220; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__"; @@ -213,6 +235,15 @@ function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { }); } +function toWorkspaceTabTarget( + tab: WorkspaceTabDescriptor +): WorkspaceTabTarget { + if (tab.kind === "agent") { + return { kind: "agent", agentId: tab.agentId }; + } + return { kind: "terminal", terminalId: tab.terminalId }; +} + export function WorkspaceScreen({ serverId, workspaceId, @@ -315,6 +346,18 @@ function WorkspaceScreenContent({ } }, }); + const killTerminalMutation = useMutation({ + mutationFn: async (terminalId: string) => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.killTerminal(terminalId); + if (!payload.success) { + throw new Error("Unable to close terminal"); + } + return payload; + }, + }); useEffect(() => { if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) { @@ -389,7 +432,11 @@ function WorkspaceScreenContent({ const desktopFileExplorerOpen = usePanelStore( (state) => state.desktop.fileExplorerOpen ); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const activateExplorerTabForCheckout = usePanelStore( + (state) => state.activateExplorerTabForCheckout + ); const closeToAgent = usePanelStore((state) => state.closeToAgent); const setActiveExplorerCheckout = usePanelStore( (state) => state.setActiveExplorerCheckout @@ -418,8 +465,21 @@ function WorkspaceScreenContent({ if (!activeExplorerCheckout) { return; } + activateExplorerTabForCheckout(activeExplorerCheckout); openFileExplorer(); - }, [activeExplorerCheckout, openFileExplorer]); + }, [ + activateExplorerTabForCheckout, + activeExplorerCheckout, + openFileExplorer, + ]); + + const handleToggleExplorer = useCallback(() => { + if (isExplorerOpen) { + toggleFileExplorer(); + return; + } + openExplorerForWorkspace(); + }, [isExplorerOpen, openExplorerForWorkspace, toggleFileExplorer]); const explorerOpenGesture = useExplorerOpenGesture({ enabled: isMobile && mobileView === "agent", @@ -665,9 +725,11 @@ function WorkspaceScreenContent({ ]); const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false); - const [isNewTabMenuOpen, setIsNewTabMenuOpen] = useState(false); + const [hoveredTabKey, setHoveredTabKey] = useState(null); + const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState( + null + ); const tabSwitcherAnchorRef = useRef(null); - const newTabAnchorRef = useRef(null); const tabByKey = useMemo(() => { const map = new Map(); @@ -701,21 +763,12 @@ function WorkspaceScreenContent({ [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 activeAgent = useMemo(() => { + if (resolvedTab?.kind !== "agent") { + return null; + } + return agentsById.get(resolvedTab.agentId) ?? null; + }, [agentsById, resolvedTab]); const activeTabLabel = useMemo(() => { const active = tabs.find((tab) => tab.key === activeTabKey); @@ -754,8 +807,7 @@ function WorkspaceScreenContent({ ); const handleSelectNewTabOption = useCallback( - (key: string) => { - setIsNewTabMenuOpen(false); + (key: typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID) => { if (key === NEW_TAB_AGENT_OPTION_ID) { handleCreateAgent(); return; @@ -767,6 +819,104 @@ function WorkspaceScreenContent({ [handleCreateAgent, handleCreateTerminal] ); + const getTabAfterClosingTerminal = useCallback( + (terminalId: string): WorkspaceTabTarget | null => { + const currentIndex = tabs.findIndex( + (tab) => tab.kind === "terminal" && tab.terminalId === terminalId + ); + const nextTabs = tabs.filter( + (tab) => !(tab.kind === "terminal" && tab.terminalId === terminalId) + ); + if (nextTabs.length === 0) { + return null; + } + const safeIndex = currentIndex < 0 + ? 0 + : Math.min(currentIndex, nextTabs.length - 1); + const candidate = nextTabs[safeIndex] ?? nextTabs[0]; + if (!candidate) { + return null; + } + return toWorkspaceTabTarget(candidate); + }, + [tabs] + ); + + const handleCloseTerminalTab = useCallback( + async (terminalId: string) => { + if ( + killTerminalMutation.isPending && + killTerminalMutation.variables === terminalId + ) { + return; + } + + const confirmed = await confirmDialog({ + title: "Close terminal?", + message: "Any running process in this terminal will be stopped immediately.", + confirmLabel: "Close", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) { + return; + } + + killTerminalMutation.mutate(terminalId, { + onSuccess: () => { + const tabKey = `terminal:${terminalId}`; + setHoveredTabKey((current) => (current === tabKey ? null : current)); + setHoveredCloseTabKey((current) => + current === tabKey ? null : current + ); + + if ( + resolvedTab?.kind === "terminal" && + resolvedTab.terminalId === terminalId + ) { + const nextTab = getTabAfterClosingTerminal(terminalId); + if (nextTab) { + navigateToTab(nextTab); + } else { + router.replace( + buildHostWorkspaceRoute( + normalizedServerId, + normalizedWorkspaceId + ) as any + ); + } + } + + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + void queryClient.refetchQueries({ + queryKey: terminalsQueryKey, + type: "active", + }); + }, + }); + }, + [ + getTabAfterClosingTerminal, + killTerminalMutation, + navigateToTab, + normalizedServerId, + normalizedWorkspaceId, + queryClient, + resolvedTab, + router, + terminalsQueryKey, + ] + ); + + const handleOpenAgentChatView = useCallback(() => { + if (!activeAgent) { + return; + } + router.push( + buildHostAgentDetailRoute(normalizedServerId, activeAgent.id) as any + ); + }, [activeAgent, normalizedServerId, router]); + const renderContent = () => { if (!resolvedTab) { return ( @@ -836,21 +986,114 @@ function WorkspaceScreenContent({ ) : null} - [ - styles.newTabButton, - (hovered || pressed) && styles.newTabButtonActive, - ]} - onPress={() => setIsNewTabMenuOpen(true)} + - - New tab - - + {isMobile ? ( + isGitCheckout ? ( + + ) : ( + + ) + ) : ( + + )} + + + + [ + styles.newTabButton, + (hovered || pressed || open) && styles.newTabButtonActive, + ]} + > + + New tab + + + + { + handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID); + }} + > + Agent tab + + { + handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID); + }} + > + Terminal tab + + + + + {activeAgent ? ( + + + + + + + Open chat view + + + + ) : null} {isMobile ? ( ) : null} - - } /> @@ -891,6 +1122,15 @@ function WorkspaceScreenContent({ > {tabs.map((tab) => { const isActive = tab.key === activeTabKey; + const isTabHovered = hoveredTabKey === tab.key; + const isCloseHovered = hoveredCloseTabKey === tab.key; + const isClosingTerminal = + tab.kind === "terminal" && + killTerminalMutation.isPending && + killTerminalMutation.variables === tab.terminalId; + const shouldShowCloseButton = + tab.kind === "terminal" && + (isTabHovered || isCloseHovered || isClosingTerminal); const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted; @@ -910,11 +1150,20 @@ function WorkspaceScreenContent({ return ( [ styles.tab, isActive && styles.tabActive, (hovered || pressed) && styles.tabHovered, ]} + onHoverIn={() => { + setHoveredTabKey(tab.key); + }} + onHoverOut={() => { + setHoveredTabKey((current) => + current === tab.key ? null : current + ); + }} onPress={() => { if (tab.kind === "agent") { navigateToTab({ kind: "agent", agentId: tab.agentId }); @@ -928,11 +1177,47 @@ function WorkspaceScreenContent({ > {icon} {tab.label} + {tab.kind === "terminal" ? ( + { + setHoveredCloseTabKey(tab.key); + }} + onHoverOut={() => { + setHoveredCloseTabKey((current) => + current === tab.key ? null : current + ); + }} + onPress={(event) => { + event.stopPropagation(); + void handleCloseTerminalTab(tab.terminalId); + }} + style={({ hovered, pressed }) => [ + styles.tabCloseButton, + shouldShowCloseButton + ? styles.tabCloseButtonShown + : styles.tabCloseButtonHidden, + (hovered || pressed) && styles.tabCloseButtonActive, + ]} + > + {isClosingTerminal ? ( + + ) : ( + + )} + + ) : null} ); })} @@ -979,6 +1264,10 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + menuButton: { + padding: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, newTabButton: { flexDirection: "row", alignItems: "center", @@ -1055,13 +1344,35 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, tabLabel: { + flexShrink: 1, + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, + tabLabelWithCloseButton: { + paddingRight: theme.spacing[1], + }, tabLabelActive: { color: theme.colors.foreground, }, + tabCloseButton: { + width: 18, + height: 18, + marginLeft: theme.spacing[1], + borderRadius: theme.borderRadius.sm, + alignItems: "center", + justifyContent: "center", + }, + tabCloseButtonShown: { + opacity: 1, + }, + tabCloseButtonHidden: { + opacity: 0, + }, + tabCloseButtonActive: { + backgroundColor: theme.colors.surface3, + }, content: { flex: 1, minHeight: 0,