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],
);
}