diff --git a/packages/app/src/plugins/react-native/runtime-context-bridge.browser.test.tsx b/packages/app/src/plugins/react-native/runtime-context-bridge.browser.test.tsx new file mode 100644 index 0000000000..803d71ff0a --- /dev/null +++ b/packages/app/src/plugins/react-native/runtime-context-bridge.browser.test.tsx @@ -0,0 +1,79 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { PluginNewWorkspace, PluginPaseoApi } from "@getpaseo/plugin/client"; +import { + PaseoApiProvider, + PluginNewWorkspaceProvider, + PluginRpcProvider, + PluginTranslationProvider, + PluginTranslationRuntime, + usePluginRuntimeContextBridge, + type PluginRuntimeContextBridge, + type PluginSubscribe, +} from "@getpaseo/plugin/client/host"; +import { useNewWorkspace } from "@getpaseo/plugin/client"; + +// App sources compile against the classic JSX runtime, which expects React on the global, as the +// other browser tests here arrange. +beforeEach(() => vi.stubGlobal("React", React)); + +const newWorkspace = { + host: { id: "host_1", label: "Laptop" }, + project: { id: "project_1", name: "paseo" }, + isolation: "worktree", + baseRef: { type: "branch", name: "refs/remotes/origin/main" }, + branch: null, + metadata: {}, +} as PluginNewWorkspace; + +const translations = new PluginTranslationRuntime({ + getLocale: () => "en", + subscribe: () => () => undefined, +}); +const paseo = {} as PluginPaseoApi; +const rejectRpc = () => Promise.reject(new Error("no rpc in this test")); +const noSubscribe: PluginSubscribe = () => + Object.assign(() => undefined, { ready: Promise.resolve(), release: () => Promise.resolve() }); + +/** An item the way a plugin writes one: it reads the setup it sits in and shows the branch base. */ +function BaseRefLabel() { + const setup = useNewWorkspace(); + return ( + {setup.baseRef?.type === "branch" ? setup.baseRef.name : "none"} + ); +} + +it("a New workspace item rendered through a portal host still finds its setup", () => { + let bridge: PluginRuntimeContextBridge | null = null; + function Capture() { + bridge = usePluginRuntimeContextBridge(); + return null; + } + const surface = createRoot(document.createElement("div")); + act(() => + surface.render( + + + + + + + + + , + ), + ); + if (!bridge) throw new Error("bridge did not mount"); + + // A portal host, such as the modal sheet, mounts the item in a separate React tree. + const portalElement = document.createElement("div"); + const portal = createRoot(portalElement); + act(() => portal.render(<>{bridge!()})); + expect(portalElement.querySelector('[data-testid="base"]')?.textContent).toBe( + "refs/remotes/origin/main", + ); + + act(() => portal.unmount()); + act(() => surface.unmount()); +}); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index a9f9517519..900fdad7d7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -125,6 +125,7 @@ import { } from "./workspace/terminals/state"; import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; +import { retriedAgentRequest } from "./new-workspace/retry-agent-request"; function useIsNewWorkspaceDraftHandoffActive(input: { draftId: string | undefined; @@ -517,14 +518,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise execute()), retry: (request: CreateAgentRequestOptions) => - execute({ - ...initialAgent, - config: { ...request.config!, cwd }, - initialPrompt: request.initialPrompt ?? "", - clientMessageId: initialAgent.clientMessageId, - images: request.images, - attachments: request.attachments, - }), + execute(retriedAgentRequest(initialAgent, request, cwd)), }; await agentCreation.result; if (outcome === "background") clearConsumedDraft(); diff --git a/packages/app/src/screens/new-workspace/retry-agent-request.test.ts b/packages/app/src/screens/new-workspace/retry-agent-request.test.ts new file mode 100644 index 0000000000..248a044a3c --- /dev/null +++ b/packages/app/src/screens/new-workspace/retry-agent-request.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request"; + +const initial: WorkspaceAgentRequest = { + config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, + metadata: { "issues.key": "PAS-1" }, + initialPrompt: "first try", + clientMessageId: "draft_1:initial-message", +}; + +describe("retriedAgentRequest", () => { + it("creates with the metadata the draft resubmitted", () => { + const retried = retriedAgentRequest( + initial, + { + config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, + initialPrompt: "second try", + metadata: { "issues.key": "PAS-2" }, + }, + "/repo", + ); + expect(retried.metadata).toEqual({ "issues.key": "PAS-2" }); + expect(retried.initialPrompt).toBe("second try"); + expect(retried.clientMessageId).toBe("draft_1:initial-message"); + }); + + it("creates with no metadata once the draft cleared it", () => { + const retried = retriedAgentRequest( + initial, + { config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" }, + "/repo", + ); + expect("metadata" in retried).toBe(false); + }); + + it("keeps the first attempt's directory over the resubmitted config", () => { + const retried = retriedAgentRequest( + initial, + { + config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" }, + initialPrompt: "second try", + }, + "/repo", + ); + expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" }); + }); +}); diff --git a/packages/app/src/screens/new-workspace/retry-agent-request.ts b/packages/app/src/screens/new-workspace/retry-agent-request.ts new file mode 100644 index 0000000000..4a7efa02cf --- /dev/null +++ b/packages/app/src/screens/new-workspace/retry-agent-request.ts @@ -0,0 +1,28 @@ +import type { + CreateAgentRequestOptions, + CreateWorkspaceRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; + +export type WorkspaceAgentRequest = NonNullable; + +/** + * The agent a retried creation asks for: everything the draft resubmitted (config, prompt, + * images, attachments and metadata, cleared included) on the first attempt's identity, so a + * retry creates what the composer shows now, not what it showed when creation first failed. + */ +export function retriedAgentRequest( + initial: WorkspaceAgentRequest, + request: CreateAgentRequestOptions, + cwd: string, +): WorkspaceAgentRequest { + const { metadata: _replaced, ...identity } = initial; + return { + ...identity, + config: { ...request.config!, cwd }, + initialPrompt: request.initialPrompt ?? "", + clientMessageId: initial.clientMessageId, + images: request.images, + attachments: request.attachments, + ...(request.metadata ? { metadata: request.metadata } : {}), + }; +} diff --git a/packages/plugin/src/client/new-workspace.tsx b/packages/plugin/src/client/new-workspace.tsx index 5da7a2a5be..7a12593b41 100644 --- a/packages/plugin/src/client/new-workspace.tsx +++ b/packages/plugin/src/client/new-workspace.tsx @@ -66,6 +66,10 @@ export function PluginNewWorkspaceProvider({ ); } +export function usePluginNewWorkspaceValue(): PluginNewWorkspace | null { + return useContext(PluginNewWorkspaceContext); +} + /** The New workspace setup this item sits in. Call it from a component registered with `addNewWorkspaceItem`. */ export function useNewWorkspace(): PluginNewWorkspace { const newWorkspace = useContext(PluginNewWorkspaceContext); diff --git a/packages/plugin/src/client/runtime-context-bridge.tsx b/packages/plugin/src/client/runtime-context-bridge.tsx index 76e0639d90..c9315084cb 100644 --- a/packages/plugin/src/client/runtime-context-bridge.tsx +++ b/packages/plugin/src/client/runtime-context-bridge.tsx @@ -7,6 +7,7 @@ import { usePluginComposersSource, } from "./composer.js"; import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js"; +import { PluginNewWorkspaceProvider, usePluginNewWorkspaceValue } from "./new-workspace.js"; import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js"; import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js"; @@ -20,6 +21,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { const state = usePluginClientStateSource(); const composer = usePluginComposerValue(); const composers = usePluginComposersSource(); + const newWorkspace = usePluginNewWorkspaceValue(); if (!paseo || !rpc) { throw new Error("Plugin UI must run inside a contributed plugin surface"); @@ -37,11 +39,18 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { ) : ( withState ); - const content = composer ? ( - {withComposers} + const withNewWorkspace = newWorkspace ? ( + + {withComposers} + ) : ( withComposers ); + const content = composer ? ( + {withNewWorkspace} + ) : ( + withNewWorkspace + ); return ( @@ -50,6 +59,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { ); }, - [composer, composers, paseo, rpc, state, translations], + [composer, composers, newWorkspace, paseo, rpc, state, translations], ); }