diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index f4c9093f37..b4c150cd98 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/side-panel.spec.ts b/packages/app/e2e/browser/side-panel.spec.ts index 879ec5b677..e46a709acd 100644 --- a/packages/app/e2e/browser/side-panel.spec.ts +++ b/packages/app/e2e/browser/side-panel.spec.ts @@ -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, diff --git a/packages/app/e2e/browser/terminal-stuck-size.spec.ts b/packages/app/e2e/browser/terminal-stuck-size.spec.ts index ab406e93bb..048b92dc09 100644 --- a/packages/app/e2e/browser/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/browser/terminal-stuck-size.spec.ts @@ -60,7 +60,11 @@ async function createTerminalViaMenu(page: Page): Promise { // 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 { diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index bb300268ce..cf0c42c3fb 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -96,17 +96,20 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise // ─── 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 { await createAgentTabFromMenu(page); } -/** Create a New tab and choose Terminal. */ +/** Choose Terminal from the pane-local `+` menu. */ export async function clickNewTerminal(page: Page): Promise { 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(); } diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 4f9b64de0c..cdebe3af3e 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -88,16 +88,16 @@ export async function ensureSidePanel(page: Page): Promise { */ async function openSidePanelView( page: Page, - view: { tabTestId: string; launcherTestId: string; contentTestId: string; timeout?: number }, + view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number }, ): Promise { 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 { 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 { export async function openFilesPanel(page: Page): Promise { 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 { } 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 { }); } -/** 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 { 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(); } diff --git a/packages/app/src/panels/new-tab-action-order.ts b/packages/app/src/panels/new-tab-action-order.ts deleted file mode 100644 index 848c01504c..0000000000 --- a/packages/app/src/panels/new-tab-action-order.ts +++ /dev/null @@ -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"]; -} diff --git a/packages/app/src/panels/new-tab-panel.test.ts b/packages/app/src/panels/new-tab-panel.test.ts deleted file mode 100644 index ec0f50aae8..0000000000 --- a/packages/app/src/panels/new-tab-panel.test.ts +++ /dev/null @@ -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", - ]); - }); -}); diff --git a/packages/app/src/panels/new-tab-panel.tsx b/packages/app/src/panels/new-tab-panel.tsx index 8cbc93277b..5faed42ae9 100644 --- a/packages/app/src/panels/new-tab-panel.tsx +++ b/packages/app/src/panels/new-tab-panel.tsx @@ -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(null); - -export function NewTabLauncherProvider({ - value, - children, -}: { - value: NewTabLauncher; - children: ReactNode; -}) { - return {children}; -} + 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 ; } @@ -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 ( - {Icon ? : null} - {terminalIconKey ? ( - + {item.Icon ? : null} + {item.terminalIconKey ? ( + ) : null} - {label} + {item.label} - {shortcut ? : null} + {item.shortcutActionId ? : null} ); } -function TerminalProfileLauncherRow({ - profile, - disabled, -}: { - profile: TerminalProfile; - disabled: boolean; -}) { - const selection = useMemo(() => ({ kind: "terminal", profile }), [profile]); - return ( - - ); -} - -function PluginLauncherRow({ - pluginId, - panel, -}: { - pluginId: string; - panel: PluginWorkspacePanelContribution; -}) { - const selection = useMemo( - () => ({ - kind: "target", - target: { kind: "plugin", pluginId, panelId: panel.id, context: "workspace" }, - }), - [panel.id, pluginId], - ); - return ( - - ); -} - -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(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 = { - 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 { - - {getLauncherActionOrder(isSidePanel).map((actionId) => { - const action = launcherActions[actionId]; - if (action.hidden) return null; - return ; - })} - {pluginPanels.map(({ pluginId, panel }) => ( - - ))} - - {profiles.length > 0 ? ( - - - - {t("workspace.tabs.actions.terminalProfilesMenu")} - - - - {profiles.map((profile) => ( - + {groups.map((group) => ( + + {group.label ? ( + + {group.label} + {group.accessory ? ( + + ) : null} + + ) : null} + {group.items.map((item) => ( + ))} - ) : null} + ))} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index b2f1dd66a7..bfe922b2a2 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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 ( - - - - - - - - - {tooltipText} - {shortcutKeys ? : null} - - - + + + + + + + + + + {tooltipText} + {shortcutKeys ? : null} + + + + + ); } @@ -1347,8 +1362,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( ) : null} @@ -1361,8 +1377,9 @@ function ResolvedWorkspaceDesktopTabsRow({ {layout.requiresHorizontalScrollFallback ? ( ) : null} diff --git a/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx new file mode 100644 index 0000000000..da2d2d7fe7 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx @@ -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 ; +} + +const ThemedLaunchItemIconGlyph = withUnistyles(LaunchItemIconGlyph); + +function LaunchItemIcon({ item }: { item: WorkspaceTabLaunchItem }): ReactElement | null { + if (item.Icon) { + return ; + } + if (item.terminalIconKey) { + return ( + + + + ); + } + return null; +} + +function WorkspaceNewTabMenuItem({ + item, + paneId, +}: { + item: WorkspaceTabLaunchItem; + paneId?: string; +}) { + const leading = useMemo(() => , [item]); + const trailing = useMemo( + () => + item.shortcutActionId ? : undefined, + [item.shortcutActionId], + ); + const handleSelect = useCallback(() => item.launch({ kind: "open", paneId }), [item, paneId]); + + return ( + + {item.label} + + ); +} + +function LaunchItemShortcut({ actionId }: { actionId: string }) { + const keys = useShortcutKeys(actionId); + return keys ? : null; +} + +export function WorkspaceNewTabMenuContent({ + serverId, + purpose, + paneId, +}: { + serverId: string; + purpose: WorkspaceTabLaunchPurpose; + paneId?: string; +}) { + const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose }); + + return ( + + {groups.map((group, index) => ( + + {index > 0 ? : null} + {group.label ? {group.label} : null} + {group.items.map((item) => ( + + ))} + {group.accessory ? ( + <> + + + {group.accessory.label} + + + ) : null} + + ))} + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d83c28240b..dfe1948b40 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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( diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx new file mode 100644 index 0000000000..402c8554cd --- /dev/null +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -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(null); + +export function NewTabLauncherProvider({ + value, + children, +}: { + value: NewTabLauncher; + children: ReactNode; +}) { + return {children}; +} + +const BUILT_IN_SELECTIONS: Record = { + 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 = { + 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"; diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts new file mode 100644 index 0000000000..420ffdccfb --- /dev/null +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts new file mode 100644 index 0000000000..a2d55dbb90 --- /dev/null +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts @@ -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; +}