diff --git a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts new file mode 100644 index 000000000..07e62131b --- /dev/null +++ b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { test, expect } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { renameModalInput, renameModalSubmit } from "../support/helpers/rename"; +import type { SeedDaemonClient } from "../support/helpers/seed-client"; + +const MOBILE_VIEWPORT = { width: 390, height: 844 }; + +async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise { + const result = await client.fetchAgents({ scope: "active" }); + return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; +} + +/** + * Compact "sessions dropdown" rename path. The desktop rename specs drive the right-click + * `workspace-tab-context-*` menu, which portals into overlay-root and was never broken. This + * exercises the tab switcher's per-session actions on a phone-sized viewport — the path that + * regressed when the actions menu presented as a popover instead of a bottom sheet. + */ +test.describe("Workspace session rename (compact tab switcher)", () => { + test("renames an agent session from the switcher's per-session actions sheet", async ({ + page, + }) => { + test.setTimeout(120_000); + + await page.setViewportSize(MOBILE_VIEWPORT); + const initialTitle = `switcher-rename-${randomUUID().slice(0, 8)}`; + const session = await seedMockAgentWorkspace({ + repoPrefix: "workspace-switcher-rename-", + title: initialTitle, + }); + + try { + await openAgentRoute(page, session); + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({ + timeout: 30_000, + }); + + // Open the sessions dropdown (a bottom sheet on compact). + await page.getByRole("button", { name: /Switch tabs/ }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible( + { + timeout: 10_000, + }, + ); + + // Open this session's "…" actions. Before the fix this tried to open a popover-Modal + // that never surfaced over the sheet on native; it now opens as a stacked sheet. + const menuBase = `workspace-tab-menu-agent_${session.agentId}`; + const actionsTrigger = page.getByTestId(`${menuBase}-trigger`); + await expect(actionsTrigger).toBeVisible({ timeout: 15_000 }); + await actionsTrigger.click(); + + const renameItem = page.getByTestId(`${menuBase}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-agent-${session.agentId}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + await expect(input).toHaveValue(initialTitle); + + const renamed = "Renamed from the dropdown"; + await input.fill(renamed); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, { + timeout: 15_000, + }); + await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed); + } finally { + await session.cleanup(); + } + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3ee7f59b3..1ef6d79e2 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -20,21 +20,15 @@ import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; import { DiffStat } from "@/components/diff-stat"; import { - CopyX, - ArrowLeftToLine, - ArrowRightToLine, ChevronDown, Copy, Ellipsis, Globe, Import as ImportIcon, PanelRight, - Pencil, - RotateCw, Settings, SquarePen, SquareTerminal, - X, } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -128,13 +122,13 @@ import { useWorkspaceTabRename, WorkspaceTabRenameModal, } from "@/screens/workspace/use-workspace-tab-rename"; +import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; import { buildWorkspaceTabMenuEntries, - type WorkspaceTabMenuEntry, type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; @@ -249,12 +243,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedCopy = withUnistyles(Copy); -const ThemedRotateCw = withUnistyles(RotateCw); -const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); -const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); -const ThemedCopyX = withUnistyles(CopyX); -const ThemedPencil = withUnistyles(Pencil); -const ThemedX = withUnistyles(X); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); @@ -516,95 +504,10 @@ function WorkspaceDocumentTitleEffect({ function noop() {} -function mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) { - return [ - styles.mobileTabMenuTrigger, - (Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive, - ]; -} - function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; } -function MobileTabTrailingAccessory({ - menuTestIDBase, - presentationLabel, - menuEntries, -}: { - menuTestIDBase: string; - presentationLabel: string; - menuEntries: WorkspaceTabMenuEntry[]; -}) { - const { t } = useTranslation(); - return ( - - - - - - {menuEntries.map((entry) => - entry.kind === "separator" ? ( - - ) : ( - - ), - )} - - - ); -} - -function MobileTabDropdownMenuItem({ - entry, -}: { - entry: Extract; -}) { - const leading = useMemo(() => { - switch (entry.icon) { - case "copy": - return ; - case "rotate-cw": - return ; - case "arrow-left-to-line": - return ; - case "arrow-right-to-line": - return ; - case "copy-x": - return ; - case "pencil": - return ; - case "x": - return ; - default: - return undefined; - } - }, [entry.icon]); - const trailing = useMemo( - () => (entry.hint ? {entry.hint} : undefined), - [entry.hint], - ); - return ( - - {entry.label} - - ); -} - function MobileWorkspaceTabOption({ tab, tabIndex, @@ -4170,20 +4073,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, - mobileTabMenuTrigger: { - width: 28, - height: 28, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - }, - mobileTabMenuTriggerActive: { - backgroundColor: theme.colors.surface2, - }, - menuItemHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, headerMenuProfileIconWrapper: { width: 16, height: 16, diff --git a/packages/app/src/screens/workspace/workspace-tab-trailing-accessory.test.tsx b/packages/app/src/screens/workspace/workspace-tab-trailing-accessory.test.tsx new file mode 100644 index 000000000..388554acb --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-tab-trailing-accessory.test.tsx @@ -0,0 +1,232 @@ +/** + * @vitest-environment jsdom + */ +import { i18n as testI18n } from "@/i18n/i18next"; +import React, { type ReactElement } from "react"; +import { act, fireEvent } from "@testing-library/react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; + +void testI18n; + +/** + * Records the props the accessory hands to the menu engine, so the test can assert the + * compact presentation without rendering the real gorhom sheet in jsdom. + */ +const capturedRootProps: { compactMode?: string }[] = []; +const capturedContentProps: { sheetTitle?: string; testID?: string }[] = []; + +const { mockTheme } = vi.hoisted(() => ({ + mockTheme: { + colors: { foregroundMuted: "#aaa", surface2: "#222" }, + borderRadius: { md: 6 }, + fontSize: { xs: 11 }, + }, +})); + +vi.mock("lucide-react-native", () => { + const StubIcon = () => null; + return { + ArrowLeftToLine: StubIcon, + ArrowRightToLine: StubIcon, + Copy: StubIcon, + CopyX: StubIcon, + Ellipsis: StubIcon, + Pencil: StubIcon, + RotateCw: StubIcon, + X: StubIcon, + }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (theme: unknown) => unknown)(mockTheme) : factory, + }, + withUnistyles: + (Component: React.ComponentType>) => + ({ + uniProps, + ...rest + }: { uniProps?: (theme: unknown) => Record } & Record) => { + const themed = uniProps ? uniProps(mockTheme) : {}; + return React.createElement(Component, { ...rest, ...themed }); + }, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ + children, + compactMode, + }: { + children: React.ReactNode; + compactMode?: string; + }) => { + capturedRootProps.push({ compactMode }); + return
{children}
; + }, + DropdownMenuTrigger: ({ + children, + testID, + }: { + children: + | React.ReactNode + | ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode); + testID?: string; + }) => ( + + ), + DropdownMenuContent: ({ + children, + sheetTitle, + testID, + }: { + children: React.ReactNode; + sheetTitle?: string; + testID?: string; + }) => { + capturedContentProps.push({ sheetTitle, testID }); + return
{children}
; + }, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ + children, + onSelect, + testID, + }: { + children: React.ReactNode; + onSelect?: () => void; + testID?: string; + }) => ( + + ), +})); + +function agentTab(): WorkspaceTabDescriptor { + return { + key: "agent_123", + tabId: "agent_123", + kind: "agent", + target: { kind: "agent", agentId: "agent-123" }, + }; +} + +function terminalTab(): WorkspaceTabDescriptor { + return { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; +} + +function renderAccessory( + tab: WorkspaceTabDescriptor, + onRenameTab: (tab: WorkspaceTabDescriptor) => void, +): { base: string; unmount: () => void } { + const base = `workspace-tab-menu-${tab.tabId}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "mobile", + tab, + index: 0, + tabCount: 2, + menuTestIDBase: base, + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), + onCopyFilePath: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const element: ReactElement = ( + + ); + act(() => { + root.render(element); + }); + return { + base, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("MobileTabTrailingAccessory", () => { + let current: { unmount: () => void } | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + document.body.innerHTML = ""; + capturedRootProps.length = 0; + capturedContentProps.length = 0; + }); + + afterEach(() => { + current?.unmount(); + current = null; + vi.unstubAllGlobals(); + }); + + it("renders the per-session actions as a bottom sheet on compact (so it is reachable over the sessions sheet)", () => { + const onRenameTab = vi.fn(); + current = renderAccessory(agentTab(), onRenameTab); + + // The fix: the menu must open as a sheet, not the default popover that stacks a + // fragile second native Modal over the sessions bottom sheet. + expect(capturedRootProps).toContainEqual({ compactMode: "sheet" }); + expect(capturedContentProps[0]?.sheetTitle).toBe("My session"); + expect(capturedContentProps[0]?.testID).toBe("workspace-tab-menu-agent_123"); + }); + + it("reaches Rename for an agent session", () => { + const onRenameTab = vi.fn(); + const rendered = renderAccessory(agentTab(), onRenameTab); + current = rendered; + + expect(document.querySelector(`[data-testid="${rendered.base}-trigger"]`)).not.toBeNull(); + const renameButton = document.querySelector(`[data-testid="${rendered.base}-rename"]`); + expect(renameButton).not.toBeNull(); + fireEvent.click(renameButton as HTMLElement); + + expect(onRenameTab).toHaveBeenCalledTimes(1); + expect(onRenameTab).toHaveBeenCalledWith(agentTab()); + }); + + it("reaches Rename for a terminal session", () => { + const onRenameTab = vi.fn(); + const rendered = renderAccessory(terminalTab(), onRenameTab); + current = rendered; + + const renameButton = document.querySelector(`[data-testid="${rendered.base}-rename"]`); + expect(renameButton).not.toBeNull(); + fireEvent.click(renameButton as HTMLElement); + + expect(onRenameTab).toHaveBeenCalledWith(terminalTab()); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-trailing-accessory.tsx b/packages/app/src/screens/workspace/workspace-tab-trailing-accessory.tsx new file mode 100644 index 000000000..fe224dae9 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-tab-trailing-accessory.tsx @@ -0,0 +1,142 @@ +import { useMemo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { Text } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + ArrowLeftToLine, + ArrowRightToLine, + Copy, + CopyX, + Ellipsis, + Pencil, + RotateCw, + X, +} from "lucide-react-native"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { WorkspaceTabMenuEntry } from "@/screens/workspace/workspace-tab-menu"; +import type { Theme } from "@/styles/theme"; + +const ThemedEllipsis = withUnistyles(Ellipsis); +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); +const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); +const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); +const ThemedX = withUnistyles(X); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) { + return [ + styles.mobileTabMenuTrigger, + (Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive, + ]; +} + +function MobileTabDropdownMenuItem({ + entry, +}: { + entry: Extract; +}) { + const leading = useMemo(() => { + switch (entry.icon) { + case "copy": + return ; + case "rotate-cw": + return ; + case "arrow-left-to-line": + return ; + case "arrow-right-to-line": + return ; + case "copy-x": + return ; + case "pencil": + return ; + case "x": + return ; + default: + return undefined; + } + }, [entry.icon]); + const trailing = useMemo( + () => (entry.hint ? {entry.hint} : undefined), + [entry.hint], + ); + return ( + + {entry.label} + + ); +} + +export function MobileTabTrailingAccessory({ + menuTestIDBase, + presentationLabel, + menuEntries, +}: { + menuTestIDBase: string; + presentationLabel: string; + menuEntries: WorkspaceTabMenuEntry[]; +}): ReactElement { + const { t } = useTranslation(); + return ( + + + + + + {menuEntries.map((entry) => + entry.kind === "separator" ? ( + + ) : ( + + ), + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + mobileTabMenuTrigger: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + }, + mobileTabMenuTriggerActive: { + backgroundColor: theme.colors.surface2, + }, + menuItemHint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, +}));