Carry the New workspace context through portal hosts and the draft's metadata through retry

This commit is contained in:
Mohamed Boudra
2026-11-08 13:54:58 +01:00
parent ebc2d89a37
commit 05386f8979
6 changed files with 172 additions and 11 deletions
@@ -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 (
<span data-testid="base">{setup.baseRef?.type === "branch" ? setup.baseRef.name : "none"}</span>
);
}
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(
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rejectRpc} subscribe={noSubscribe}>
<PluginTranslationProvider runtime={translations}>
<PluginNewWorkspaceProvider newWorkspace={newWorkspace}>
<Capture />
</PluginNewWorkspaceProvider>
</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>,
),
);
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!(<BaseRefLabel />)}</>));
expect(portalElement.querySelector('[data-testid="base"]')?.textContent).toBe(
"refs/remotes/origin/main",
);
act(() => portal.unmount());
act(() => surface.unmount());
});
@@ -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<Su
const agentCreation = {
result: Promise.resolve().then(() => 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();
@@ -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" });
});
});
@@ -0,0 +1,28 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
export type WorkspaceAgentRequest = NonNullable<CreateWorkspaceRequestOptions["agent"]>;
/**
* 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 } : {}),
};
}
@@ -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);
@@ -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 ? (
<PluginComposerProvider composer={composer}>{withComposers}</PluginComposerProvider>
const withNewWorkspace = newWorkspace ? (
<PluginNewWorkspaceProvider newWorkspace={newWorkspace}>
{withComposers}
</PluginNewWorkspaceProvider>
) : (
withComposers
);
const content = composer ? (
<PluginComposerProvider composer={composer}>{withNewWorkspace}</PluginComposerProvider>
) : (
withNewWorkspace
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
@@ -50,6 +59,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
</PaseoApiProvider>
);
},
[composer, composers, paseo, rpc, state, translations],
[composer, composers, newWorkspace, paseo, rpc, state, translations],
);
}