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.
This commit is contained in:
Mohamed Boudra
2026-08-17 22:55:46 +02:00
parent 5ec26f0c09
commit 3948bf4fd6
6 changed files with 70 additions and 30 deletions
@@ -22,11 +22,23 @@ import {
setupDeterministicPrompt, setupDeterministicPrompt,
} from "../support/helpers/terminal-perf"; } from "../support/helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalOutputContains,
seedTerminalProfiles,
type TerminalProfile,
} from "../support/helpers/new-workspace-launch";
// ─── Shared state ────────────────────────────────────────────────────────── // ─── Shared state ──────────────────────────────────────────────────────────
let workspace: SeededWorkspace; 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 () => { test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" }); workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
}); });
@@ -137,6 +149,23 @@ test.describe("Tab creation", () => {
expect(terminalTabs.length).toBeGreaterThanOrEqual(1); 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 }) => { test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page); await assertSingleNewTabButton(page);
@@ -69,7 +69,7 @@ import {
WorkspaceDesktopTabsRow, WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem, type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row"; } 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 { import {
WorkspaceTabPresentationResolver, WorkspaceTabPresentationResolver,
WorkspaceTabIcon, WorkspaceTabIcon,
@@ -111,7 +111,7 @@ interface SplitContainerProps {
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => 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; onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean; showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; 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 type { WorkspaceDescriptor } from "@/stores/session-store";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useReplicaQuery } from "@/data/query"; import { useReplicaQuery } from "@/data/query";
@@ -17,19 +19,11 @@ import {
upsertCreatedTerminalPayload, upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state"; } from "@/screens/workspace/terminals/state";
interface TerminalProfileInput {
name: string;
command: string;
args?: string[];
}
interface PendingTerminalCreateInput { interface PendingTerminalCreateInput {
paneId?: string; paneId?: string;
profile?: TerminalProfileInput; profile?: TerminalProfile;
} }
export type { TerminalProfileInput };
interface UseWorkspaceTerminalsInput { interface UseWorkspaceTerminalsInput {
client: DaemonClient | null; client: DaemonClient | null;
isConnected: boolean; isConnected: boolean;
@@ -135,10 +129,13 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
throw new Error(t("workspace.terminal.hostDisconnected")); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
const payload = _input?.profile const profile = _input?.profile
? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, { ? resolveTerminalProfileLaunch(_input.profile, "")
command: _input.profile.command, : undefined;
args: _input.profile.args, const payload = profile
? await client.createTerminal(workspaceDirectory, profile.name, undefined, {
command: profile.command,
args: profile.args,
workspaceId: normalizedWorkspaceId || undefined, workspaceId: normalizedWorkspaceId || undefined,
}) })
: await client.createTerminal(workspaceDirectory, undefined, undefined, { : await client.createTerminal(workspaceDirectory, undefined, undefined, {
@@ -96,7 +96,7 @@ import {
resolveTerminalProfiles, resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles"; } from "@getpaseo/protocol/terminal-profiles";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; 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 { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
@@ -261,7 +261,7 @@ function TabLabelMeasurement({
interface TerminalProfileMenuItemProps { interface TerminalProfileMenuItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled?: boolean; disabled?: boolean;
onLaunch: (profile: TerminalProfileInput) => void; onLaunch: (profile: TerminalProfile) => void;
} }
function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) { function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) {
@@ -286,7 +286,7 @@ interface TabTargetLauncherOptions {
onOpenChanges: () => void; onOpenChanges: () => void;
onOpenFiles: () => void; onOpenFiles: () => void;
onOpenPullRequest: () => void; onOpenPullRequest: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
} }
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
@@ -660,7 +660,7 @@ interface WorkspaceDesktopTabsRowProps {
onCloseTabsToRight: (tabId: string) => Promise<void> | void; onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => 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; onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean; showCreateBrowserTab?: boolean;
disableCreateTerminal?: boolean; disableCreateTerminal?: boolean;
@@ -1396,7 +1396,7 @@ function ResolvedWorkspaceDesktopTabsRow({
}, [onCreateTerminalTab, paneId]); }, [onCreateTerminalTab, paneId]);
const handleCreateTerminalWithProfile = useCallback( const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfileInput) => { (profile: TerminalProfile) => {
onCreateTerminalTab({ paneId, profile }); onCreateTerminalTab({ paneId, profile });
}, },
[onCreateTerminalTab, paneId], [onCreateTerminalTab, paneId],
@@ -180,13 +180,10 @@ import {
buildSettingsHostRoute, buildSettingsHostRoute,
buildSettingsHostSectionRoute, buildSettingsHostSectionRoute,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
useWorkspaceTerminals, import type { TerminalProfile } from "@getpaseo/protocol/messages";
type TerminalProfileInput,
} from "@/screens/workspace/terminals/use-workspace-terminals";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { import {
resolveTerminalProfileLaunch,
getTerminalProfileIcon, getTerminalProfileIcon,
resolveTerminalProfiles, resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles"; } from "@getpaseo/protocol/terminal-profiles";
@@ -930,7 +927,7 @@ interface WorkspaceHeaderMenuProps {
menuSettingsIcon: ReactElement; menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void; onCreateDraftTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void; onCreateBrowser: () => void;
onOpenImportSheet: () => void; onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void; onCopyWorkspacePath: () => void;
@@ -940,7 +937,7 @@ interface WorkspaceHeaderMenuProps {
interface HeaderMenuProfileItemProps { interface HeaderMenuProfileItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled: boolean; disabled: boolean;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
} }
function HeaderMenuProfileItem({ function HeaderMenuProfileItem({
@@ -949,7 +946,7 @@ function HeaderMenuProfileItem({
onCreateTerminalWithProfile, onCreateTerminalWithProfile,
}: HeaderMenuProfileItemProps) { }: HeaderMenuProfileItemProps) {
const handleSelect = useCallback(() => { const handleSelect = useCallback(() => {
onCreateTerminalWithProfile(resolveTerminalProfileLaunch(profile, "")); onCreateTerminalWithProfile(profile);
}, [onCreateTerminalWithProfile, profile]); }, [onCreateTerminalWithProfile, profile]);
const icon = getTerminalProfileIcon(profile); const icon = getTerminalProfileIcon(profile);
@@ -1196,7 +1193,7 @@ interface WorkspaceHeaderTitleBarProps {
menuSettingsIcon: ReactElement; menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void; onCreateDraftTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void; onCreateBrowser: () => void;
onOpenImportSheet: () => void; onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void; onCopyWorkspacePath: () => void;
@@ -2604,7 +2601,7 @@ function WorkspaceScreenContent({
const handleCreateTerminal = useStableEvent(createTerminal); const handleCreateTerminal = useStableEvent(createTerminal);
const handleCreateTerminalWithProfile = useCallback( const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfileInput) => { (profile: TerminalProfile) => {
createTerminal({ profile }); createTerminal({ profile });
}, },
[createTerminal], [createTerminal],
@@ -6,6 +6,7 @@ import {
getTerminalProfileIcon, getTerminalProfileIcon,
guessTerminalProfileIcon, guessTerminalProfileIcon,
profileTakesPrompt, profileTakesPrompt,
resolveTerminalProfileLaunch,
resolveTerminalProfiles, resolveTerminalProfiles,
substitutePrompt, substitutePrompt,
} from "./terminal-profiles.js"; } 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", () => { describe("formatResolvedCommand", () => {
it("joins the command and its args with spaces", () => { it("joins the command and its args with spaces", () => {
expect(formatResolvedCommand({ command: "/bin/sh", args: ["-c", "echo hi"] })).toBe( expect(formatResolvedCommand({ command: "/bin/sh", args: ["-c", "echo hi"] })).toBe(