Add pinnable new-tab targets to the tab row and sidebar
Any new-tab target (new agent, terminal, browser, or a terminal profile) can be pinned as a quick-launch button. Pins are global client app state (persisted), not daemon config, so the same set shows on every workspace; profile pins resolve per host and are dropped where that profile is absent. The sidebar mirror launches via a navigate-then-launch intent store rather than calling the create handlers directly: terminal creation only works in the mounted/focused workspace, so the sidebar requests the launch and the workspace screen consumes it on focus.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -93,6 +93,15 @@ export async function expectFirstTerminalTabContains(page: Page, text: string):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectTerminalTabOpen(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function sampleWorkspaceTabIds(
|
export async function sampleWorkspaceTabIds(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { durationMs?: number; intervalMs?: number } = {},
|
options: { durationMs?: number; intervalMs?: number } = {},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,11 @@ import {
|
|||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
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 { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
@@ -662,15 +667,40 @@ function WorkspaceRowRightGroup({
|
|||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||||
const showKebabInSlot = showKebab && !showShortcut;
|
const showKebabInSlot = showKebab && !showShortcut;
|
||||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{launchers.length > 0 ? (
|
||||||
|
<View
|
||||||
|
style={showPinnedTargets ? undefined : styles.pinnedTargetsHidden}
|
||||||
|
pointerEvents={showPinnedTargets ? "auto" : "none"}
|
||||||
|
>
|
||||||
|
<PinnedTargetsRow launchers={launchers} testIdPrefix="sidebar-pinned-target" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
{shouldRenderActionSlot ? (
|
{shouldRenderActionSlot ? (
|
||||||
<SidebarWorkspaceTrailingActionSlot>
|
<SidebarWorkspaceTrailingActionSlot>
|
||||||
<SidebarWorkspaceTrailingActionBase
|
<SidebarWorkspaceTrailingActionBase
|
||||||
@@ -2689,6 +2719,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
pinnedTargetsHidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
kebabButton: {
|
kebabButton: {
|
||||||
padding: 2,
|
padding: 2,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ export const ar: TranslationResources = {
|
|||||||
splitDown: "تقسيم الجزء لأسفل",
|
splitDown: "تقسيم الجزء لأسفل",
|
||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
|
pinTarget: "تثبيت",
|
||||||
|
unpinTarget: "إلغاء التثبيت",
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
open: "افتح المستكشف",
|
open: "افتح المستكشف",
|
||||||
|
|||||||
@@ -466,6 +466,8 @@ export const en = {
|
|||||||
splitDown: "Split pane down",
|
splitDown: "Split pane down",
|
||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
|
pinTarget: "Pin",
|
||||||
|
unpinTarget: "Unpin",
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
open: "Open explorer",
|
open: "Open explorer",
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ export const es: TranslationResources = {
|
|||||||
splitDown: "Dividir panel hacia abajo",
|
splitDown: "Dividir panel hacia abajo",
|
||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
|
pinTarget: "Fijar",
|
||||||
|
unpinTarget: "Desfijar",
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
open: "Explorador abierto",
|
open: "Explorador abierto",
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ export const fr: TranslationResources = {
|
|||||||
splitDown: "Diviser le volet vers le bas",
|
splitDown: "Diviser le volet vers le bas",
|
||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
|
pinTarget: "Épingler",
|
||||||
|
unpinTarget: "Détacher",
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
open: "Ouvrir l'explorateur",
|
open: "Ouvrir l'explorateur",
|
||||||
|
|||||||
@@ -470,6 +470,8 @@ export const ru: TranslationResources = {
|
|||||||
splitDown: "Разделить панель вниз",
|
splitDown: "Разделить панель вниз",
|
||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
|
pinTarget: "Закрепить",
|
||||||
|
unpinTarget: "Открепить",
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
open: "Открыть проводник",
|
open: "Открыть проводник",
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ export const zhCN: TranslationResources = {
|
|||||||
splitDown: "向下拆分窗格",
|
splitDown: "向下拆分窗格",
|
||||||
terminalProfilesMenu: "Terminal profiles",
|
terminalProfilesMenu: "Terminal profiles",
|
||||||
editTerminalProfiles: "Edit profiles…",
|
editTerminalProfiles: "Edit profiles…",
|
||||||
|
pinTarget: "固定",
|
||||||
|
unpinTarget: "取消固定",
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
open: "打开 explorer",
|
open: "打开 explorer",
|
||||||
|
|||||||
@@ -82,9 +82,13 @@ import {
|
|||||||
getTerminalProfileIcon,
|
getTerminalProfileIcon,
|
||||||
resolveTerminalProfiles,
|
resolveTerminalProfiles,
|
||||||
} from "@getpaseo/protocol/terminal-profiles";
|
} from "@getpaseo/protocol/terminal-profiles";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||||
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
|
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 DROPDOWN_WIDTH = 220;
|
||||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||||
@@ -105,21 +109,6 @@ const ThemedGlobe = withUnistyles(Globe);
|
|||||||
const ThemedColumns2 = withUnistyles(Columns2);
|
const ThemedColumns2 = withUnistyles(Columns2);
|
||||||
const ThemedRows2 = withUnistyles(Rows2);
|
const ThemedRows2 = withUnistyles(Rows2);
|
||||||
const ThemedPlus = withUnistyles(Plus);
|
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 <Icon size={size} color={color} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
|
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
@@ -127,6 +116,10 @@ const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
|
|||||||
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
|
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
|
||||||
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
|
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
|
||||||
|
|
||||||
|
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
|
||||||
|
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
|
||||||
|
const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" };
|
||||||
|
|
||||||
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||||
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||||
}
|
}
|
||||||
@@ -140,46 +133,39 @@ function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event:
|
|||||||
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
|
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropdownMenuProfileItemProps {
|
function ProfileLeadingIcon({ iconKey }: { iconKey: string | undefined }) {
|
||||||
profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
|
return (
|
||||||
disabled?: boolean;
|
<View style={styles.terminalProfileIconWrapper}>
|
||||||
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
|
<ProfileIcon iconKey={iconKey} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuProfileItem({
|
interface PinnableProfileMenuItemProps {
|
||||||
profile,
|
profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
|
||||||
disabled,
|
disabled?: boolean;
|
||||||
onCreateTerminalWithProfile,
|
onLaunch: (target: PinnedTabTarget) => void;
|
||||||
}: DropdownMenuProfileItemProps) {
|
}
|
||||||
const handleSelect = useCallback(() => {
|
|
||||||
onCreateTerminalWithProfile({
|
|
||||||
name: profile.name,
|
|
||||||
command: profile.command,
|
|
||||||
args: profile.args,
|
|
||||||
});
|
|
||||||
}, [onCreateTerminalWithProfile, profile]);
|
|
||||||
|
|
||||||
const icon = getTerminalProfileIcon(profile);
|
function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfileMenuItemProps) {
|
||||||
|
const target = useMemo<PinnedTabTarget>(
|
||||||
const leading = useMemo(() => {
|
() => ({ kind: "profile", profileId: profile.id }),
|
||||||
if (!icon) {
|
[profile.id],
|
||||||
return (
|
);
|
||||||
<View style={styles.terminalProfileIconWrapper}>
|
const leading = useMemo(
|
||||||
<ThemedProfileSquareTerminal size={14} uniProps={mutedColorMapping} />
|
() => <ProfileLeadingIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||||
</View>
|
[profile],
|
||||||
);
|
);
|
||||||
}
|
const handleSelect = useCallback(() => onLaunch(target), [onLaunch, target]);
|
||||||
return (
|
|
||||||
<View style={styles.terminalProfileIconWrapper}>
|
|
||||||
<ThemedDynamicProviderIcon iconKey={icon} size={14} uniProps={mutedColorMapping} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [icon]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
|
<PinnableMenuItem
|
||||||
{profile.name}
|
target={target}
|
||||||
</DropdownMenuItem>
|
label={profile.name}
|
||||||
|
leading={leading}
|
||||||
|
disabled={disabled}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +201,25 @@ function WorkspaceInlineAddTabButton({
|
|||||||
[config?.terminalProfiles],
|
[config?.terminalProfiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlers = useMemo<TabTargetHandlers>(
|
||||||
|
() => ({
|
||||||
|
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");
|
const tooltipText = t("workspace.tabs.actions.newAgent");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -238,6 +243,7 @@ function WorkspaceInlineAddTabButton({
|
|||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||||
@@ -255,38 +261,38 @@ function WorkspaceInlineAddTabButton({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}>
|
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}>
|
||||||
<DropdownMenuItem
|
<PinnableMenuItem
|
||||||
testID="workspace-new-tab-menu-agent"
|
testID="workspace-new-tab-menu-agent"
|
||||||
|
target={DRAFT_TARGET}
|
||||||
|
label={t("workspace.tabs.actions.newAgent")}
|
||||||
leading={AGENT_ICON}
|
leading={AGENT_ICON}
|
||||||
onSelect={onCreateAgentTab}
|
onSelect={onCreateAgentTab}
|
||||||
>
|
/>
|
||||||
{t("workspace.tabs.actions.newAgent")}
|
<PinnableMenuItem
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID="workspace-new-tab-menu-terminal"
|
testID="workspace-new-tab-menu-terminal"
|
||||||
|
target={TERMINAL_TARGET}
|
||||||
|
label={t("workspace.tabs.actions.newTerminal")}
|
||||||
leading={TERMINAL_ICON}
|
leading={TERMINAL_ICON}
|
||||||
disabled={terminalDisabled}
|
disabled={terminalDisabled}
|
||||||
onSelect={terminalDisabled ? undefined : onCreateTerminal}
|
onSelect={terminalDisabled ? undefined : onCreateTerminal}
|
||||||
>
|
/>
|
||||||
{t("workspace.tabs.actions.newTerminal")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{showCreateBrowserTab ? (
|
{showCreateBrowserTab ? (
|
||||||
<DropdownMenuItem
|
<PinnableMenuItem
|
||||||
testID="workspace-new-tab-menu-browser"
|
testID="workspace-new-tab-menu-browser"
|
||||||
|
target={BROWSER_TARGET}
|
||||||
|
label={t("workspace.tabs.actions.newBrowser")}
|
||||||
leading={BROWSER_ICON}
|
leading={BROWSER_ICON}
|
||||||
onSelect={onCreateBrowser}
|
onSelect={onCreateBrowser}
|
||||||
>
|
/>
|
||||||
{t("workspace.tabs.actions.newBrowser")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
|
||||||
{profiles.map((profile) => (
|
{profiles.map((profile) => (
|
||||||
<DropdownMenuProfileItem
|
<PinnableProfileMenuItem
|
||||||
key={profile.id}
|
key={profile.id}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
disabled={terminalDisabled}
|
disabled={terminalDisabled}
|
||||||
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
|
onLaunch={onLaunch}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ import {
|
|||||||
resolveTerminalProfiles,
|
resolveTerminalProfiles,
|
||||||
} from "@getpaseo/protocol/terminal-profiles";
|
} from "@getpaseo/protocol/terminal-profiles";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { useLaunchIntentStore } from "@/workspace-pins/launch-intent-store";
|
||||||
|
import { runPinnedTabTarget, type TabTargetHandlers } from "@/workspace-pins/run";
|
||||||
import {
|
import {
|
||||||
createWorkspaceFileTabTarget,
|
createWorkspaceFileTabTarget,
|
||||||
normalizeWorkspaceFileLocation,
|
normalizeWorkspaceFileLocation,
|
||||||
@@ -1718,6 +1720,37 @@ function useWorkspaceCheckoutStatus(input: {
|
|||||||
return { checkoutQuery, isCheckoutStatusLoading };
|
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({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -2564,6 +2597,28 @@ function WorkspaceScreenContent({
|
|||||||
[openWorkspaceTabFocused, persistenceKey],
|
[openWorkspaceTabFocused, persistenceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pinnedTabHandlers = useMemo<TabTargetHandlers>(
|
||||||
|
() => ({
|
||||||
|
createDraft: handleCreateDraftTab,
|
||||||
|
createTerminal: handleCreateTerminal,
|
||||||
|
createBrowser: handleCreateBrowserTab,
|
||||||
|
createTerminalWithProfile: handleCreateTerminalWithProfile,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleCreateBrowserTab,
|
||||||
|
handleCreateDraftTab,
|
||||||
|
handleCreateTerminal,
|
||||||
|
handleCreateTerminalWithProfile,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
usePinnedTabLaunchConsumer({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
persistenceKey,
|
||||||
|
isRouteFocused,
|
||||||
|
handlers: pinnedTabHandlers,
|
||||||
|
});
|
||||||
|
|
||||||
useDesktopBrowserNewTabRequests({
|
useDesktopBrowserNewTabRequests({
|
||||||
enabled: Boolean(persistenceKey),
|
enabled: Boolean(persistenceKey),
|
||||||
workspaceLayout,
|
workspaceLayout,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<LaunchIntentState>((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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 <Icon size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedProviderPinIcon = withUnistyles(ProviderPinIcon);
|
||||||
|
|
||||||
|
export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement {
|
||||||
|
if (!iconKey) {
|
||||||
|
return <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
|
||||||
|
}
|
||||||
|
return <ThemedProviderPinIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: <ThemedSquarePen size={14} uniProps={mutedColorMapping} />,
|
||||||
|
onPress: () => onLaunch(target),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (target.kind === "terminal") {
|
||||||
|
resolved.push({
|
||||||
|
key: pinnedTargetKey(target),
|
||||||
|
label: t("workspace.tabs.actions.newTerminal"),
|
||||||
|
icon: <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />,
|
||||||
|
onPress: () => onLaunch(target),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (target.kind === "browser") {
|
||||||
|
resolved.push({
|
||||||
|
key: pinnedTargetKey(target),
|
||||||
|
label: t("workspace.tabs.actions.newBrowser"),
|
||||||
|
icon: <ThemedGlobe size={14} uniProps={mutedColorMapping} />,
|
||||||
|
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: <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||||
|
onPress: () => onLaunch(target),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}, [onLaunch, pinned, profiles, t]);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
() => (
|
||||||
|
<View style={slotStyle} pointerEvents={showToggle ? "auto" : "none"}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleTogglePin}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.pinToggleButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={
|
||||||
|
isPinned
|
||||||
|
? t("workspace.tabs.actions.unpinTarget")
|
||||||
|
: t("workspace.tabs.actions.pinTarget")
|
||||||
|
}
|
||||||
|
testID={`workspace-pin-toggle-${pinnedTargetKey(target)}`}
|
||||||
|
>
|
||||||
|
{isPinned ? (
|
||||||
|
<ThemedPinOff size={14} uniProps={mutedColorMapping} />
|
||||||
|
) : (
|
||||||
|
<ThemedPin size={14} uniProps={mutedColorMapping} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleTogglePin, isPinned, showToggle, slotStyle, t, target],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={testID}
|
||||||
|
leading={leading}
|
||||||
|
trailing={trailing}
|
||||||
|
disabled={disabled}
|
||||||
|
onSelect={onSelect}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.row}>
|
||||||
|
{launchers.map((launcher) => (
|
||||||
|
<Tooltip key={launcher.key} delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID={`${testIdPrefix}-${launcher.key}`}
|
||||||
|
onPress={launcher.onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={launcher.label}
|
||||||
|
style={pinButtonStyle}
|
||||||
|
>
|
||||||
|
{launcher.icon}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
|
<Text style={styles.tooltipText}>{launcher.label}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<PinnedTargetsState>()(
|
||||||
|
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 }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user