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:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user