fix(app): make session rename reachable in the mobile sessions dropdown (#2924)
* fix(app): make session rename reachable in the mobile sessions dropdown The per-session actions menu in the mobile tab switcher (a gorhom bottom sheet) opened as an anchored popover, which on native renders a second RN Modal stacked on the sheet — the fragile nested-overlay case docs/floating-panels.md warns about. The actions menu (incl. Rename) never appeared on native, so sessions could not be renamed from the dropdown. Give the menu compactMode="sheet" like every other mobile menu in the file, so it stacks as a gorhom sheet on a gorhom sheet (the supported path). Extract the accessory into workspace-tab-trailing-accessory.tsx and add a reachability test that fails if the sheet presentation regresses. * test(app): cover compact session rename via the tab switcher e2e Adds a Playwright spec that drives the mobile-viewport sessions dropdown -> per-session actions sheet -> Rename, exercising the real gorhom sheet interaction the fix restores (the desktop rename specs only cover the right-click context menu, and the unit test mocks the menu engine). --------- Co-authored-by: Klaude <klaude@Klaudes-MacBook-Pro.local> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Klaude
Mohamed Boudra
parent
1e0ce69c54
commit
49b257c55a
@@ -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<string | null> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,21 +20,15 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
CopyX,
|
|
||||||
ArrowLeftToLine,
|
|
||||||
ArrowRightToLine,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
Globe,
|
Globe,
|
||||||
Import as ImportIcon,
|
Import as ImportIcon,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Pencil,
|
|
||||||
RotateCw,
|
|
||||||
Settings,
|
Settings,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
X,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
@@ -128,13 +122,13 @@ import {
|
|||||||
useWorkspaceTabRename,
|
useWorkspaceTabRename,
|
||||||
WorkspaceTabRenameModal,
|
WorkspaceTabRenameModal,
|
||||||
} from "@/screens/workspace/use-workspace-tab-rename";
|
} from "@/screens/workspace/use-workspace-tab-rename";
|
||||||
|
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
|
||||||
import {
|
import {
|
||||||
WorkspaceDesktopTabsRow,
|
WorkspaceDesktopTabsRow,
|
||||||
type WorkspaceDesktopTabRowItem,
|
type WorkspaceDesktopTabRowItem,
|
||||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabMenuEntries,
|
buildWorkspaceTabMenuEntries,
|
||||||
type WorkspaceTabMenuEntry,
|
|
||||||
type WorkspaceTabMenuLabels,
|
type WorkspaceTabMenuLabels,
|
||||||
} from "@/screens/workspace/workspace-tab-menu";
|
} from "@/screens/workspace/workspace-tab-menu";
|
||||||
import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests";
|
import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests";
|
||||||
@@ -249,12 +243,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|||||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
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 ThemedSquarePen = withUnistyles(SquarePen);
|
||||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||||
const ThemedGlobe = withUnistyles(Globe);
|
const ThemedGlobe = withUnistyles(Globe);
|
||||||
@@ -516,95 +504,10 @@ function WorkspaceDocumentTitleEffect({
|
|||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
|
|
||||||
function mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) {
|
|
||||||
return [
|
|
||||||
styles.mobileTabMenuTrigger,
|
|
||||||
(Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
|
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
|
||||||
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
|
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileTabTrailingAccessory({
|
|
||||||
menuTestIDBase,
|
|
||||||
presentationLabel,
|
|
||||||
menuEntries,
|
|
||||||
}: {
|
|
||||||
menuTestIDBase: string;
|
|
||||||
presentationLabel: string;
|
|
||||||
menuEntries: WorkspaceTabMenuEntry[];
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
testID={`${menuTestIDBase}-trigger`}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t("workspace.tabs.menu.openFor", { label: presentationLabel })}
|
|
||||||
hitSlop={8}
|
|
||||||
style={mobileTabMenuTriggerStyle}
|
|
||||||
>
|
|
||||||
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
|
|
||||||
{menuEntries.map((entry) =>
|
|
||||||
entry.kind === "separator" ? (
|
|
||||||
<DropdownMenuSeparator key={entry.key} />
|
|
||||||
) : (
|
|
||||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileTabDropdownMenuItem({
|
|
||||||
entry,
|
|
||||||
}: {
|
|
||||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
|
||||||
}) {
|
|
||||||
const leading = useMemo(() => {
|
|
||||||
switch (entry.icon) {
|
|
||||||
case "copy":
|
|
||||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
|
||||||
case "rotate-cw":
|
|
||||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
|
||||||
case "arrow-left-to-line":
|
|
||||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
|
||||||
case "arrow-right-to-line":
|
|
||||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
|
||||||
case "copy-x":
|
|
||||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
|
||||||
case "pencil":
|
|
||||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
|
||||||
case "x":
|
|
||||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}, [entry.icon]);
|
|
||||||
const trailing = useMemo(
|
|
||||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
|
||||||
[entry.hint],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={entry.testID}
|
|
||||||
disabled={entry.disabled}
|
|
||||||
destructive={entry.destructive}
|
|
||||||
onSelect={entry.onSelect}
|
|
||||||
tooltip={entry.tooltip}
|
|
||||||
leading={leading}
|
|
||||||
trailing={trailing}
|
|
||||||
>
|
|
||||||
{entry.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileWorkspaceTabOption({
|
function MobileWorkspaceTabOption({
|
||||||
tab,
|
tab,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
@@ -4170,20 +4073,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
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: {
|
headerMenuProfileIconWrapper: {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>) =>
|
||||||
|
({
|
||||||
|
uniProps,
|
||||||
|
...rest
|
||||||
|
}: { uniProps?: (theme: unknown) => Record<string, unknown> } & Record<string, unknown>) => {
|
||||||
|
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 <div>{children}</div>;
|
||||||
|
},
|
||||||
|
DropdownMenuTrigger: ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<button type="button" data-testid={testID}>
|
||||||
|
{typeof children === "function"
|
||||||
|
? children({ hovered: false, pressed: false, open: false })
|
||||||
|
: children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({
|
||||||
|
children,
|
||||||
|
sheetTitle,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sheetTitle?: string;
|
||||||
|
testID?: string;
|
||||||
|
}) => {
|
||||||
|
capturedContentProps.push({ sheetTitle, testID });
|
||||||
|
return <div data-testid={testID}>{children}</div>;
|
||||||
|
},
|
||||||
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<button type="button" data-testid={testID} onClick={onSelect}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<MobileTabTrailingAccessory
|
||||||
|
menuTestIDBase={base}
|
||||||
|
presentationLabel="My session"
|
||||||
|
menuEntries={menuEntries}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||||
|
}) {
|
||||||
|
const leading = useMemo(() => {
|
||||||
|
switch (entry.icon) {
|
||||||
|
case "copy":
|
||||||
|
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "rotate-cw":
|
||||||
|
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "arrow-left-to-line":
|
||||||
|
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "arrow-right-to-line":
|
||||||
|
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "copy-x":
|
||||||
|
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "pencil":
|
||||||
|
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "x":
|
||||||
|
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [entry.icon]);
|
||||||
|
const trailing = useMemo(
|
||||||
|
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||||
|
[entry.hint],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={entry.testID}
|
||||||
|
disabled={entry.disabled}
|
||||||
|
destructive={entry.destructive}
|
||||||
|
onSelect={entry.onSelect}
|
||||||
|
tooltip={entry.tooltip}
|
||||||
|
leading={leading}
|
||||||
|
trailing={trailing}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileTabTrailingAccessory({
|
||||||
|
menuTestIDBase,
|
||||||
|
presentationLabel,
|
||||||
|
menuEntries,
|
||||||
|
}: {
|
||||||
|
menuTestIDBase: string;
|
||||||
|
presentationLabel: string;
|
||||||
|
menuEntries: WorkspaceTabMenuEntry[];
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<DropdownMenu compactMode="sheet">
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID={`${menuTestIDBase}-trigger`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("workspace.tabs.menu.openFor", { label: presentationLabel })}
|
||||||
|
hitSlop={8}
|
||||||
|
style={mobileTabMenuTriggerStyle}
|
||||||
|
>
|
||||||
|
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
width={220}
|
||||||
|
sheetTitle={presentationLabel}
|
||||||
|
testID={menuTestIDBase}
|
||||||
|
>
|
||||||
|
{menuEntries.map((entry) =>
|
||||||
|
entry.kind === "separator" ? (
|
||||||
|
<DropdownMenuSeparator key={entry.key} />
|
||||||
|
) : (
|
||||||
|
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user