Open the tab chooser beside the pointer (#3735)

* feat(app): open the tab chooser beside the pointer

Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.

* test(app): follow the anchored tab chooser

The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.
This commit is contained in:
Mohamed Boudra
2026-08-23 13:08:30 +02:00
committed by GitHub
parent 4094eeb566
commit 2124115c8e
14 changed files with 535 additions and 361 deletions
+21 -5
View File
@@ -137,6 +137,18 @@ test.describe("Tab creation", () => {
).toBeVisible();
});
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "new_tab");
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
});
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
@@ -218,7 +230,11 @@ test.describe("Tab creation", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByRole("button", { name: EMPTY_PROMPT_PROFILE.name }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
.click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
@@ -230,10 +246,10 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
await expect(panel.getByText("Terminal profiles", { exact: true })).toBeVisible();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
const editProfiles = panel.getByTestId("workspace-new-tab-edit-terminal-profiles");
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
await editProfiles.click();
@@ -363,7 +379,7 @@ test.describe("Tab transitions (no flash)", () => {
}) => {
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
await isolatedWorkspace.navigateTo();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await pressNewTabShortcut(page);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
+5 -1
View File
@@ -218,7 +218,11 @@ test.describe("Side panel", () => {
await capture(page, testInfo, "08-changes-in-side-panel");
await mainPane(page).getByTestId("workspace-new-tab-button").click();
await page.getByTestId("workspace-new-tab-changes").filter({ visible: true }).first().click();
await page
.getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true })
.first()
.click();
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 15_000,
@@ -60,7 +60,11 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
// main pane, so an unscoped testid locator matches both; scope to the
// visible one.
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first().click();
await page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first()
.click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
+6 -3
View File
@@ -96,17 +96,20 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void>
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Create a New tab and choose Agent to create a draft/chat tab. */
/** Choose Agent from the pane-local `+` menu. */
export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page);
}
/** Create a New tab and choose Terminal. */
/** Choose Terminal from the pane-local `+` menu. */
export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first();
const item = page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
@@ -88,16 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
*/
async function openSidePanelView(
page: Page,
view: { tabTestId: string; launcherTestId: string; contentTestId: string; timeout?: number },
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
const launcher = panel.getByTestId("workspace-new-tab-panel");
if ((await launcher.count()) === 0) {
await panel.getByTestId("workspace-new-tab-button").click();
}
await panel.getByTestId(view.launcherTestId).click();
await panel.getByTestId("workspace-new-tab-button").click();
await page
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
.filter({ visible: true })
.click();
}
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
@@ -108,7 +108,7 @@ async function openSidePanelView(
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
launcherTestId: "workspace-new-tab-changes",
launchItemId: "changes",
contentTestId: "working-diff-panel",
});
}
@@ -116,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
launcherTestId: "workspace-new-tab-files",
launchItemId: "files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -130,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
launcherTestId: "workspace-new-tab-pull-request",
launchItemId: "pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
@@ -145,12 +145,12 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
});
}
/** Create a New tab from `+` and pick Agent in its launcher. */
/** Open the pane-local `+` menu and pick Agent. */
export async function createAgentTabFromMenu(page: Page): Promise<void> {
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = visibleTestId(page, "workspace-new-tab-agent").first();
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
@@ -1,8 +0,0 @@
export type LauncherAction = "agent" | "terminal" | "changes" | "files" | "browser" | "pullRequest";
export function getLauncherActionOrder(isSidePanel: boolean): LauncherAction[] {
if (isSidePanel) {
return ["changes", "files", "terminal", "agent", "browser", "pullRequest"];
}
return ["agent", "terminal", "changes", "files", "browser", "pullRequest"];
}
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { getLauncherActionOrder } from "@/panels/new-tab-action-order";
describe("getLauncherActionOrder", () => {
it("leads with the work companion tools in the Side panel", () => {
expect(getLauncherActionOrder(true)).toEqual([
"changes",
"files",
"terminal",
"agent",
"browser",
"pullRequest",
]);
});
it("leads with creating work in an ordinary pane", () => {
expect(getLauncherActionOrder(false)).toEqual([
"agent",
"terminal",
"changes",
"files",
"browser",
"pullRequest",
]);
});
});
+45 -273
View File
@@ -1,72 +1,21 @@
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactElement,
type ReactNode,
} from "react";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactElement } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
Globe,
Pencil,
Plus,
SquarePen,
SquareTerminal,
type LucideIcon,
} from "lucide-react-native";
import { useRouter, type Href } from "expo-router";
import { Pencil, Plus, type LucideIcon } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { getLauncherActionOrder, type LauncherAction } from "@/panels/new-tab-action-order";
import type { PanelRegistration } from "@/panels/panel-registry";
import { resolvePluginIcon } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin } from "@/plugins/types";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
export interface NewTabLauncher {
showChanges: boolean;
showPullRequest: boolean;
showBrowser: boolean;
terminalDisabled: boolean;
activate: (tabId: string, selection: NewTabSelection) => void;
}
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
export function NewTabLauncherProvider({
value,
children,
}: {
value: NewTabLauncher;
children: ReactNode;
}) {
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
}
useWorkspaceTabLaunchCatalog,
type WorkspaceTabLaunchItem,
} from "@/workspace-tabs/launcher";
const ThemedPlus = withUnistyles(Plus);
const ThemedPencil = withUnistyles(Pencil);
@@ -80,29 +29,6 @@ const LAUNCHER_MAX_WIDTH = 380;
const EDIT_PROFILES_HIT_SIZE = ICON_SIZE.xs + SPACING[2];
const ROW_DATA_SET = { newTabLauncherRow: "true" };
const ROW_SELECTOR = '[data-new-tab-launcher-row="true"]';
const CHANGES_SELECTION: NewTabSelection = {
kind: "target",
target: { kind: "working_diff" },
};
const FILES_SELECTION: NewTabSelection = { kind: "target", target: { kind: "files" } };
const PULL_REQUEST_SELECTION: NewTabSelection = {
kind: "target",
target: { kind: "pull_request" },
};
const AGENT_SELECTION: NewTabSelection = { kind: "agent" };
const TERMINAL_SELECTION: NewTabSelection = { kind: "terminal" };
const BROWSER_SELECTION: NewTabSelection = { kind: "browser" };
interface LauncherActionDefinition {
label: string;
Icon: LucideIcon;
disabled?: boolean;
hidden?: boolean;
selection: NewTabSelection;
shortcut?: string;
testID: string;
}
function LauncherIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={LAUNCHER_ICON_SIZE} color={color} />;
}
@@ -147,109 +73,35 @@ function EditProfilesButton({ label, onPress }: { label: string; onPress: () =>
);
}
function LauncherRow({
label,
Icon,
terminalIconKey,
disabled,
selection,
shortcut,
testID,
}: {
label: string;
Icon?: LucideIcon;
terminalIconKey?: string;
disabled?: boolean;
selection: NewTabSelection;
shortcut?: string;
testID: string;
}) {
function LauncherRow({ item }: { item: WorkspaceTabLaunchItem }) {
const { tabId } = usePaneContext();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const handlePress = useCallback(() => {
launcher.activate(tabId, selection);
}, [launcher, selection, tabId]);
item.launch({ kind: "replace", tabId });
}, [item, tabId]);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityLabel={item.label}
dataSet={ROW_DATA_SET}
disabled={disabled}
disabled={item.disabled}
onPress={handlePress}
style={rowStyle}
tabIndex={-1}
testID={testID}
testID={`workspace-new-tab-${item.id}`}
>
{Icon ? <ThemedLauncherIcon Icon={Icon} uniProps={mutedColorMapping} /> : null}
{terminalIconKey ? (
<TerminalProfileIcon iconKey={terminalIconKey} size={LAUNCHER_ICON_SIZE} />
{item.Icon ? <ThemedLauncherIcon Icon={item.Icon} uniProps={mutedColorMapping} /> : null}
{item.terminalIconKey ? (
<TerminalProfileIcon iconKey={item.terminalIconKey} size={LAUNCHER_ICON_SIZE} />
) : null}
<Text numberOfLines={1} style={styles.rowLabel}>
{label}
{item.label}
</Text>
{shortcut ? <LauncherShortcut actionId={shortcut} /> : null}
{item.shortcutActionId ? <LauncherShortcut actionId={item.shortcutActionId} /> : null}
</Pressable>
);
}
function TerminalProfileLauncherRow({
profile,
disabled,
}: {
profile: TerminalProfile;
disabled: boolean;
}) {
const selection = useMemo<NewTabSelection>(() => ({ kind: "terminal", profile }), [profile]);
return (
<LauncherRow
label={profile.name}
terminalIconKey={getTerminalProfileIcon(profile)}
disabled={disabled}
selection={selection}
testID={`workspace-new-tab-terminal-profile-${profile.id}`}
/>
);
}
function PluginLauncherRow({
pluginId,
panel,
}: {
pluginId: string;
panel: PluginWorkspacePanelContribution;
}) {
const selection = useMemo<NewTabSelection>(
() => ({
kind: "target",
target: { kind: "plugin", pluginId, panelId: panel.id, context: "workspace" },
}),
[panel.id, pluginId],
);
return (
<LauncherRow
label={panel.title}
Icon={resolvePluginIcon(panel.icon)}
selection={selection}
testID={`workspace-new-tab-plugin-${pluginId}-${panel.id}`}
/>
);
}
function collectWorkspacePluginPanels(plugins: InstalledPlugin[], serverId: string) {
const result: Array<{ pluginId: string; panel: PluginWorkspacePanelContribution }> = [];
for (const plugin of plugins) {
if (plugin.serverId !== serverId) continue;
for (const panel of plugin.workspacePanels) {
if (panel.context === "workspace") {
result.push({ pluginId: plugin.id, panel });
}
}
}
return result;
}
function useNewTabDescriptor() {
const { t } = useTranslation();
const label = t("workspace.tabs.actions.newTab");
@@ -264,73 +116,17 @@ function useNewTabDescriptor() {
}
const NewTabPanel = memo(function NewTabPanel(): ReactElement {
const { t } = useTranslation();
const router = useRouter();
const { isSidePanel, serverId, tabId } = usePaneContext();
const { isInteractive, focusPane } = usePaneFocus();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const { config } = useDaemonConfig(serverId);
const plugins = useInstalledPlugins();
const containerRef = useRef<View | null>(null);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
const groups = useWorkspaceTabLaunchCatalog({
serverId,
purpose: isSidePanel ? "supporting" : "primary",
});
const itemsById = useMemo(
() => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])),
[groups],
);
const pluginPanels = useMemo(
() => collectWorkspacePluginPanels(plugins, serverId),
[plugins, serverId],
);
const editTerminalProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href);
}, [router, serverId]);
const launcherActions: Record<LauncherAction, LauncherActionDefinition> = {
agent: {
label: t("workspace.tabs.fallback.agent"),
Icon: SquarePen,
selection: AGENT_SELECTION,
shortcut: "workspace-tab-target-agent",
testID: "workspace-new-tab-agent",
},
terminal: {
label: t("workspace.tabs.fallback.terminal"),
Icon: SquareTerminal,
disabled: launcher.terminalDisabled,
selection: TERMINAL_SELECTION,
shortcut: "workspace-terminal-new",
testID: "workspace-new-tab-terminal",
},
changes: {
label: t("workspace.tabs.actions.changes"),
Icon: FileDiff,
hidden: !launcher.showChanges,
selection: CHANGES_SELECTION,
shortcut: "workspace-tab-target-changes",
testID: "workspace-new-tab-changes",
},
files: {
label: t("workspace.tabs.actions.files"),
Icon: FolderTree,
selection: FILES_SELECTION,
shortcut: "workspace-tab-target-files",
testID: "workspace-new-tab-files",
},
browser: {
label: t("workspace.tabs.fallback.browser"),
Icon: Globe,
hidden: !launcher.showBrowser,
selection: BROWSER_SELECTION,
shortcut: "workspace-tab-target-browser",
testID: "workspace-new-tab-browser",
},
pullRequest: {
label: t("workspace.tabs.actions.pullRequest"),
Icon: GitPullRequest,
hidden: !launcher.showPullRequest,
selection: PULL_REQUEST_SELECTION,
testID: "workspace-new-tab-pull-request",
},
};
useEffect(() => {
if (!isWeb || !isInteractive) return;
@@ -383,39 +179,31 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement {
};
}, [focusPane, isInteractive]);
const activate = useCallback(
(selection: NewTabSelection) => launcher.activate(tabId, selection),
[launcher, tabId],
);
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "workspace.agent.new" || action.id === "workspace.tab.target.agent") {
activate({ kind: "agent" });
itemsById.get("agent")?.launch({ kind: "replace", tabId });
return true;
}
if (action.id === "workspace.terminal.new") {
activate({ kind: "terminal" });
itemsById.get("terminal")?.launch({ kind: "replace", tabId });
return true;
}
if (action.id === "workspace.browser.new" || action.id === "workspace.tab.target.browser") {
if (launcher.showBrowser) {
activate({ kind: "browser" });
}
itemsById.get("browser")?.launch({ kind: "replace", tabId });
return true;
}
if (action.id === "workspace.tab.target.changes") {
if (launcher.showChanges) {
activate({ kind: "target", target: { kind: "working_diff" } });
}
itemsById.get("changes")?.launch({ kind: "replace", tabId });
return true;
}
if (action.id === "workspace.tab.target.files") {
activate({ kind: "target", target: { kind: "files" } });
itemsById.get("files")?.launch({ kind: "replace", tabId });
return true;
}
return false;
},
[activate, launcher.showBrowser, launcher.showChanges],
[itemsById, tabId],
);
useKeyboardActionHandler({
handlerId: `new-tab:${tabId}`,
@@ -437,40 +225,24 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement {
<View ref={containerRef} style={styles.container} testID="workspace-new-tab-panel">
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.rail}>
<View style={styles.group}>
{getLauncherActionOrder(isSidePanel).map((actionId) => {
const action = launcherActions[actionId];
if (action.hidden) return null;
return <LauncherRow key={actionId} {...action} />;
})}
{pluginPanels.map(({ pluginId, panel }) => (
<PluginLauncherRow
key={`${pluginId}:${panel.id}`}
pluginId={pluginId}
panel={panel}
/>
))}
</View>
{profiles.length > 0 ? (
<View style={styles.group}>
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>
{t("workspace.tabs.actions.terminalProfilesMenu")}
</Text>
<EditProfilesButton
label={t("workspace.tabs.actions.editTerminalProfiles")}
onPress={editTerminalProfiles}
/>
</View>
{profiles.map((profile) => (
<TerminalProfileLauncherRow
key={profile.id}
disabled={launcher.terminalDisabled}
profile={profile}
/>
{groups.map((group) => (
<View key={group.id} style={styles.group}>
{group.label ? (
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>{group.label}</Text>
{group.accessory ? (
<EditProfilesButton
label={group.accessory.label}
onPress={group.accessory.run}
/>
) : null}
</View>
) : null}
{group.items.map((item) => (
<LauncherRow key={item.id} item={item} />
))}
</View>
) : null}
))}
</View>
</ScrollView>
</View>
@@ -51,6 +51,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -79,6 +80,8 @@ import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
import { SIDE_PANEL_PANE_ID } from "@/stores/workspace-layout-actions";
const DROPDOWN_WIDTH = 220;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
@@ -217,36 +220,48 @@ function TabLabelMeasurement({
}
interface WorkspaceNewTabButtonProps {
serverId: string;
paneId?: string;
shortcutKeys: ShortcutKey[][] | null;
onPress: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceNewTabButton({ shortcutKeys, onPress, onLayout }: WorkspaceNewTabButtonProps) {
function WorkspaceNewTabButton({
serverId,
paneId,
shortcutKeys,
onLayout,
}: WorkspaceNewTabButtonProps) {
const { t } = useTranslation();
const tooltipText = t("workspace.tabs.actions.newTab");
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<Pressable
testID="workspace-new-tab-button"
accessibilityRole="button"
accessibilityLabel={tooltipText}
onPress={onPress}
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger
testID="workspace-new-tab-button"
accessibilityRole="button"
accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<WorkspaceNewTabMenuContent
serverId={serverId}
purpose={paneId === SIDE_PANEL_PANE_ID ? "supporting" : "primary"}
paneId={paneId}
/>
</DropdownMenu>
</View>
);
}
@@ -1347,8 +1362,9 @@ function ResolvedWorkspaceDesktopTabsRow({
/>
{!layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
serverId={normalizedServerId}
paneId={paneId}
shortcutKeys={newTabKeys}
onPress={createNewTab}
onLayout={handleInlineAddButtonLayout}
/>
) : null}
@@ -1361,8 +1377,9 @@ function ResolvedWorkspaceDesktopTabsRow({
</View>
{layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
serverId={normalizedServerId}
paneId={paneId}
shortcutKeys={newTabKeys}
onPress={createNewTab}
onLayout={handleInlineAddButtonLayout}
/>
) : null}
@@ -0,0 +1,117 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { Theme } from "@/styles/theme";
import {
useWorkspaceTabLaunchCatalog,
type WorkspaceTabLaunchItem,
type WorkspaceTabLaunchPurpose,
} from "@/workspace-tabs/launcher";
import type { LucideIcon } from "lucide-react-native";
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function LaunchItemIconGlyph({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={14} color={color} />;
}
const ThemedLaunchItemIconGlyph = withUnistyles(LaunchItemIconGlyph);
function LaunchItemIcon({ item }: { item: WorkspaceTabLaunchItem }): ReactElement | null {
if (item.Icon) {
return <ThemedLaunchItemIconGlyph Icon={item.Icon} uniProps={mutedColorMapping} />;
}
if (item.terminalIconKey) {
return (
<View>
<TerminalProfileIcon iconKey={item.terminalIconKey} size={14} />
</View>
);
}
return null;
}
function WorkspaceNewTabMenuItem({
item,
paneId,
}: {
item: WorkspaceTabLaunchItem;
paneId?: string;
}) {
const leading = useMemo(() => <LaunchItemIcon item={item} />, [item]);
const trailing = useMemo(
() =>
item.shortcutActionId ? <LaunchItemShortcut actionId={item.shortcutActionId} /> : undefined,
[item.shortcutActionId],
);
const handleSelect = useCallback(() => item.launch({ kind: "open", paneId }), [item, paneId]);
return (
<DropdownMenuItem
testID={`workspace-new-tab-menu-${item.id}`}
leading={leading}
trailing={trailing}
disabled={item.disabled}
onSelect={handleSelect}
>
{item.label}
</DropdownMenuItem>
);
}
function LaunchItemShortcut({ actionId }: { actionId: string }) {
const keys = useShortcutKeys(actionId);
return keys ? <Shortcut chord={keys} /> : null;
}
export function WorkspaceNewTabMenuContent({
serverId,
purpose,
paneId,
}: {
serverId: string;
purpose: WorkspaceTabLaunchPurpose;
paneId?: string;
}) {
const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose });
return (
<DropdownMenuContent
side="bottom"
align="start"
offset={4}
minWidth={200}
testID="workspace-new-tab-menu"
>
{groups.map((group, index) => (
<View key={group.id}>
{index > 0 ? <DropdownMenuSeparator /> : null}
{group.label ? <DropdownMenuLabel>{group.label}</DropdownMenuLabel> : null}
{group.items.map((item) => (
<WorkspaceNewTabMenuItem key={item.id} item={item} paneId={paneId} />
))}
{group.accessory ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
testID={`workspace-new-tab-menu-${group.accessory.id}`}
onSelect={group.accessory.run}
>
{group.accessory.label}
</DropdownMenuItem>
</>
) : null}
</View>
))}
</DropdownMenuContent>
);
}
@@ -156,8 +156,12 @@ import {
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { WorkspaceFocusProvider } from "@/workspace/focus";
import { NewTabLauncherProvider, type NewTabLauncher } from "@/panels/new-tab-panel";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
import {
NewTabLauncherProvider,
type NewTabLauncher,
type WorkspaceTabLaunchDestination,
} from "@/workspace-tabs/launcher";
import type { TerminalTabDestination } from "@/screens/workspace/terminals/use-workspace-terminals";
import {
buildBulkCloseConfirmationMessage,
@@ -2314,17 +2318,24 @@ function WorkspaceScreenContent({
[createWorkspaceTab, persistenceKey],
);
const activateNewTab = useCallback(
(tabId: string, selection: NewTabSelection) => {
const launchWorkspaceTab = useCallback(
(selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => {
if (!persistenceKey) {
return;
}
const openTarget = (target: WorkspaceTab["target"]) => {
if (destination.kind === "replace") {
replaceWorkspaceTabTarget(persistenceKey, destination.tabId, target);
} else {
createWorkspaceTab(persistenceKey, target, paneLocalPlacement(destination.paneId));
}
};
if (selection.kind === "target") {
replaceWorkspaceTabTarget(persistenceKey, tabId, selection.target);
openTarget(selection.target);
return;
}
if (selection.kind === "agent") {
replaceWorkspaceTabTarget(persistenceKey, tabId, {
openTarget({
kind: "draft",
draftId: generateDraftId(),
});
@@ -2333,14 +2344,14 @@ function WorkspaceScreenContent({
if (selection.kind === "terminal") {
createTerminal({
profile: selection.profile,
destination: { kind: "replace", tabId },
destination,
});
return;
}
const { browserId } = createWorkspaceBrowser();
replaceWorkspaceTabTarget(persistenceKey, tabId, { kind: "browser", browserId });
openTarget({ kind: "browser", browserId });
},
[createTerminal, persistenceKey, replaceWorkspaceTabTarget],
[createTerminal, createWorkspaceTab, persistenceKey, replaceWorkspaceTabTarget],
);
const handleOpenUrlInBrowserTab = useCallback(
@@ -3679,9 +3690,15 @@ function WorkspaceScreenContent({
showPullRequest: hasPullRequest,
showBrowser: showCreateBrowserTab,
terminalDisabled: createTerminalDisabled,
activate: activateNewTab,
launch: launchWorkspaceTab,
}),
[activateNewTab, createTerminalDisabled, hasPullRequest, isGitCheckout, showCreateBrowserTab],
[
createTerminalDisabled,
hasPullRequest,
isGitCheckout,
launchWorkspaceTab,
showCreateBrowserTab,
],
);
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
const desktopFocusModeEnabled = useMemo(
@@ -0,0 +1,209 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { useRouter, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
Globe,
SquarePen,
SquareTerminal,
type LucideIcon,
} from "lucide-react-native";
import invariant from "tiny-invariant";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { resolvePluginIcon } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { getBuiltInLaunchOrder, type BuiltInLaunchItemId } from "./internal/catalog";
export type WorkspaceTabLaunchPurpose = "primary" | "supporting";
export type WorkspaceTabLaunchDestination =
| { kind: "open"; paneId?: string }
| { kind: "replace"; tabId: string };
export interface NewTabLauncher {
showChanges: boolean;
showPullRequest: boolean;
showBrowser: boolean;
terminalDisabled: boolean;
launch: (selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => void;
}
export interface WorkspaceTabLaunchItem {
id: string;
label: string;
Icon?: LucideIcon;
terminalIconKey?: string;
shortcutActionId?: string;
disabled: boolean;
launch: (destination: WorkspaceTabLaunchDestination) => void;
}
export interface WorkspaceTabLaunchGroup {
id: "tabs" | "terminal-profiles";
label: string | null;
items: readonly WorkspaceTabLaunchItem[];
accessory?: { id: string; label: string; run: () => void };
}
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
export function NewTabLauncherProvider({
value,
children,
}: {
value: NewTabLauncher;
children: ReactNode;
}) {
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
}
const BUILT_IN_SELECTIONS: Record<BuiltInLaunchItemId, NewTabSelection> = {
agent: { kind: "agent" },
terminal: { kind: "terminal" },
changes: { kind: "target", target: { kind: "working_diff" } },
files: { kind: "target", target: { kind: "files" } },
browser: { kind: "browser" },
pullRequest: { kind: "target", target: { kind: "pull_request" } },
};
export function useWorkspaceTabLaunchCatalog(input: {
serverId: string;
purpose: WorkspaceTabLaunchPurpose;
}): readonly WorkspaceTabLaunchGroup[] {
const { serverId, purpose } = input;
const { t } = useTranslation();
const router = useRouter();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const { config } = useDaemonConfig(serverId);
const plugins = useInstalledPlugins();
const launchSelection = useCallback(
(selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => {
launcher.launch(selection, destination);
},
[launcher],
);
const editTerminalProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href);
}, [router, serverId]);
return useMemo(() => {
const builtIns: Record<BuiltInLaunchItemId, WorkspaceTabLaunchItem & { hidden?: boolean }> = {
agent: {
id: "agent",
label: t("workspace.tabs.fallback.agent"),
Icon: SquarePen,
shortcutActionId: "workspace-tab-target-agent",
disabled: false,
launch: launchSelection(BUILT_IN_SELECTIONS.agent),
},
terminal: {
id: "terminal",
label: t("workspace.tabs.fallback.terminal"),
Icon: SquareTerminal,
shortcutActionId: "workspace-terminal-new",
disabled: launcher.terminalDisabled,
launch: launchSelection(BUILT_IN_SELECTIONS.terminal),
},
changes: {
id: "changes",
label: t("workspace.tabs.actions.changes"),
Icon: FileDiff,
shortcutActionId: "workspace-tab-target-changes",
disabled: false,
hidden: !launcher.showChanges,
launch: launchSelection(BUILT_IN_SELECTIONS.changes),
},
files: {
id: "files",
label: t("workspace.tabs.actions.files"),
Icon: FolderTree,
shortcutActionId: "workspace-tab-target-files",
disabled: false,
launch: launchSelection(BUILT_IN_SELECTIONS.files),
},
browser: {
id: "browser",
label: t("workspace.tabs.fallback.browser"),
Icon: Globe,
shortcutActionId: "workspace-tab-target-browser",
disabled: false,
hidden: !launcher.showBrowser,
launch: launchSelection(BUILT_IN_SELECTIONS.browser),
},
pullRequest: {
id: "pull-request",
label: t("workspace.tabs.actions.pullRequest"),
Icon: GitPullRequest,
disabled: false,
hidden: !launcher.showPullRequest,
launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest),
},
};
const tabItems = getBuiltInLaunchOrder(purpose).flatMap((id) => {
const item = builtIns[id];
return item.hidden ? [] : [item];
});
for (const plugin of plugins) {
if (plugin.serverId !== serverId) continue;
for (const panel of plugin.workspacePanels) {
if (panel.context !== "workspace") continue;
const selection: NewTabSelection = {
kind: "target",
target: { kind: "plugin", pluginId: plugin.id, panelId: panel.id, context: "workspace" },
};
tabItems.push({
id: `plugin:${plugin.id}:${panel.id}`,
label: panel.title,
Icon: resolvePluginIcon(panel.icon),
disabled: false,
launch: launchSelection(selection),
});
}
}
const profiles = resolveTerminalProfiles(config?.terminalProfiles);
const groups: WorkspaceTabLaunchGroup[] = [{ id: "tabs", label: null, items: tabItems }];
if (profiles.length > 0) {
groups.push({
id: "terminal-profiles",
label: t("workspace.tabs.actions.terminalProfilesMenu"),
items: profiles.map((profile: TerminalProfile) => ({
id: `terminal-profile:${profile.id}`,
label: profile.name,
terminalIconKey: getTerminalProfileIcon(profile),
disabled: launcher.terminalDisabled,
launch: launchSelection({ kind: "terminal", profile }),
})),
accessory: {
id: "edit-terminal-profiles",
label: t("workspace.tabs.actions.editTerminalProfiles"),
run: editTerminalProfiles,
},
});
}
return groups;
}, [
config?.terminalProfiles,
editTerminalProfiles,
launchSelection,
launcher,
plugins,
purpose,
serverId,
t,
]);
}
export { getBuiltInLaunchOrder } from "./internal/catalog";
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { getBuiltInLaunchOrder } from "./catalog";
describe("getBuiltInLaunchOrder", () => {
it("leads with creating work in a primary pane", () => {
expect(getBuiltInLaunchOrder("primary")).toEqual([
"agent",
"terminal",
"changes",
"files",
"browser",
"pullRequest",
]);
});
it("leads with companion tools in a supporting pane", () => {
expect(getBuiltInLaunchOrder("supporting")).toEqual([
"changes",
"files",
"terminal",
"agent",
"browser",
"pullRequest",
]);
});
});
@@ -0,0 +1,23 @@
export const PRIMARY_LAUNCH_ORDER = [
"agent",
"terminal",
"changes",
"files",
"browser",
"pullRequest",
] as const;
export const SUPPORTING_LAUNCH_ORDER = [
"changes",
"files",
"terminal",
"agent",
"browser",
"pullRequest",
] as const;
export type BuiltInLaunchItemId = (typeof PRIMARY_LAUNCH_ORDER)[number];
export function getBuiltInLaunchOrder(purpose: "primary" | "supporting") {
return purpose === "supporting" ? SUPPORTING_LAUNCH_ORDER : PRIMARY_LAUNCH_ORDER;
}