mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock
Draft metadata was component state while the prompt lived in the draft store, so a composer that remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when the prompt is emptied. It is not persisted across app restarts. While the New workspace composer is creating the agent, the built-in controls are disabled but plugin controls and commands were not. They read the composer's own lock: Paseo controls render disabled, and setPrompt, insertText and the agent commands refuse. The options and metadata a plugin reads are frozen copies, through the same helper the timeline projection uses. The composer control test mounted components in JSDOM, which the testing rules ban; it now runs in the browser project.
This commit is contained in:
+1
-4
@@ -1,13 +1,10 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Scan } from "lucide-react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider } from "@/contexts/toast-context";
|
||||
import { ToastApiProvider } from "@/contexts/toast-api-context";
|
||||
import { ComposerControls, type ComposerControlItem } from "./composer-control";
|
||||
import { useAgentControlItems } from "./control-items";
|
||||
import { resolveComposerControlPresentation } from "./layout";
|
||||
@@ -1139,6 +1139,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
if (!agent || !client) return null;
|
||||
return {
|
||||
context: "agent",
|
||||
locked: false,
|
||||
provider: agent.provider,
|
||||
model: activeModelId,
|
||||
mode: modeControl?.selectedModeId ?? null,
|
||||
@@ -1359,6 +1360,7 @@ export function DraftAgentControls({
|
||||
() =>
|
||||
draftComposerAgent({
|
||||
context,
|
||||
disabled,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
selectedMode,
|
||||
@@ -1374,6 +1376,7 @@ export function DraftAgentControls({
|
||||
}),
|
||||
[
|
||||
context,
|
||||
disabled,
|
||||
effectiveSelectedThinkingOption,
|
||||
metadata,
|
||||
onSelectMode,
|
||||
|
||||
@@ -9,6 +9,7 @@ export function draftComposerAgent(
|
||||
props: Pick<
|
||||
DraftAgentControlsProps,
|
||||
| "context"
|
||||
| "disabled"
|
||||
| "selectedProvider"
|
||||
| "selectedModel"
|
||||
| "selectedMode"
|
||||
@@ -24,6 +25,7 @@ export function draftComposerAgent(
|
||||
): ComposerAgent {
|
||||
return {
|
||||
context: props.context ?? "draft",
|
||||
locked: props.disabled ?? false,
|
||||
provider: props.selectedProvider,
|
||||
model: props.selectedModel || null,
|
||||
mode: props.selectedMode || null,
|
||||
|
||||
@@ -28,7 +28,8 @@ import { AfterPaintPublication } from "@/composer/after-paint-publication";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { applyMetadataPatch } from "@/composer/draft/metadata";
|
||||
|
||||
const NO_METADATA: Record<string, JsonValue> = {};
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -197,18 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const [metadata, setMetadataState] = useState<Record<string, JsonValue>>({});
|
||||
const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA);
|
||||
const setMetadata = useCallback(
|
||||
(patch: Record<string, JsonValue>) =>
|
||||
setMetadataState((current) => applyMetadataPatch(current, patch)),
|
||||
[],
|
||||
useDraftStore.getState().patchDraftMetadata({ draftKey, patch }),
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const clear = useCallback(
|
||||
(lifecycle: "sent" | "abandoned") => {
|
||||
textPublication.cancel();
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||
if (lifecycle === "sent") setMetadataState({});
|
||||
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
|
||||
},
|
||||
[draftKey, textPublication],
|
||||
);
|
||||
|
||||
@@ -1435,13 +1435,21 @@ function ComposerContentImpl({
|
||||
);
|
||||
const composerInput = useMemo<ComposerInput>(
|
||||
() => ({
|
||||
locked: isComposerLocked,
|
||||
textSource,
|
||||
setText: replaceUserInput,
|
||||
insertText: insertUserInput,
|
||||
attachments,
|
||||
changeAttachments: setSelectedAttachments,
|
||||
}),
|
||||
[attachments, insertUserInput, replaceUserInput, setSelectedAttachments, textSource],
|
||||
[
|
||||
attachments,
|
||||
insertUserInput,
|
||||
isComposerLocked,
|
||||
replaceUserInput,
|
||||
setSelectedAttachments,
|
||||
textSource,
|
||||
],
|
||||
);
|
||||
|
||||
const runClientSlashCommand = useCallback(
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { ComposerTextSource } from "@/composer/text-source";
|
||||
* control reaches the prompt without knowing which screen mounted the composer.
|
||||
*/
|
||||
export interface ComposerInput {
|
||||
/** The prompt is held while the composer submits; the built-in input is read-only meanwhile. */
|
||||
locked: boolean;
|
||||
textSource: ComposerTextSource;
|
||||
setText: (text: string) => void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
@@ -25,6 +27,8 @@ export interface ComposerInput {
|
||||
*/
|
||||
export interface ComposerAgent {
|
||||
context: "agent" | "draft" | "new-workspace";
|
||||
/** The agent is being created; the built-in controls are disabled meanwhile. */
|
||||
locked: boolean;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
mode: string | null;
|
||||
@@ -72,3 +76,10 @@ export function useComposerInput(): ComposerInput | null {
|
||||
export function useComposerAgent(): ComposerAgent | null {
|
||||
return useContext(ComposerAgentContext);
|
||||
}
|
||||
|
||||
/** True while the composer holds its prompt or its agent's settings for a submit in flight. */
|
||||
export function useComposerLocked(): boolean {
|
||||
const input = useComposerInput();
|
||||
const agent = useComposerAgent();
|
||||
return Boolean(input?.locked || agent?.locked);
|
||||
}
|
||||
|
||||
@@ -59,10 +59,11 @@ const issue = {
|
||||
resourceType: "issue",
|
||||
};
|
||||
|
||||
function fakeAgent(context: ComposerAgent["context"]) {
|
||||
function fakeAgent(context: ComposerAgent["context"], locked = false) {
|
||||
const calls: string[] = [];
|
||||
const agent: ComposerAgent = {
|
||||
context,
|
||||
locked,
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: "default",
|
||||
@@ -84,9 +85,10 @@ function fakeAgent(context: ComposerAgent["context"]) {
|
||||
return { agent, calls };
|
||||
}
|
||||
|
||||
function fakeInput(initial: UserComposerAttachment[] = []) {
|
||||
function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
|
||||
const state = { text: "", attachments: initial };
|
||||
const input: ComposerInput = {
|
||||
locked,
|
||||
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
|
||||
setText: (text) => {
|
||||
state.text = text;
|
||||
@@ -189,6 +191,46 @@ describe("toPluginComposer", () => {
|
||||
expect(state.text).toBe("ab");
|
||||
});
|
||||
|
||||
it("refuses every prompt and agent command while the composer is locked", async () => {
|
||||
const { agent, calls } = fakeAgent("new-workspace", true);
|
||||
const { input, state } = fakeInput([], true);
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
|
||||
expect(() => composer.insertText("b")).toThrow("The composer is locked");
|
||||
await expect(composer.setProvider("codex")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setModel("gpt")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setMode("plan")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setThinking("high")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setMetadata({ flag: null })).rejects.toThrow("The composer is locked");
|
||||
expect(state.text).toBe("");
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("hands out options and metadata a plugin cannot change", () => {
|
||||
const { agent } = fakeAgent("draft");
|
||||
agent.metadata = { nested: { list: [1] } };
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(composer.agent.options)).toBe(true);
|
||||
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
|
||||
expect(Object.isFrozen((composer.agent.metadata as { nested: object }).nested)).toBe(true);
|
||||
expect(agent.metadata).toEqual({ nested: { list: [1] } });
|
||||
expect(Object.isFrozen(agent.metadata)).toBe(false);
|
||||
});
|
||||
|
||||
it("adds and removes the plugin's own attachments only", () => {
|
||||
const { agent } = fakeAgent("agent");
|
||||
const other: UserComposerAttachment = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerAgent, ComposerInput } from "@/composer/session";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment } from "../attachments/model";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
|
||||
function isPluginAttachment(
|
||||
attachment: UserComposerAttachment,
|
||||
@@ -19,6 +20,17 @@ function isPluginAttachment(
|
||||
);
|
||||
}
|
||||
|
||||
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
|
||||
|
||||
function assertUnlocked(locked: boolean): void {
|
||||
if (locked) throw new Error(LOCKED_MESSAGE);
|
||||
}
|
||||
|
||||
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
|
||||
assertUnlocked(agent.locked);
|
||||
await command();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plugin sees of the composer it sits in. `agent` and `input` are the composer's own
|
||||
* halves, so the plugin's commands reach the same models the built-in controls use.
|
||||
@@ -44,11 +56,17 @@ export function toPluginComposer(input: {
|
||||
model: agent.model,
|
||||
mode: agent.mode,
|
||||
thinking: agent.thinking,
|
||||
options: agent.options as Record<string, JsonValue>,
|
||||
metadata: agent.metadata,
|
||||
options: cloneAndFreeze(agent.options as Record<string, JsonValue>),
|
||||
metadata: cloneAndFreeze(agent.metadata),
|
||||
},
|
||||
setPrompt: (text) => {
|
||||
assertUnlocked(composerInput.locked);
|
||||
composerInput.setText(text);
|
||||
},
|
||||
insertText: (text) => {
|
||||
assertUnlocked(composerInput.locked);
|
||||
composerInput.insertText(text);
|
||||
},
|
||||
setPrompt: composerInput.setText,
|
||||
insertText: composerInput.insertText,
|
||||
addAttachment({ sourceId, item }) {
|
||||
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
|
||||
@@ -72,11 +90,12 @@ export function toPluginComposer(input: {
|
||||
current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)),
|
||||
);
|
||||
},
|
||||
setProvider: agent.setProvider,
|
||||
setModel: agent.setModel,
|
||||
setMode: agent.setMode,
|
||||
setThinking: agent.setThinking,
|
||||
setOptions: agent.setOptions,
|
||||
setMetadata: agent.setMetadata,
|
||||
setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)),
|
||||
setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)),
|
||||
setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)),
|
||||
setThinking: (thinkingOptionId) =>
|
||||
whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)),
|
||||
setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)),
|
||||
setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** A deep copy no plugin can change. Plugins read data through it, never the host's own objects. */
|
||||
export function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useComposerControlSurface,
|
||||
type ComposerControlItem,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerLocked } from "@/composer/session";
|
||||
import { parsePluginIcon, pluginIconComponent } from "../icons";
|
||||
|
||||
/** Icons resolve at render, so an invalid one throws to the item's boundary. */
|
||||
@@ -21,12 +22,13 @@ function PluginComposerControl({
|
||||
disabled: boolean | undefined;
|
||||
}) {
|
||||
const surface = useComposerControlSurface();
|
||||
const locked = useComposerLocked();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<ComposerControl
|
||||
item={item}
|
||||
surface={surface}
|
||||
disabled={disabled}
|
||||
disabled={disabled || locked}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
|
||||
type AssistantPhase = "streaming" | "complete";
|
||||
@@ -9,18 +10,6 @@ const projectionCache = new WeakMap<
|
||||
WeakMap<StreamItem, Partial<Record<AssistantPhase, StreamItem[]>>>
|
||||
>();
|
||||
|
||||
function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata, agentMessage } = data;
|
||||
const base = {
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
} from "./migration";
|
||||
import { createDraftPersistStorage } from "./persistence";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { applyMetadataPatch } from "@/composer/draft/metadata";
|
||||
|
||||
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||
|
||||
@@ -49,6 +51,9 @@ interface DraftStoreActions {
|
||||
draftKey: string;
|
||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||
}) => void;
|
||||
/** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */
|
||||
patchDraftMetadata: (input: { draftKey: string; patch: Record<string, JsonValue> }) => void;
|
||||
clearDraftMetadata: (input: { draftKey: string }) => void;
|
||||
attachWorkspaceFile: (input: {
|
||||
draftKey: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
@@ -60,6 +65,11 @@ interface DraftStoreActions {
|
||||
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
/**
|
||||
* Kept beside the prompt so a composer that remounts finds it. Not persisted, and not cleared
|
||||
* with an emptied prompt: a plugin's choice outlives the text until the draft is sent.
|
||||
*/
|
||||
metadataByDraftKey: Record<string, Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
@@ -256,6 +266,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
metadataByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
const record = get().drafts[draftKey];
|
||||
@@ -374,6 +385,25 @@ export const useDraftStore = create<DraftStore>()(
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
patchDraftMetadata: ({ draftKey, patch }) => {
|
||||
set((state) => {
|
||||
const next = applyMetadataPatch(state.metadataByDraftKey[draftKey] ?? {}, patch);
|
||||
const { [draftKey]: _previous, ...others } = state.metadataByDraftKey;
|
||||
return {
|
||||
metadataByDraftKey:
|
||||
Object.keys(next).length > 0 ? { ...others, [draftKey]: next } : others,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearDraftMetadata: ({ draftKey }) => {
|
||||
set((state) => {
|
||||
if (!(draftKey in state.metadataByDraftKey)) return state;
|
||||
const { [draftKey]: _removed, ...others } = state.metadataByDraftKey;
|
||||
return { metadataByDraftKey: others };
|
||||
});
|
||||
},
|
||||
|
||||
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||
await get().hydrateDraftInput({ draftKey });
|
||||
set((state) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useDraftStore } from "./index";
|
||||
|
||||
const draftKey = "draft:metadata";
|
||||
|
||||
describe("a draft's plugin metadata", () => {
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {}, metadataByDraftKey: {} });
|
||||
});
|
||||
|
||||
it("is there when the draft's composer mounts again", () => {
|
||||
const store = useDraftStore.getState();
|
||||
store.editDraftText({ draftKey, text: "review this" });
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true, "plugin.tag": "a" } });
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.tag": null } });
|
||||
|
||||
// A remount reads the store again; nothing from the unmounted composer is kept.
|
||||
const remounted = useDraftStore.getState();
|
||||
expect(remounted.getDraftInput(draftKey)?.text).toBe("review this");
|
||||
expect(remounted.metadataByDraftKey[draftKey]).toEqual({ "plugin.review": true });
|
||||
});
|
||||
|
||||
it("is dropped with the prompt once the draft is sent, and not before", () => {
|
||||
const store = useDraftStore.getState();
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true } });
|
||||
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toEqual({
|
||||
"plugin.review": true,
|
||||
});
|
||||
|
||||
store.clearDraftMetadata({ draftKey });
|
||||
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user