Restore workspace header controls and terminal tab close
This commit is contained in:
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -9,14 +10,31 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
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 { GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-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 { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
@@ -29,6 +47,8 @@ import {
|
|||||||
type WorkspaceTabTarget,
|
type WorkspaceTabTarget,
|
||||||
} from "@/stores/workspace-tabs-store";
|
} from "@/stores/workspace-tabs-store";
|
||||||
import {
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostWorkspaceTerminalRoute,
|
buildHostWorkspaceTerminalRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -41,8 +61,10 @@ import {
|
|||||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
|
const DROPDOWN_WIDTH = 220;
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
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({
|
export function WorkspaceScreen({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
||||||
@@ -389,7 +432,11 @@ function WorkspaceScreenContent({
|
|||||||
const desktopFileExplorerOpen = usePanelStore(
|
const desktopFileExplorerOpen = usePanelStore(
|
||||||
(state) => state.desktop.fileExplorerOpen
|
(state) => state.desktop.fileExplorerOpen
|
||||||
);
|
);
|
||||||
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
|
const activateExplorerTabForCheckout = usePanelStore(
|
||||||
|
(state) => state.activateExplorerTabForCheckout
|
||||||
|
);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
const setActiveExplorerCheckout = usePanelStore(
|
const setActiveExplorerCheckout = usePanelStore(
|
||||||
(state) => state.setActiveExplorerCheckout
|
(state) => state.setActiveExplorerCheckout
|
||||||
@@ -418,8 +465,21 @@ function WorkspaceScreenContent({
|
|||||||
if (!activeExplorerCheckout) {
|
if (!activeExplorerCheckout) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
activateExplorerTabForCheckout(activeExplorerCheckout);
|
||||||
openFileExplorer();
|
openFileExplorer();
|
||||||
}, [activeExplorerCheckout, openFileExplorer]);
|
}, [
|
||||||
|
activateExplorerTabForCheckout,
|
||||||
|
activeExplorerCheckout,
|
||||||
|
openFileExplorer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleToggleExplorer = useCallback(() => {
|
||||||
|
if (isExplorerOpen) {
|
||||||
|
toggleFileExplorer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openExplorerForWorkspace();
|
||||||
|
}, [isExplorerOpen, openExplorerForWorkspace, toggleFileExplorer]);
|
||||||
|
|
||||||
const explorerOpenGesture = useExplorerOpenGesture({
|
const explorerOpenGesture = useExplorerOpenGesture({
|
||||||
enabled: isMobile && mobileView === "agent",
|
enabled: isMobile && mobileView === "agent",
|
||||||
@@ -665,9 +725,11 @@ function WorkspaceScreenContent({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
||||||
const [isNewTabMenuOpen, setIsNewTabMenuOpen] = useState(false);
|
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||||
|
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const tabSwitcherAnchorRef = useRef<View>(null);
|
const tabSwitcherAnchorRef = useRef<View>(null);
|
||||||
const newTabAnchorRef = useRef<View>(null);
|
|
||||||
|
|
||||||
const tabByKey = useMemo(() => {
|
const tabByKey = useMemo(() => {
|
||||||
const map = new Map<string, WorkspaceTabTarget>();
|
const map = new Map<string, WorkspaceTabTarget>();
|
||||||
@@ -701,21 +763,12 @@ function WorkspaceScreenContent({
|
|||||||
[tabs]
|
[tabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const newTabOptions = useMemo(
|
const activeAgent = useMemo(() => {
|
||||||
() => [
|
if (resolvedTab?.kind !== "agent") {
|
||||||
{
|
return null;
|
||||||
id: NEW_TAB_AGENT_OPTION_ID,
|
}
|
||||||
label: "Agent tab",
|
return agentsById.get(resolvedTab.agentId) ?? null;
|
||||||
description: "Open the draft agent flow for this workspace",
|
}, [agentsById, resolvedTab]);
|
||||||
},
|
|
||||||
{
|
|
||||||
id: NEW_TAB_TERMINAL_OPTION_ID,
|
|
||||||
label: "Terminal tab",
|
|
||||||
description: "Create a new terminal in this workspace",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTabLabel = useMemo(() => {
|
const activeTabLabel = useMemo(() => {
|
||||||
const active = tabs.find((tab) => tab.key === activeTabKey);
|
const active = tabs.find((tab) => tab.key === activeTabKey);
|
||||||
@@ -754,8 +807,7 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectNewTabOption = useCallback(
|
const handleSelectNewTabOption = useCallback(
|
||||||
(key: string) => {
|
(key: typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID) => {
|
||||||
setIsNewTabMenuOpen(false);
|
|
||||||
if (key === NEW_TAB_AGENT_OPTION_ID) {
|
if (key === NEW_TAB_AGENT_OPTION_ID) {
|
||||||
handleCreateAgent();
|
handleCreateAgent();
|
||||||
return;
|
return;
|
||||||
@@ -767,6 +819,104 @@ function WorkspaceScreenContent({
|
|||||||
[handleCreateAgent, handleCreateTerminal]
|
[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 = () => {
|
const renderContent = () => {
|
||||||
if (!resolvedTab) {
|
if (!resolvedTab) {
|
||||||
return (
|
return (
|
||||||
@@ -836,21 +986,114 @@ function WorkspaceScreenContent({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Pressable
|
<HeaderToggleButton
|
||||||
ref={newTabAnchorRef}
|
testID="workspace-explorer-toggle"
|
||||||
testID="workspace-new-tab"
|
onPress={handleToggleExplorer}
|
||||||
style={({ hovered, pressed }) => [
|
tooltipLabel="Toggle explorer"
|
||||||
styles.newTabButton,
|
tooltipKeys={["mod", "E"]}
|
||||||
(hovered || pressed) && styles.newTabButtonActive,
|
tooltipSide="left"
|
||||||
]}
|
style={styles.menuButton}
|
||||||
onPress={() => setIsNewTabMenuOpen(true)}
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="New tab"
|
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||||
|
accessibilityState={{ expanded: isExplorerOpen }}
|
||||||
>
|
>
|
||||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{isMobile ? (
|
||||||
<Text style={styles.newTabButtonText}>New tab</Text>
|
isGitCheckout ? (
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<GitBranch
|
||||||
</Pressable>
|
size={theme.iconSize.lg}
|
||||||
|
color={
|
||||||
|
isExplorerOpen
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Folder
|
||||||
|
size={theme.iconSize.lg}
|
||||||
|
color={
|
||||||
|
isExplorerOpen
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<PanelRight
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={
|
||||||
|
isExplorerOpen
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HeaderToggleButton>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="workspace-new-tab"
|
||||||
|
style={({ hovered, pressed, open }) => [
|
||||||
|
styles.newTabButton,
|
||||||
|
(hovered || pressed || open) && styles.newTabButtonActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.newTabButtonText}>New tab</Text>
|
||||||
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
width={DROPDOWN_WIDTH}
|
||||||
|
testID="workspace-new-tab-content"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="workspace-new-tab-agent-option"
|
||||||
|
description="Open the draft agent flow for this workspace"
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Agent tab
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="workspace-new-tab-terminal-option"
|
||||||
|
description="Create a new terminal in this workspace"
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Terminal tab
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{activeAgent ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="workspace-agent-overflow-menu"
|
||||||
|
style={styles.menuButton}
|
||||||
|
>
|
||||||
|
<MoreVertical
|
||||||
|
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
width={DROPDOWN_WIDTH}
|
||||||
|
testID="workspace-agent-overflow-content"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="workspace-agent-overflow-open-chat"
|
||||||
|
description="Open this agent with the full chat header"
|
||||||
|
onSelect={handleOpenAgentChatView}
|
||||||
|
>
|
||||||
|
Open chat view
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -865,18 +1108,6 @@ function WorkspaceScreenContent({
|
|||||||
anchorRef={tabSwitcherAnchorRef}
|
anchorRef={tabSwitcherAnchorRef}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Combobox
|
|
||||||
options={newTabOptions}
|
|
||||||
value=""
|
|
||||||
onSelect={handleSelectNewTabOption}
|
|
||||||
searchable={false}
|
|
||||||
title="New tab"
|
|
||||||
searchPlaceholder="Search tab types"
|
|
||||||
open={isNewTabMenuOpen}
|
|
||||||
onOpenChange={setIsNewTabMenuOpen}
|
|
||||||
anchorRef={newTabAnchorRef}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -891,6 +1122,15 @@ function WorkspaceScreenContent({
|
|||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.key === activeTabKey;
|
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
|
const iconColor = isActive
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
: theme.colors.foregroundMuted;
|
: theme.colors.foregroundMuted;
|
||||||
@@ -910,11 +1150,20 @@ function WorkspaceScreenContent({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
testID={`workspace-tab-${tab.key}`}
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
styles.tab,
|
styles.tab,
|
||||||
isActive && styles.tabActive,
|
isActive && styles.tabActive,
|
||||||
(hovered || pressed) && styles.tabHovered,
|
(hovered || pressed) && styles.tabHovered,
|
||||||
]}
|
]}
|
||||||
|
onHoverIn={() => {
|
||||||
|
setHoveredTabKey(tab.key);
|
||||||
|
}}
|
||||||
|
onHoverOut={() => {
|
||||||
|
setHoveredTabKey((current) =>
|
||||||
|
current === tab.key ? null : current
|
||||||
|
);
|
||||||
|
}}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (tab.kind === "agent") {
|
if (tab.kind === "agent") {
|
||||||
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
||||||
@@ -928,11 +1177,47 @@ function WorkspaceScreenContent({
|
|||||||
>
|
>
|
||||||
<View style={styles.tabIcon}>{icon}</View>
|
<View style={styles.tabIcon}>{icon}</View>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.tabLabel, isActive && styles.tabLabelActive]}
|
style={[
|
||||||
|
styles.tabLabel,
|
||||||
|
isActive && styles.tabLabelActive,
|
||||||
|
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||||
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{tab.kind === "terminal" ? (
|
||||||
|
<Pressable
|
||||||
|
testID={`workspace-terminal-close-${tab.terminalId}`}
|
||||||
|
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||||
|
disabled={!shouldShowCloseButton || isClosingTerminal}
|
||||||
|
onHoverIn={() => {
|
||||||
|
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 ? (
|
||||||
|
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<X size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -979,6 +1264,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
newTabButton: {
|
newTabButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -1055,13 +1344,35 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
tabLabel: {
|
tabLabel: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
tabLabelWithCloseButton: {
|
||||||
|
paddingRight: theme.spacing[1],
|
||||||
|
},
|
||||||
tabLabelActive: {
|
tabLabelActive: {
|
||||||
color: theme.colors.foreground,
|
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: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user