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:
@@ -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 <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
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 = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
|
||||
const TERMINAL_ICON = <ThemedSquareTerminal 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) {
|
||||
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));
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.terminalProfileIconWrapper}>
|
||||
<ProfileIcon iconKey={iconKey} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.terminalProfileIconWrapper}>
|
||||
<ThemedProfileSquareTerminal size={14} uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.terminalProfileIconWrapper}>
|
||||
<ThemedDynamicProviderIcon iconKey={icon} size={14} uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}, [icon]);
|
||||
function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfileMenuItemProps) {
|
||||
const target = useMemo<PinnedTabTarget>(
|
||||
() => ({ kind: "profile", profileId: profile.id }),
|
||||
[profile.id],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => <ProfileLeadingIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||
[profile],
|
||||
);
|
||||
const handleSelect = useCallback(() => onLaunch(target), [onLaunch, target]);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
|
||||
{profile.name}
|
||||
</DropdownMenuItem>
|
||||
<PinnableMenuItem
|
||||
target={target}
|
||||
label={profile.name}
|
||||
leading={leading}
|
||||
disabled={disabled}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,6 +201,25 @@ function WorkspaceInlineAddTabButton({
|
||||
[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");
|
||||
|
||||
return (
|
||||
@@ -238,6 +243,7 @@ function WorkspaceInlineAddTabButton({
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
@@ -255,38 +261,38 @@ function WorkspaceInlineAddTabButton({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}>
|
||||
<DropdownMenuItem
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-agent"
|
||||
target={DRAFT_TARGET}
|
||||
label={t("workspace.tabs.actions.newAgent")}
|
||||
leading={AGENT_ICON}
|
||||
onSelect={onCreateAgentTab}
|
||||
>
|
||||
{t("workspace.tabs.actions.newAgent")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
/>
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-terminal"
|
||||
target={TERMINAL_TARGET}
|
||||
label={t("workspace.tabs.actions.newTerminal")}
|
||||
leading={TERMINAL_ICON}
|
||||
disabled={terminalDisabled}
|
||||
onSelect={terminalDisabled ? undefined : onCreateTerminal}
|
||||
>
|
||||
{t("workspace.tabs.actions.newTerminal")}
|
||||
</DropdownMenuItem>
|
||||
/>
|
||||
{showCreateBrowserTab ? (
|
||||
<DropdownMenuItem
|
||||
<PinnableMenuItem
|
||||
testID="workspace-new-tab-menu-browser"
|
||||
target={BROWSER_TARGET}
|
||||
label={t("workspace.tabs.actions.newBrowser")}
|
||||
leading={BROWSER_ICON}
|
||||
onSelect={onCreateBrowser}
|
||||
>
|
||||
{t("workspace.tabs.actions.newBrowser")}
|
||||
</DropdownMenuItem>
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
|
||||
{profiles.map((profile) => (
|
||||
<DropdownMenuProfileItem
|
||||
<PinnableProfileMenuItem
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
disabled={terminalDisabled}
|
||||
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
|
||||
onLaunch={onLaunch}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -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<TabTargetHandlers>(
|
||||
() => ({
|
||||
createDraft: handleCreateDraftTab,
|
||||
createTerminal: handleCreateTerminal,
|
||||
createBrowser: handleCreateBrowserTab,
|
||||
createTerminalWithProfile: handleCreateTerminalWithProfile,
|
||||
}),
|
||||
[
|
||||
handleCreateBrowserTab,
|
||||
handleCreateDraftTab,
|
||||
handleCreateTerminal,
|
||||
handleCreateTerminalWithProfile,
|
||||
],
|
||||
);
|
||||
|
||||
usePinnedTabLaunchConsumer({
|
||||
serverId: normalizedServerId,
|
||||
persistenceKey,
|
||||
isRouteFocused,
|
||||
handlers: pinnedTabHandlers,
|
||||
});
|
||||
|
||||
useDesktopBrowserNewTabRequests({
|
||||
enabled: Boolean(persistenceKey),
|
||||
workspaceLayout,
|
||||
|
||||
Reference in New Issue
Block a user