From 3948bf4fd6a085b565ffc2b87943d831089cf7d8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 22:52:19 +0200 Subject: [PATCH] fix(app): resolve terminal profiles at creation boundary Profile placeholders were reaching spawned processes because each menu decided whether to resolve them. Keep UI callers profile-only and perform empty-prompt resolution once when the workspace terminal is created. --- packages/app/e2e/browser/launcher-tab.spec.ts | 29 +++++++++++++++++++ .../app/src/components/split-container.tsx | 4 +-- .../terminals/use-workspace-terminals.ts | 23 +++++++-------- .../workspace/workspace-desktop-tabs-row.tsx | 10 +++---- .../screens/workspace/workspace-screen.tsx | 17 +++++------ .../protocol/src/terminal-profiles.test.ts | 17 +++++++++++ 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index a8e75e6ce..31ef00c48 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -22,11 +22,23 @@ import { setupDeterministicPrompt, } from "../support/helpers/terminal-perf"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + expectTerminalOutputContains, + seedTerminalProfiles, + type TerminalProfile, +} from "../support/helpers/new-workspace-launch"; // ─── Shared state ────────────────────────────────────────────────────────── let workspace: SeededWorkspace; +const EMPTY_PROMPT_PROFILE: TerminalProfile = { + id: "e2e-empty-prompt", + name: "Empty Prompt", + command: "/bin/sh", + args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"], +}; + test.beforeAll(async () => { workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" }); }); @@ -137,6 +149,23 @@ test.describe("Tab creation", () => { expect(terminalTabs.length).toBeGreaterThanOrEqual(1); }); + test("launching a profile from the New tab menu drops its empty prompt argument", async ({ + page, + }) => { + test.setTimeout(45_000); + const profileSeed = await seedTerminalProfiles([EMPTY_PROMPT_PROFILE]); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click(); + await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click(); + + await expectTerminalOutputContains(page, "prompt-args: 0"); + } finally { + await profileSeed.restore(); + } + }); + test("tab bar shows action buttons per pane", async ({ page }) => { await gotoWorkspace(page, workspace.workspaceId); await assertSingleNewTabButton(page); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 85d5ab038..a9fa4a164 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -69,7 +69,7 @@ import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -111,7 +111,7 @@ interface SplitContainerProps { onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCreateDraftTab: (input: { paneId?: string }) => void; - onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void; + onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void; onCreateBrowserTab: (input: { paneId?: string }) => void; showCreateBrowserTab?: boolean; buildPaneContentModel: (input: { diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index 8bb916205..eaa4e09e5 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { resolveTerminalProfileLaunch } from "@getpaseo/protocol/terminal-profiles"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useTranslation } from "react-i18next"; import { useReplicaQuery } from "@/data/query"; @@ -17,19 +19,11 @@ import { upsertCreatedTerminalPayload, } from "@/screens/workspace/terminals/state"; -interface TerminalProfileInput { - name: string; - command: string; - args?: string[]; -} - interface PendingTerminalCreateInput { paneId?: string; - profile?: TerminalProfileInput; + profile?: TerminalProfile; } -export type { TerminalProfileInput }; - interface UseWorkspaceTerminalsInput { client: DaemonClient | null; isConnected: boolean; @@ -135,10 +129,13 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { if (!client || !workspaceDirectory) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const payload = _input?.profile - ? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, { - command: _input.profile.command, - args: _input.profile.args, + const profile = _input?.profile + ? resolveTerminalProfileLaunch(_input.profile, "") + : undefined; + const payload = profile + ? await client.createTerminal(workspaceDirectory, profile.name, undefined, { + command: profile.command, + args: profile.args, workspaceId: normalizedWorkspaceId || undefined, }) : await client.createTerminal(workspaceDirectory, undefined, undefined, { 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 b730c4767..e8db0f14f 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -96,7 +96,7 @@ import { resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; -import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -261,7 +261,7 @@ function TabLabelMeasurement({ interface TerminalProfileMenuItemProps { profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; disabled?: boolean; - onLaunch: (profile: TerminalProfileInput) => void; + onLaunch: (profile: TerminalProfile) => void; } function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) { @@ -286,7 +286,7 @@ interface TabTargetLauncherOptions { onOpenChanges: () => void; onOpenFiles: () => void; onOpenPullRequest: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; + onCreateTerminalWithProfile: (profile: TerminalProfile) => void; } interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { @@ -660,7 +660,7 @@ interface WorkspaceDesktopTabsRowProps { onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; onCreateDraftTab: (input: { paneId?: string }) => void; - onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void; + onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void; onCreateBrowserTab: (input: { paneId?: string }) => void; showCreateBrowserTab?: boolean; disableCreateTerminal?: boolean; @@ -1396,7 +1396,7 @@ function ResolvedWorkspaceDesktopTabsRow({ }, [onCreateTerminalTab, paneId]); const handleCreateTerminalWithProfile = useCallback( - (profile: TerminalProfileInput) => { + (profile: TerminalProfile) => { onCreateTerminalTab({ paneId, profile }); }, [onCreateTerminalTab, paneId], diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 8ec07a704..7d7f705ba 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -180,13 +180,10 @@ import { buildSettingsHostRoute, buildSettingsHostSectionRoute, } from "@/utils/host-routes"; -import { - useWorkspaceTerminals, - type TerminalProfileInput, -} from "@/screens/workspace/terminals/use-workspace-terminals"; +import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { - resolveTerminalProfileLaunch, getTerminalProfileIcon, resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; @@ -930,7 +927,7 @@ interface WorkspaceHeaderMenuProps { menuSettingsIcon: ReactElement; onCreateDraftTab: () => void; onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; + onCreateTerminalWithProfile: (profile: TerminalProfile) => void; onCreateBrowser: () => void; onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; @@ -940,7 +937,7 @@ interface WorkspaceHeaderMenuProps { interface HeaderMenuProfileItemProps { profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; disabled: boolean; - onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; + onCreateTerminalWithProfile: (profile: TerminalProfile) => void; } function HeaderMenuProfileItem({ @@ -949,7 +946,7 @@ function HeaderMenuProfileItem({ onCreateTerminalWithProfile, }: HeaderMenuProfileItemProps) { const handleSelect = useCallback(() => { - onCreateTerminalWithProfile(resolveTerminalProfileLaunch(profile, "")); + onCreateTerminalWithProfile(profile); }, [onCreateTerminalWithProfile, profile]); const icon = getTerminalProfileIcon(profile); @@ -1196,7 +1193,7 @@ interface WorkspaceHeaderTitleBarProps { menuSettingsIcon: ReactElement; onCreateDraftTab: () => void; onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; + onCreateTerminalWithProfile: (profile: TerminalProfile) => void; onCreateBrowser: () => void; onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; @@ -2604,7 +2601,7 @@ function WorkspaceScreenContent({ const handleCreateTerminal = useStableEvent(createTerminal); const handleCreateTerminalWithProfile = useCallback( - (profile: TerminalProfileInput) => { + (profile: TerminalProfile) => { createTerminal({ profile }); }, [createTerminal], diff --git a/packages/protocol/src/terminal-profiles.test.ts b/packages/protocol/src/terminal-profiles.test.ts index 6bc3d37ac..e45f68b29 100644 --- a/packages/protocol/src/terminal-profiles.test.ts +++ b/packages/protocol/src/terminal-profiles.test.ts @@ -6,6 +6,7 @@ import { getTerminalProfileIcon, guessTerminalProfileIcon, profileTakesPrompt, + resolveTerminalProfileLaunch, resolveTerminalProfiles, substitutePrompt, } from "./terminal-profiles.js"; @@ -285,6 +286,22 @@ describe("substitutePrompt", () => { }); }); +describe("resolveTerminalProfileLaunch", () => { + it("turns an empty prompt profile into a spawnable command with no prompt argument", () => { + expect( + resolveTerminalProfileLaunch( + { + id: "agent", + name: "Agent", + command: "agent-cli", + args: [PROMPT_SENTINEL], + }, + "", + ), + ).toEqual({ name: "Agent", command: "agent-cli", args: [] }); + }); +}); + describe("formatResolvedCommand", () => { it("joins the command and its args with spaces", () => { expect(formatResolvedCommand({ command: "/bin/sh", args: ["-c", "echo hi"] })).toBe(