diff --git a/packages/app/e2e/helpers/pins.ts b/packages/app/e2e/helpers/pins.ts new file mode 100644 index 000000000..a4846ab6c --- /dev/null +++ b/packages/app/e2e/helpers/pins.ts @@ -0,0 +1,46 @@ +import { expect, type Page } from "@playwright/test"; +import type { PinnedTabTarget } from "../../src/workspace-pins/target"; +import { pinnedTargetKey } from "../../src/workspace-pins/target"; + +// The new-tab dropdown menu items carry no stable web ARIA role for the inline +// pin toggle (it lives inside a reveal-on-hover slot), so the pins flow is +// addressed through the test ids the feature assigns per target key — the same +// escape-hatch convention the sidebar kebab and tab context menus use. + +function pinToggle(page: Page, target: PinnedTabTarget) { + return page.getByTestId(`workspace-pin-toggle-${pinnedTargetKey(target)}`); +} + +function menuItemFor(page: Page, target: PinnedTabTarget) { + return page.getByTestId(`workspace-new-tab-menu-${target.kind}`); +} + +export function tabRowPin(page: Page, target: PinnedTabTarget) { + return page.getByTestId(`workspace-pinned-target-${pinnedTargetKey(target)}`); +} + +export function sidebarPin(page: Page, target: PinnedTabTarget) { + return page.getByTestId(`sidebar-pinned-target-${pinnedTargetKey(target)}`); +} + +export async function openNewTabMenu(page: Page): Promise { + const trigger = page + .getByTestId("workspace-new-tab-menu-trigger") + .filter({ visible: true }) + .first(); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await trigger.click(); +} + +// The pin toggle is hidden behind reveal-on-hover (opacity + pointerEvents) on +// desktop web, so the menu item must be hovered before the toggle is clickable. +export async function togglePinFromMenu(page: Page, target: PinnedTabTarget): Promise { + await openNewTabMenu(page); + const item = menuItemFor(page, target); + await expect(item).toBeVisible({ timeout: 10_000 }); + await item.hover(); + const toggle = pinToggle(page, target); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + await toggle.click(); + await page.keyboard.press("Escape"); +} diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index f28b07a62..3f0a853f9 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -93,6 +93,15 @@ export async function expectFirstTerminalTabContains(page: Page, text: string): ); } +export async function expectTerminalTabOpen( + page: Page, + options?: { timeout?: number }, +): Promise { + await expect( + page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true }).first(), + ).toBeVisible({ timeout: options?.timeout ?? 30_000 }); +} + export async function sampleWorkspaceTabIds( page: Page, options: { durationMs?: number; intervalMs?: number } = {}, diff --git a/packages/app/e2e/workspace-pins.spec.ts b/packages/app/e2e/workspace-pins.spec.ts new file mode 100644 index 000000000..5f3e29727 --- /dev/null +++ b/packages/app/e2e/workspace-pins.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "./fixtures"; +import { gotoWorkspace } from "./helpers/launcher"; +import { gotoAppShell } from "./helpers/app"; +import { getServerId } from "./helpers/server-id"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { togglePinFromMenu, tabRowPin, sidebarPin } from "./helpers/pins"; +import { expectTerminalTabOpen } from "./helpers/workspace-tabs"; +import type { PinnedTabTarget } from "../src/workspace-pins/target"; + +const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" }; + +let workspace: SeededWorkspace; + +test.beforeAll(async () => { + workspace = await seedWorkspace({ repoPrefix: "workspace-pins-e2e-" }); +}); + +test.afterAll(async () => { + await workspace?.cleanup(); +}); + +test.describe("Pinned tab targets", () => { + test("pinning a target from the dropdown adds its quick-launch button to the tab row, unpinning removes it", async ({ + page, + }) => { + await gotoWorkspace(page, workspace.workspaceId); + + await expect(tabRowPin(page, TERMINAL_TARGET)).toHaveCount(0); + + await togglePinFromMenu(page, TERMINAL_TARGET); + await expect(tabRowPin(page, TERMINAL_TARGET)).toBeVisible({ timeout: 10_000 }); + + await togglePinFromMenu(page, TERMINAL_TARGET); + await expect(tabRowPin(page, TERMINAL_TARGET)).toHaveCount(0, { timeout: 10_000 }); + }); + + test("clicking the pinned quick-launch button in the tab row opens a terminal tab", async ({ + page, + }) => { + test.setTimeout(45_000); + await gotoWorkspace(page, workspace.workspaceId); + + await togglePinFromMenu(page, TERMINAL_TARGET); + await tabRowPin(page, TERMINAL_TARGET).click(); + + await expectTerminalTabOpen(page); + }); + + test("the pinned button is mirrored in the sidebar workspace row and launches a terminal in that workspace", async ({ + page, + }) => { + test.setTimeout(45_000); + await gotoWorkspace(page, workspace.workspaceId); + await togglePinFromMenu(page, TERMINAL_TARGET); + + await gotoAppShell(page); + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + await expect(sidebarPin(page, TERMINAL_TARGET)).toBeVisible({ timeout: 10_000 }); + await sidebarPin(page, TERMINAL_TARGET).click(); + + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expectTerminalTabOpen(page); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bbe1828e3..15829df12 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -70,6 +70,11 @@ import { type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store/state"; +import { usePinnedLaunchers } from "@/workspace-pins/launch"; +import { useLaunchIntentStore } from "@/workspace-pins/launch-intent-store"; +import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row"; +import type { PinnedTabTarget } from "@/workspace-pins/target"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { @@ -662,15 +667,40 @@ function WorkspaceRowRightGroup({ onRename?: () => void; }) { const { t } = useTranslation(); + const isMobileBreakpoint = useIsCompactFormFactor(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebabInSlot = showKebab && !showShortcut; const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); + const showPinnedTargets = (isHovered || platformIsNative || isMobileBreakpoint) && !showShortcut; + + const onLaunch = useCallback( + (target: PinnedTabTarget) => { + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + if (!workspaceKey) return; + navigateToWorkspace(workspace.serverId, workspace.workspaceId); + useLaunchIntentStore.getState().request({ workspaceKey, target }); + }, + [workspace.serverId, workspace.workspaceId], + ); + const launchers = usePinnedLaunchers({ serverId: workspace.serverId, onLaunch }); + return ( <> {isCreating ? ( {t("sidebar.workspace.status.creating")} ) : null} + {launchers.length > 0 ? ( + + + + ) : null} {shouldRenderActionSlot ? ( ({ fontSize: theme.fontSize.xs, flexShrink: 0, }, + pinnedTargetsHidden: { + opacity: 0, + }, kebabButton: { padding: 2, borderRadius: 4, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 00cdf4c06..53d000303 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -467,6 +467,8 @@ export const ar: TranslationResources = { splitDown: "تقسيم الجزء لأسفل", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", + pinTarget: "تثبيت", + unpinTarget: "إلغاء التثبيت", }, explorer: { open: "افتح المستكشف", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a2fdd1aaf..db6dd3b88 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -466,6 +466,8 @@ export const en = { splitDown: "Split pane down", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", + pinTarget: "Pin", + unpinTarget: "Unpin", }, explorer: { open: "Open explorer", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fd9f69447..fbb054557 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -471,6 +471,8 @@ export const es: TranslationResources = { splitDown: "Dividir panel hacia abajo", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", + pinTarget: "Fijar", + unpinTarget: "Desfijar", }, explorer: { open: "Explorador abierto", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a885785aa..622fedb73 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -471,6 +471,8 @@ export const fr: TranslationResources = { splitDown: "Diviser le volet vers le bas", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", + pinTarget: "Épingler", + unpinTarget: "Détacher", }, explorer: { open: "Ouvrir l'explorateur", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d27301ba4..4adb2a6a1 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -470,6 +470,8 @@ export const ru: TranslationResources = { splitDown: "Разделить панель вниз", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", + pinTarget: "Закрепить", + unpinTarget: "Открепить", }, explorer: { open: "Открыть проводник", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 71846cb42..08d17ecd0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -467,6 +467,8 @@ export const zhCN: TranslationResources = { splitDown: "向下拆分窗格", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", + pinTarget: "固定", + unpinTarget: "取消固定", }, explorer: { open: "打开 explorer", 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 90c8814ca..2c00248cd 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -82,9 +82,13 @@ import { getTerminalProfileIcon, resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; -import { getProviderIcon } from "@/components/provider-icons"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; +import { ProfileIcon, usePinnedLaunchers } from "@/workspace-pins/launch"; +import { runPinnedTabTarget, type TabTargetHandlers } from "@/workspace-pins/run"; +import type { PinnedTabTarget } from "@/workspace-pins/target"; +import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row"; +import { PinnableMenuItem } from "@/workspace-pins/pinnable-menu-item"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; @@ -105,21 +109,6 @@ const ThemedGlobe = withUnistyles(Globe); const ThemedColumns2 = withUnistyles(Columns2); const ThemedRows2 = withUnistyles(Rows2); const ThemedPlus = withUnistyles(Plus); -const ThemedProfileSquareTerminal = withUnistyles(SquareTerminal); - -interface DynamicProviderIconProps { - iconKey: string; - size: number; - color?: string; -} - -function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { - const Icon = getProviderIcon(iconKey); - return ; -} - -const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon); - const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -127,6 +116,10 @@ const AGENT_ICON = ; const TERMINAL_ICON = ; const BROWSER_ICON = ; +const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" }; +const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" }; +const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" }; + function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; } @@ -140,46 +133,39 @@ function updateMeasuredWidth(setWidth: Dispatch>, event: setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); } -interface DropdownMenuProfileItemProps { - profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; - disabled?: boolean; - onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; +function ProfileLeadingIcon({ iconKey }: { iconKey: string | undefined }) { + return ( + + + + ); } -function DropdownMenuProfileItem({ - profile, - disabled, - onCreateTerminalWithProfile, -}: DropdownMenuProfileItemProps) { - const handleSelect = useCallback(() => { - onCreateTerminalWithProfile({ - name: profile.name, - command: profile.command, - args: profile.args, - }); - }, [onCreateTerminalWithProfile, profile]); +interface PinnableProfileMenuItemProps { + profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; + disabled?: boolean; + onLaunch: (target: PinnedTabTarget) => void; +} - const icon = getTerminalProfileIcon(profile); - - const leading = useMemo(() => { - if (!icon) { - return ( - - - - ); - } - return ( - - - - ); - }, [icon]); +function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfileMenuItemProps) { + const target = useMemo( + () => ({ kind: "profile", profileId: profile.id }), + [profile.id], + ); + const leading = useMemo( + () => , + [profile], + ); + const handleSelect = useCallback(() => onLaunch(target), [onLaunch, target]); return ( - - {profile.name} - + ); } @@ -215,6 +201,25 @@ function WorkspaceInlineAddTabButton({ [config?.terminalProfiles], ); + const handlers = useMemo( + () => ({ + createDraft: onCreateAgentTab, + createTerminal: onCreateTerminal, + createBrowser: onCreateBrowser, + createTerminalWithProfile: onCreateTerminalWithProfile, + }), + [onCreateAgentTab, onCreateBrowser, onCreateTerminal, onCreateTerminalWithProfile], + ); + + const onLaunch = useCallback( + (target: PinnedTabTarget) => { + runPinnedTabTarget(target, profiles, handlers); + }, + [handlers, profiles], + ); + + const launchers = usePinnedLaunchers({ serverId: normalizedServerId, onLaunch }); + const tooltipText = t("workspace.tabs.actions.newAgent"); return ( @@ -238,6 +243,7 @@ function WorkspaceInlineAddTabButton({ + @@ -255,38 +261,38 @@ function WorkspaceInlineAddTabButton({ - - {t("workspace.tabs.actions.newAgent")} - - + - {t("workspace.tabs.actions.newTerminal")} - + /> {showCreateBrowserTab ? ( - - {t("workspace.tabs.actions.newBrowser")} - + /> ) : null} {t("workspace.tabs.actions.terminalProfilesMenu")} {profiles.map((profile) => ( - ))} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5b7406923..6cf313082 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -185,6 +185,8 @@ import { resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; import { getProviderIcon } from "@/components/provider-icons"; +import { useLaunchIntentStore } from "@/workspace-pins/launch-intent-store"; +import { runPinnedTabTarget, type TabTargetHandlers } from "@/workspace-pins/run"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -1718,6 +1720,37 @@ function useWorkspaceCheckoutStatus(input: { return { checkoutQuery, isCheckoutStatusLoading }; } +interface PinnedTabLaunchConsumerInput { + serverId: string; + persistenceKey: string | null; + isRouteFocused: boolean; + handlers: TabTargetHandlers; +} + +function usePinnedTabLaunchConsumer({ + serverId, + persistenceKey, + isRouteFocused, + handlers, +}: PinnedTabLaunchConsumerInput) { + const { config } = useDaemonConfig(serverId); + const profiles = useMemo( + () => resolveTerminalProfiles(config?.terminalProfiles), + [config?.terminalProfiles], + ); + const pending = useLaunchIntentStore((state) => state.pending); + + useEffect(() => { + if (!persistenceKey || !isRouteFocused || pending?.workspaceKey !== persistenceKey) { + return; + } + const target = useLaunchIntentStore.getState().consume(persistenceKey); + if (target) { + runPinnedTabTarget(target, profiles, handlers); + } + }, [handlers, isRouteFocused, pending, persistenceKey, profiles]); +} + function WorkspaceScreenContent({ serverId, workspaceId, @@ -2564,6 +2597,28 @@ function WorkspaceScreenContent({ [openWorkspaceTabFocused, persistenceKey], ); + const pinnedTabHandlers = useMemo( + () => ({ + createDraft: handleCreateDraftTab, + createTerminal: handleCreateTerminal, + createBrowser: handleCreateBrowserTab, + createTerminalWithProfile: handleCreateTerminalWithProfile, + }), + [ + handleCreateBrowserTab, + handleCreateDraftTab, + handleCreateTerminal, + handleCreateTerminalWithProfile, + ], + ); + + usePinnedTabLaunchConsumer({ + serverId: normalizedServerId, + persistenceKey, + isRouteFocused, + handlers: pinnedTabHandlers, + }); + useDesktopBrowserNewTabRequests({ enabled: Boolean(persistenceKey), workspaceLayout, diff --git a/packages/app/src/workspace-pins/launch-intent-store.test.ts b/packages/app/src/workspace-pins/launch-intent-store.test.ts new file mode 100644 index 000000000..b6b7fcfd6 --- /dev/null +++ b/packages/app/src/workspace-pins/launch-intent-store.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useLaunchIntentStore } from "./launch-intent-store"; + +beforeEach(() => { + useLaunchIntentStore.setState({ pending: null }); +}); + +describe("launch intent store", () => { + it("returns the requested target when the workspace key matches", () => { + useLaunchIntentStore.getState().request({ workspaceKey: "ws-1", target: { kind: "terminal" } }); + expect(useLaunchIntentStore.getState().consume("ws-1")).toEqual({ kind: "terminal" }); + }); + + it("clears the pending intent once consumed", () => { + useLaunchIntentStore.getState().request({ workspaceKey: "ws-1", target: { kind: "draft" } }); + useLaunchIntentStore.getState().consume("ws-1"); + expect(useLaunchIntentStore.getState().pending).toBeNull(); + }); + + it("returns null and keeps the intent when the workspace key does not match", () => { + useLaunchIntentStore.getState().request({ workspaceKey: "ws-1", target: { kind: "browser" } }); + expect(useLaunchIntentStore.getState().consume("ws-2")).toBeNull(); + expect(useLaunchIntentStore.getState().pending).toEqual({ + workspaceKey: "ws-1", + target: { kind: "browser" }, + }); + }); + + it("returns null when there is no pending intent", () => { + expect(useLaunchIntentStore.getState().consume("ws-1")).toBeNull(); + }); +}); diff --git a/packages/app/src/workspace-pins/launch-intent-store.ts b/packages/app/src/workspace-pins/launch-intent-store.ts new file mode 100644 index 000000000..aa5f07601 --- /dev/null +++ b/packages/app/src/workspace-pins/launch-intent-store.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; +import type { PinnedTabTarget } from "@/workspace-pins/target"; + +export interface PendingTabLaunch { + workspaceKey: string; + target: PinnedTabTarget; +} + +interface LaunchIntentState { + pending: PendingTabLaunch | null; + request: (intent: PendingTabLaunch) => void; + consume: (workspaceKey: string) => PinnedTabTarget | null; +} + +export const useLaunchIntentStore = create((set, get) => ({ + pending: null, + request: (intent) => set({ pending: intent }), + consume: (workspaceKey) => { + const { pending } = get(); + if (!pending || pending.workspaceKey !== workspaceKey) { + return null; + } + set({ pending: null }); + return pending.target; + }, +})); diff --git a/packages/app/src/workspace-pins/launch.tsx b/packages/app/src/workspace-pins/launch.tsx new file mode 100644 index 000000000..f4e369bf5 --- /dev/null +++ b/packages/app/src/workspace-pins/launch.tsx @@ -0,0 +1,107 @@ +import { useMemo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { Globe, SquarePen, SquareTerminal } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { + getTerminalProfileIcon, + resolveTerminalProfiles, +} from "@getpaseo/protocol/terminal-profiles"; +import { getProviderIcon } from "@/components/provider-icons"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import type { Theme } from "@/styles/theme"; +import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target"; +import { usePinnedTargetsStore } from "@/workspace-pins/store"; + +export interface ResolvedPin { + key: string; + label: string; + icon: ReactElement; + onPress: () => void; +} + +interface UsePinnedLaunchersInput { + serverId: string; + onLaunch: (target: PinnedTabTarget) => void; +} + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const ThemedSquarePen = withUnistyles(SquarePen); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedGlobe = withUnistyles(Globe); + +function ProviderPinIcon({ + iconKey, + size, + color = "", +}: { + iconKey: string; + size: number; + color?: string; +}) { + const Icon = getProviderIcon(iconKey); + return ; +} + +const ThemedProviderPinIcon = withUnistyles(ProviderPinIcon); + +export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement { + if (!iconKey) { + return ; + } + return ; +} + +export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInput): ResolvedPin[] { + const { t } = useTranslation(); + const pinned = usePinnedTargetsStore((state) => state.pinned); + const { config } = useDaemonConfig(serverId); + const profiles = useMemo( + () => resolveTerminalProfiles(config?.terminalProfiles), + [config?.terminalProfiles], + ); + + return useMemo(() => { + const resolved: ResolvedPin[] = []; + for (const target of pinned) { + if (target.kind === "draft") { + resolved.push({ + key: pinnedTargetKey(target), + label: t("workspace.tabs.actions.newAgent"), + icon: , + onPress: () => onLaunch(target), + }); + continue; + } + if (target.kind === "terminal") { + resolved.push({ + key: pinnedTargetKey(target), + label: t("workspace.tabs.actions.newTerminal"), + icon: , + onPress: () => onLaunch(target), + }); + continue; + } + if (target.kind === "browser") { + resolved.push({ + key: pinnedTargetKey(target), + label: t("workspace.tabs.actions.newBrowser"), + icon: , + onPress: () => onLaunch(target), + }); + continue; + } + const profile = profiles.find((entry) => entry.id === target.profileId); + if (!profile) { + continue; + } + resolved.push({ + key: pinnedTargetKey(target), + label: profile.name, + icon: , + onPress: () => onLaunch(target), + }); + } + return resolved; + }, [onLaunch, pinned, profiles, t]); +} diff --git a/packages/app/src/workspace-pins/pinnable-menu-item.tsx b/packages/app/src/workspace-pins/pinnable-menu-item.tsx new file mode 100644 index 000000000..cefa57d49 --- /dev/null +++ b/packages/app/src/workspace-pins/pinnable-menu-item.tsx @@ -0,0 +1,119 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { Pressable, View, type GestureResponderEvent } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Pin, PinOff } from "lucide-react-native"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { isNative } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; +import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target"; +import { usePinnedTargetsStore } from "@/workspace-pins/store"; + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const ThemedPin = withUnistyles(Pin); +const ThemedPinOff = withUnistyles(PinOff); + +interface PinnableMenuItemProps { + target: PinnedTabTarget; + label: string; + leading: ReactElement; + disabled?: boolean; + onSelect?: () => void; + testID?: string; +} + +export function PinnableMenuItem({ + target, + label, + leading, + disabled, + onSelect, + testID, +}: PinnableMenuItemProps): ReactElement { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const [isHovered, setIsHovered] = useState(false); + const isPinned = usePinnedTargetsStore((state) => state.isPinned(target)); + const toggle = usePinnedTargetsStore((state) => state.toggle); + + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + + const handleTogglePin = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + toggle(target); + }, + [target, toggle], + ); + + const showToggle = isHovered || isNative || isCompact; + + const slotStyle = useMemo( + () => [styles.pinToggleSlot, showToggle ? styles.pinToggleShown : styles.pinToggleHidden], + [showToggle], + ); + + const trailing = useMemo( + () => ( + + + {isPinned ? ( + + ) : ( + + )} + + + ), + [handleTogglePin, isPinned, showToggle, slotStyle, t, target], + ); + + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create(() => ({ + pinToggleSlot: { + width: 22, + height: 22, + alignItems: "center", + justifyContent: "center", + }, + pinToggleHidden: { + opacity: 0, + }, + pinToggleShown: { + opacity: 1, + }, + pinToggleButton: { + width: 22, + height: 22, + alignItems: "center", + justifyContent: "center", + }, +})); diff --git a/packages/app/src/workspace-pins/pinned-targets-row.tsx b/packages/app/src/workspace-pins/pinned-targets-row.tsx new file mode 100644 index 000000000..0bcfe2a86 --- /dev/null +++ b/packages/app/src/workspace-pins/pinned-targets-row.tsx @@ -0,0 +1,58 @@ +import type { ReactElement } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { ResolvedPin } from "@/workspace-pins/launch"; + +function pinButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.pinButton, (Boolean(hovered) || pressed) && styles.pinButtonHovered]; +} + +interface PinnedTargetsRowProps { + launchers: ResolvedPin[]; + testIdPrefix: string; +} + +export function PinnedTargetsRow({ launchers, testIdPrefix }: PinnedTargetsRowProps): ReactElement { + return ( + + {launchers.map((launcher) => ( + + + {launcher.icon} + + + {launcher.label} + + + ))} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + }, + pinButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + }, + pinButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/workspace-pins/run.test.ts b/packages/app/src/workspace-pins/run.test.ts new file mode 100644 index 000000000..1e1d2a0f0 --- /dev/null +++ b/packages/app/src/workspace-pins/run.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; +import { runPinnedTabTarget, type TabTargetHandlers } from "./run"; + +const PROFILES: readonly TerminalProfile[] = [ + { id: "claude", name: "Claude Code", command: "claude" }, +]; + +interface RecordedLaunch { + action: "draft" | "terminal" | "browser" | "profile"; + profile?: TerminalProfileInput; +} + +function recordingHandlers() { + const launches: RecordedLaunch[] = []; + const handlers: TabTargetHandlers = { + createDraft: () => launches.push({ action: "draft" }), + createTerminal: () => launches.push({ action: "terminal" }), + createBrowser: () => launches.push({ action: "browser" }), + createTerminalWithProfile: (profile) => launches.push({ action: "profile", profile }), + }; + return { launches, handlers }; +} + +describe("runPinnedTabTarget", () => { + it("creates a draft agent for the draft target", () => { + const { launches, handlers } = recordingHandlers(); + runPinnedTabTarget({ kind: "draft" }, PROFILES, handlers); + expect(launches).toEqual([{ action: "draft" }]); + }); + + it("creates a terminal for the terminal target", () => { + const { launches, handlers } = recordingHandlers(); + runPinnedTabTarget({ kind: "terminal" }, PROFILES, handlers); + expect(launches).toEqual([{ action: "terminal" }]); + }); + + it("creates a browser for the browser target", () => { + const { launches, handlers } = recordingHandlers(); + runPinnedTabTarget({ kind: "browser" }, PROFILES, handlers); + expect(launches).toEqual([{ action: "browser" }]); + }); + + it("launches the resolved profile command for a known profile target", () => { + const { launches, handlers } = recordingHandlers(); + runPinnedTabTarget({ kind: "profile", profileId: "claude" }, PROFILES, handlers); + expect(launches).toEqual([ + { action: "profile", profile: { name: "Claude Code", command: "claude" } }, + ]); + }); + + it("does nothing when the profile id is absent from the host", () => { + const { launches, handlers } = recordingHandlers(); + runPinnedTabTarget({ kind: "profile", profileId: "missing" }, PROFILES, handlers); + expect(launches).toEqual([]); + }); +}); diff --git a/packages/app/src/workspace-pins/run.ts b/packages/app/src/workspace-pins/run.ts new file mode 100644 index 000000000..553149c90 --- /dev/null +++ b/packages/app/src/workspace-pins/run.ts @@ -0,0 +1,38 @@ +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; +import type { PinnedTabTarget } from "@/workspace-pins/target"; + +export interface TabTargetHandlers { + createDraft: () => void; + createTerminal: () => void; + createBrowser: () => void; + createTerminalWithProfile: (profile: TerminalProfileInput) => void; +} + +export function runPinnedTabTarget( + target: PinnedTabTarget, + profiles: readonly TerminalProfile[], + handlers: TabTargetHandlers, +): void { + if (target.kind === "draft") { + handlers.createDraft(); + return; + } + if (target.kind === "terminal") { + handlers.createTerminal(); + return; + } + if (target.kind === "browser") { + handlers.createBrowser(); + return; + } + const profile = profiles.find((entry) => entry.id === target.profileId); + if (!profile) { + return; + } + handlers.createTerminalWithProfile({ + name: profile.name, + command: profile.command, + args: profile.args, + }); +} diff --git a/packages/app/src/workspace-pins/store.ts b/packages/app/src/workspace-pins/store.ts new file mode 100644 index 000000000..ef986d9af --- /dev/null +++ b/packages/app/src/workspace-pins/store.ts @@ -0,0 +1,25 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target"; + +interface PinnedTargetsState { + pinned: PinnedTabTarget[]; + toggle: (target: PinnedTabTarget) => void; + isPinned: (target: PinnedTabTarget) => boolean; +} + +export const usePinnedTargetsStore = create()( + persist( + (set, get) => ({ + pinned: [], + toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })), + isPinned: (target) => isTargetPinned(get().pinned, target), + }), + { + name: "pinned-tab-targets", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ pinned: state.pinned }), + }, + ), +); diff --git a/packages/app/src/workspace-pins/target.test.ts b/packages/app/src/workspace-pins/target.test.ts new file mode 100644 index 000000000..d9d5b11d6 --- /dev/null +++ b/packages/app/src/workspace-pins/target.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { isTargetPinned, pinnedTargetKey, togglePinnedTarget } from "./target"; + +describe("pinnedTargetKey", () => { + it("uses the bare kind as the key for non-profile targets", () => { + expect(pinnedTargetKey({ kind: "draft" })).toBe("draft"); + expect(pinnedTargetKey({ kind: "terminal" })).toBe("terminal"); + expect(pinnedTargetKey({ kind: "browser" })).toBe("browser"); + }); + + it("namespaces a profile target by its profile id", () => { + expect(pinnedTargetKey({ kind: "profile", profileId: "claude" })).toBe("profile:claude"); + }); + + it("gives two profiles with different ids different keys", () => { + const claude = pinnedTargetKey({ kind: "profile", profileId: "claude" }); + const codex = pinnedTargetKey({ kind: "profile", profileId: "codex" }); + expect(claude).not.toBe(codex); + }); +}); + +describe("togglePinnedTarget / isTargetPinned", () => { + it("pins a target that is not yet pinned", () => { + const pinned = togglePinnedTarget([], { kind: "terminal" }); + expect(isTargetPinned(pinned, { kind: "terminal" })).toBe(true); + }); + + it("unpins a target that is already pinned", () => { + const pinned = togglePinnedTarget([{ kind: "browser" }], { kind: "browser" }); + expect(isTargetPinned(pinned, { kind: "browser" })).toBe(false); + }); + + it("treats profiles with different ids as independent pins", () => { + const pinned = togglePinnedTarget([], { kind: "profile", profileId: "claude" }); + expect(isTargetPinned(pinned, { kind: "profile", profileId: "claude" })).toBe(true); + expect(isTargetPinned(pinned, { kind: "profile", profileId: "codex" })).toBe(false); + }); + + it("unpins only the matching profile id", () => { + let pinned = togglePinnedTarget([], { kind: "profile", profileId: "claude" }); + pinned = togglePinnedTarget(pinned, { kind: "profile", profileId: "codex" }); + pinned = togglePinnedTarget(pinned, { kind: "profile", profileId: "claude" }); + + expect(isTargetPinned(pinned, { kind: "profile", profileId: "claude" })).toBe(false); + expect(isTargetPinned(pinned, { kind: "profile", profileId: "codex" })).toBe(true); + }); +}); diff --git a/packages/app/src/workspace-pins/target.ts b/packages/app/src/workspace-pins/target.ts new file mode 100644 index 000000000..2593c985e --- /dev/null +++ b/packages/app/src/workspace-pins/target.ts @@ -0,0 +1,32 @@ +export type PinnedTabTarget = + | { kind: "draft" } + | { kind: "terminal" } + | { kind: "browser" } + | { kind: "profile"; profileId: string }; + +export function pinnedTargetKey(target: PinnedTabTarget): string { + if (target.kind === "profile") { + return `profile:${target.profileId}`; + } + return target.kind; +} + +export function isTargetPinned( + pinned: readonly PinnedTabTarget[], + target: PinnedTabTarget, +): boolean { + const key = pinnedTargetKey(target); + return pinned.some((entry) => pinnedTargetKey(entry) === key); +} + +export function togglePinnedTarget( + pinned: readonly PinnedTabTarget[], + target: PinnedTabTarget, +): PinnedTabTarget[] { + const key = pinnedTargetKey(target); + const next = pinned.filter((entry) => pinnedTargetKey(entry) !== key); + if (next.length === pinned.length) { + next.push(target); + } + return next; +}