diff --git a/packages/app/src/composer/agent-controls/composer-control.test.tsx b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx similarity index 98% rename from packages/app/src/composer/agent-controls/composer-control.test.tsx rename to packages/app/src/composer/agent-controls/composer-control.browser.test.tsx index 84cc5659c9..8a4905b235 100644 --- a/packages/app/src/composer/agent-controls/composer-control.test.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx @@ -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"; diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 877493cac1..fb23fa1eb5 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -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, diff --git a/packages/app/src/composer/draft/composer-agent.ts b/packages/app/src/composer/draft/composer-agent.ts index 5aa0dab0c6..a7e4f7dca3 100644 --- a/packages/app/src/composer/draft/composer-agent.ts +++ b/packages/app/src/composer/draft/composer-agent.ts @@ -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, diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 9b93228827..d39f5af16d 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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 = {}; type AttachmentUpdater = | UserComposerAttachment[] @@ -197,18 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [saveDraft], ); - const [metadata, setMetadataState] = useState>({}); + const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA); const setMetadata = useCallback( (patch: Record) => - 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], ); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index f277a82ea3..a41716a13f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1435,13 +1435,21 @@ function ComposerContentImpl({ ); const composerInput = useMemo( () => ({ + locked: isComposerLocked, textSource, setText: replaceUserInput, insertText: insertUserInput, attachments, changeAttachments: setSelectedAttachments, }), - [attachments, insertUserInput, replaceUserInput, setSelectedAttachments, textSource], + [ + attachments, + insertUserInput, + isComposerLocked, + replaceUserInput, + setSelectedAttachments, + textSource, + ], ); const runClientSlashCommand = useCallback( diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx index 7f79f02b5f..73892f0d44 100644 --- a/packages/app/src/composer/session.tsx +++ b/packages/app/src/composer/session.tsx @@ -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); +} diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts index e3c7546476..aa5549e03c 100644 --- a/packages/app/src/plugins/composer-items/model.test.ts +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -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 = { diff --git a/packages/app/src/plugins/composer-items/model.ts b/packages/app/src/plugins/composer-items/model.ts index 8a43effebf..8b7b73f27e 100644 --- a/packages/app/src/plugins/composer-items/model.ts +++ b/packages/app/src/plugins/composer-items/model.ts @@ -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): Promise { + 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, - metadata: agent.metadata, + options: cloneAndFreeze(agent.options as Record), + 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)), }; } diff --git a/packages/app/src/plugins/freeze.ts b/packages/app/src/plugins/freeze.ts new file mode 100644 index 0000000000..9735d872d8 --- /dev/null +++ b/packages/app/src/plugins/freeze.ts @@ -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(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; +} diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx index 450972e727..eb4d05a09b 100644 --- a/packages/app/src/plugins/react-native/composer-controls.tsx +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -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 ( diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 0750bdb66a..afc26d07a7 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -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>> >(); -function cloneAndFreeze(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 } = data; const base = { diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 9836d3dfec..755b7a25fe 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -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; }) => void; + /** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */ + patchDraftMetadata: (input: { draftKey: string; patch: Record }) => void; + clearDraftMetadata: (input: { draftKey: string }) => void; attachWorkspaceFile: (input: { draftKey: string; attachment: WorkspaceFileComposerAttachment; @@ -60,6 +65,11 @@ interface DraftStoreActions { interface DraftStoreRuntimeState { attachmentFocusRequestByDraftKey: Record; + /** + * 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>; } type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; @@ -256,6 +266,7 @@ export const useDraftStore = create()( drafts: {}, createModalDraft: null, attachmentFocusRequestByDraftKey: {}, + metadataByDraftKey: {}, getDraftInput: (draftKey) => { const record = get().drafts[draftKey]; @@ -374,6 +385,25 @@ export const useDraftStore = create()( 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) => { diff --git a/packages/app/src/stores/draft-store/metadata.test.ts b/packages/app/src/stores/draft-store/metadata.test.ts new file mode 100644 index 0000000000..7f2f7a52d6 --- /dev/null +++ b/packages/app/src/stores/draft-store/metadata.test.ts @@ -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(); + }); +}); diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index a85976e1bf..e03104d01b 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -116,3 +116,8 @@ export const Maximize2 = StubIcon; export const Workflow = StubIcon; export const ZoomIn = StubIcon; export const ZoomOut = StubIcon; +export const ListTodo = StubIcon; +export const Shield = StubIcon; +export const ShieldEllipsis = StubIcon; +export const ShieldPlus = StubIcon; +export const Zap = StubIcon; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 50609e242b..453f6899d1 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1969,6 +1969,11 @@ callback, so `await composer.setProvider("codex")` followed by `await composer.s changes both. A command's promise resolves once the change has taken effect, and `composer.agent` shows it on the next render. +While the New workspace screen creates the workspace and its agent, the composer is locked. Your +controls render disabled, `setPrompt` and `insertText` throw, and the agent commands reject with +"The composer is locked while the agent is being created." `composer.agent.options` and +`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`. + A composer item counts toward how much fits before Paseo knows what it renders. An item that renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a control. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index d5b138bc6f..9ed5eb1b8a 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1659,6 +1659,8 @@ export default function contribute(client: PluginClientContext) { 每个命令都按运行时 composer 的当前状态执行,而不是按创建回调的那次渲染,所以 `await composer.setProvider("codex")` 之后再 `await composer.setModel("gpt")` 会同时生效。命令的 Promise 在更改生效后完成,`composer.agent` 在下一次渲染时反映它。 +新建工作区界面在创建工作区及其 Agent 期间会锁定 composer。你的控件显示为禁用,`setPrompt` 和 `insertText` 会抛出错误,Agent 命令会以 "The composer is locked while the agent is being created." 拒绝。`composer.agent.options` 和 `composer.agent.metadata` 是冻结的副本;请通过 `setOptions` 和 `setMetadata` 修改它们。 + 在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。 [Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。