mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Carry the New workspace context through portal hosts and the draft's metadata through retry
This commit is contained in:
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user