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:
Kevin Aspesi
2026-08-08 21:12:16 +00:00
committed by GitHub
co-authored by Klaude Mohamed Boudra
parent 1e0ce69c54
commit 49b257c55a
4 changed files with 451 additions and 112 deletions
@@ -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 { 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 (
<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({
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,
@@ -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,
},
}));