diff --git a/docs/plugins.md b/docs/plugins.md index 222daccf20..7f1e962d86 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -676,10 +676,16 @@ implementations provide it: without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the settings are the session store's agent, and `model` and `thinking` resolve through the provider catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the - picker shows. The model loads the catalog itself and reads `null` meanwhile. The mounted - `AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor; - without it the model edits the stored prompt. The daemon is the authority for `setModel` and - `setThinking`. + picker shows. The model loads the catalog itself and resolves with no models until it lands, as the picker does, + so a read never differs from what the picker shows. The mounted + `AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor. + Without a live input the model writes the stored prompt with `replaceDraftText`, which also asks + a mounted input to show it. A workspace the user left stays mounted, display none, with no + input ref: `ComposerInput.isLive()` is how the model tells. Closing a root agent's tab archives + it, so a running agent with no mounted composer at all only exists as a subagent whose tab was + closed. The daemon is the authority for `setModel` and `setThinking`, and validates a thinking + option against the model the agent runs: the runtime model when the catalog lists it, else the + configured one. - A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments), `DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock. @@ -689,8 +695,14 @@ implementations and `toPluginComposer`; never branch on `context` in the host. `useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`): `PluginInstallationProvider` provides the plugin's source to every surface, and -`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` resolves -from the last viewed workspace on the plugin's host and its layout's focused tab. +`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` is the +agent Command Center registration hands to agent items, published through `plugins/focused-agent.ts` +(null outside a workspace route). The source reads that module, not the navigation or layout +stores, so the plugin runtime does not import them. The remembered workspace selection is +navigation restore state and is not the focused agent. The query-cache subscriptions react to +`updated` events only: a `useQuery` in another component's render announces `added`, and a +subscriber that updates then triggers React's "update a component while rendering a different one" +warning. Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`), diff --git a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts index 142279d16d..4878ab76e6 100644 --- a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts +++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts @@ -2,7 +2,7 @@ import { test } from "../support/fixtures"; import { CHECKLIST, withComposerToolsExample, - withPluginWritingToAnotherAgent, + withPluginWritingToAnotherWorkspace, } from "../support/helpers/plugin-composers-by-reference"; test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({ @@ -39,12 +39,15 @@ test("the panel follows which agent is focused", async ({ page }) => { }); }); -test("a plugin writes to another agent's composer before its tab is opened", async ({ page }) => { +test("a plugin writes to an agent's composer while its workspace is not on screen", async ({ + page, +}) => { test.setTimeout(120_000); - await withPluginWritingToAnotherAgent(page, async (author) => { - await author.openTheOtherAgent(); - await author.insertFromCommandCenter(); + await withPluginWritingToAnotherWorkspace(page, async (author) => { await author.openTargetAgent(); + await author.goToAnotherWorkspace(); + await author.insertFromCommandCenter(); + await author.returnToTargetWorkspace(); await author.expectComposerText("A note from a plugin"); }); }); diff --git a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts index 5218208f07..d761ba966d 100644 --- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -4,10 +4,11 @@ import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { openCommandCenter } from "./command-center"; -import { buildAgentRoute, openAgentRoute } from "./mock-agent"; +import { openAgentRoute } from "./mock-agent"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { seedWorkspace } from "./seed-client"; +import { getServerId } from "./server-id"; import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs"; const WIDE = { width: 1440, height: 900 }; @@ -125,21 +126,22 @@ export async function withComposerToolsExample( } /** - * A plugin that names an agent by id from a Command Center item, while another agent is open: the - * target's tab is not what the plugin acts on. + * A plugin that names an agent by id from a Command Center item while the user is in another + * workspace: the target's workspace is not on screen, so its composer is not mounted. */ -export async function withPluginWritingToAnotherAgent( +export async function withPluginWritingToAnotherWorkspace( page: Page, run: (author: { - openTheOtherAgent(): Promise; - insertFromCommandCenter(): Promise; openTargetAgent(): Promise; + goToAnotherWorkspace(): Promise; + insertFromCommandCenter(): Promise; + returnToTargetWorkspace(): Promise; expectComposerText(text: string): Promise; }) => Promise, ) { const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" }); + const elsewhere = await seedWorkspace({ repoPrefix: "composer-away-", title: "Composer away" }); const target = await createMockAgent(workspace, "Target agent"); - const other = await createMockAgent(workspace, "Other agent"); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-ref-")); @@ -162,22 +164,33 @@ export async function withPluginWritingToAnotherAgent( return () => {}; }`, ); + const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`); + const workspaceRow = (id: string) => + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`); try { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); await page.setViewportSize(WIDE); await gotoAppShell(page); await run({ - openTheOtherAgent: () => - test.step("open a different agent", () => - openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id })), + openTargetAgent: () => + test.step("open the agent the plugin will write to", async () => { + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id }); + await expect(prompt(page)).toBeVisible({ timeout: 30_000 }); + }), + goToAnotherWorkspace: () => + test.step("go to another workspace, so the agent's composer is not on screen", async () => { + await workspaceRow(elsewhere.workspaceId).click(); + // The app keeps the workspace it left mounted, out of sight. + await expect(targetTab().first()).toBeHidden(); + }), insertFromCommandCenter: () => test.step("run the plugin's Command Center item", () => runCommand(page, "Composer ref: add a note")), - openTargetAgent: () => - test.step("open the agent the plugin wrote to", async () => { - await page.goto(buildAgentRoute(workspace.workspaceId, target.id)); - await expect(prompt(page)).toBeVisible({ timeout: 30_000 }); + returnToTargetWorkspace: () => + test.step("return to the agent's workspace without reloading the app", async () => { + await workspaceRow(workspace.workspaceId).click(); + await expect(targetTab().first()).toBeVisible({ timeout: 30_000 }); }), expectComposerText: (text) => test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)), @@ -187,6 +200,7 @@ export async function withPluginWritingToAnotherAgent( await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); await client.close(); await rm(directory, { recursive: true, force: true }); + await elsewhere.cleanup(); await workspace.cleanup(); } } diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts index 343e756f8a..c02000dd2d 100644 --- a/packages/app/src/composer/agent-composer/index.test.ts +++ b/packages/app/src/composer/agent-composer/index.test.ts @@ -129,7 +129,7 @@ function composer() { } beforeEach(() => { - useDraftStore.setState({ drafts: {} }); + useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} }); }); afterEach(() => { @@ -137,6 +137,31 @@ afterEach(() => { }); describe("agent composer", () => { + it("reads without notifying anyone, so a read during a render updates no other component", () => { + const running = agent(); + const other = agent({ id: "agent-2", cwd: "/other-repo" }); + openSession(running); + useSessionStore.getState().setAgents( + serverId, + new Map([ + [running.id, running], + [other.id, other], + ]), + ); + const queryClient = new QueryClient(); + const watched = createAgentComposer(serverId, running.id, { queryClient }); + const reader = createAgentComposer(serverId, other.id, { queryClient }); + let notifications = 0; + const stop = watched.subscribe(() => { + notifications += 1; + }); + const before = notifications; + // The first read of another catalog creates its query in the shared cache. + reader.getState(); + expect(notifications).toBe(before); + stop(); + }); + it("keeps the prompt in the stored draft while no composer is mounted", () => { openSession(agent()); const model = composer(); @@ -153,6 +178,7 @@ describe("agent composer", () => { const model = composer(); const mounted: string[] = []; const detach = model.attachInput({ + isLive: () => true, setText: (text) => mounted.push(`set:${text}`), insertText: (text) => mounted.push(`insert:${text}`), }); @@ -165,6 +191,24 @@ describe("agent composer", () => { expect(model.getState().prompt).toBe("stored"); }); + it("stores the prompt and asks the draft's input to show it while the composer is mounted off screen", () => { + openSession(agent()); + const model = composer(); + const mounted: string[] = []; + model.attachInput({ + isLive: () => false, + setText: (text) => mounted.push(`set:${text}`), + insertText: (text) => mounted.push(`insert:${text}`), + }); + model.insertText("while away"); + expect(mounted).toEqual([]); + expect(model.getState().prompt).toBe("while away"); + expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(1); + model.setPrompt("replaced"); + expect(model.getState().prompt).toBe("replaced"); + expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(2); + }); + it("notifies subscribers of a stored prompt and returns the same state until something changes", () => { openSession(agent()); const model = composer(); @@ -237,31 +281,40 @@ describe("agent composer", () => { ); }); + function pickerShows(running: Agent, catalog: ProviderSnapshotEntry[] | null) { + const picker = resolveAgentModelSelection({ + models: filterSelectableModels(catalog?.[0]?.models ?? null), + runtimeModelId: running.runtimeInfo?.model ?? null, + configuredModelId: running.model, + runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: running.thinkingOptionId, + }); + return { model: picker.activeModelId, thinking: picker.selectedThinkingId }; + } + it.each([ - ["an alias of a listed model", { runtimeInfo: { model: "sonnet" } }, "claude-sonnet-4", "low"], - ["no reported model", { model: null }, "opus", "high"], - ["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "low"], + [ + "an alias of a listed model", + { runtimeInfo: { model: "sonnet" } }, + "sonnet", + "claude-sonnet-4", + "low", + ], + ["no reported model", { model: null }, null, "opus", "high"], + ["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "opus", "low"], ] as const)( - "reads the model and thinking the picker shows for %s, once the catalog loads", - async (_name, overrides, expectedModel, expectedThinking) => { + "shows what the picker shows for %s, before the catalog and after it loads", + async (_name, overrides, modelBefore, modelAfter, thinkingAfter) => { const running = agent(overrides as unknown as Partial); openSession(running); const model = composer(); - expect(model.getState()).toMatchObject({ model: null, thinking: null }); + // No catalog yet: the picker resolves with no models. + expect(model.getState()).toMatchObject(pickerShows(running, null)); + expect(model.getState().model).toBe(modelBefore); const stop = model.subscribe(() => {}); - await until(() => model.getState().model, expectedModel); - const picker = resolveAgentModelSelection({ - models: filterSelectableModels(entries[0]?.models ?? null), - runtimeModelId: running.runtimeInfo?.model ?? null, - configuredModelId: running.model, - runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null, - explicitThinkingOptionId: running.thinkingOptionId, - }); - expect(model.getState()).toMatchObject({ - model: picker.activeModelId, - thinking: picker.selectedThinkingId, - }); - expect(model.getState().thinking).toBe(expectedThinking); + await until(() => model.getState().model, modelAfter); + expect(model.getState()).toMatchObject(pickerShows(running, entries)); + expect(model.getState().thinking).toBe(thinkingAfter); stop(); }, ); diff --git a/packages/app/src/composer/agent-composer/index.ts b/packages/app/src/composer/agent-composer/index.ts index d2c3511648..7d6582da97 100644 --- a/packages/app/src/composer/agent-composer/index.ts +++ b/packages/app/src/composer/agent-composer/index.ts @@ -18,6 +18,8 @@ import type { UserComposerAttachment } from "@/attachments/types"; /** The mounted composer's input. While attached, prompt commands go through it. */ export interface MountedComposerInput { + /** False while the composer is retained but its input is not on screen. */ + isLive(): boolean; setText(text: string): void; /** Inserts at the cursor, replacing the selection. */ insertText(text: string): void; @@ -115,16 +117,19 @@ export function createAgentComposer( if (!session.client) return { status: "loading" }; if (!catalogRequests.has(key)) { catalogRequests.add(key); - void loadCatalog(query, key); + // Creating the query tells every query-cache subscriber. A read can run in another + // component's render, so the load starts after it. + queueMicrotask(() => void loadCatalog(query, key)); } return { status: "loading" }; } function selectModel(agent: Agent, catalog: Catalog) { - if (catalog.status !== "ready") return null; + // Until the catalog lands there are no models, which is what the picker resolves with too. + const entries = catalog.status === "ready" ? catalog.entries : null; return resolveAgentModelSelection({ models: filterSelectableModels( - resolveProviderSnapshotEntry(catalog.entries ?? undefined, agent.provider)?.models ?? null, + resolveProviderSnapshotEntry(entries ?? undefined, agent.provider)?.models ?? null, ), runtimeModelId: agent.runtimeInfo?.model ?? null, configuredModelId: agent.model, @@ -145,8 +150,6 @@ export function createAgentComposer( prompt: draft?.text ?? "", attachments: draft?.attachments ?? NO_ATTACHMENTS, provider: agent?.provider ?? null, - // Null until the catalog lands, so a read never changes from the configured id to the - // catalog's canonical one. model: selection?.activeModelId ?? null, mode: agent?.currentModeId ?? null, thinking: selection?.selectedThinkingId ?? null, @@ -184,7 +187,11 @@ export function createAgentComposer( const stops = [ useSessionStore.subscribe(notify), useDraftStore.subscribe(notify), - deps.queryClient.getQueryCache().subscribe(notify), + // Only a query's update changes the catalog. A query another component creates while it + // renders is announced too, and a subscriber that updates then makes React warn. + deps.queryClient.getQueryCache().subscribe((event) => { + if (event.type === "updated") notify(); + }), ]; return () => { listeners.delete(listener); @@ -198,14 +205,14 @@ export function createAgentComposer( }; }, setPrompt(text) { - if (mounted) mounted.setText(text); - else useDraftStore.getState().editDraftText({ draftKey, text }); + if (mounted?.isLive()) mounted.setText(text); + else useDraftStore.getState().replaceDraftText({ draftKey, text }); }, insertText(text) { - if (mounted) mounted.insertText(text); + if (mounted?.isLive()) mounted.insertText(text); else { const current = useDraftStore.getState().getDraftInput(draftKey)?.text ?? ""; - useDraftStore.getState().editDraftText({ draftKey, text: current + text }); + useDraftStore.getState().replaceDraftText({ draftKey, text: current + text }); } }, changeAttachments(updater) { diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 974b976099..80c9e55823 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1135,6 +1135,7 @@ export const AgentControls = memo(function AgentControls({ () => composerInput ? composer.attachInput({ + isLive: composerInput.isLive, setText: composerInput.setText, insertText: composerInput.insertText, }) diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 82de9d3d73..871650f270 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -144,6 +144,7 @@ describe("useAgentInputDraft live contract", () => { drafts: {}, createModalDraft: null, attachmentFocusRequestByDraftKey: {}, + textReplacementRequestByDraftKey: {}, }); }); @@ -486,6 +487,37 @@ describe("useAgentInputDraft live contract", () => { }); }); + it("shows text another module put in the draft while the input is retained off screen", async () => { + let latest: ReturnType | null = null; + function getLatest(): ReturnType { + if (!latest) throw new Error("Expected hook result"); + return latest; + } + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:replaced" }); + return null; + } + const container = document.getElementById("root"); + if (!container) throw new Error("Missing root container"); + const root = createTestRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + const before = getLatest().textReplacement.key; + + await act(async () => { + useDraftStore.getState().replaceDraftText({ draftKey: "draft:replaced", text: "from away" }); + }); + + expect(getLatest().textReplacement).toMatchObject({ kind: "replace", text: "from away" }); + expect(getLatest().textReplacement.key).not.toBe(before); + expect(getLatest().textSource.getSnapshot()).toBe("from away"); + }); + it("clear resets text and attachments", async () => { let latest: ReturnType | null = null; const image: AttachmentMetadata = { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c6640a40d7..3a445df10c 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -154,6 +154,20 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [draftKey], ); + // Text another module put in the draft (a plugin writing to a composer that is not on screen) + // reaches the input the way a rewind restore does. + const replacementRequest = useDraftStore( + (state) => state.textReplacementRequestByDraftKey[draftKey] ?? 0, + ); + const shownReplacementRequestRef = useRef({ draftKey, request: replacementRequest }); + useEffect(() => { + const shown = shownReplacementRequestRef.current; + if (shown.draftKey === draftKey && shown.request === replacementRequest) return; + shownReplacementRequestRef.current = { draftKey, request: replacementRequest }; + textPublication.cancel(); + publishTextReplacement("replace", textSource.getSnapshot()); + }, [draftKey, publishTextReplacement, replacementRequest, textPublication, textSource]); + const editText = useCallback( (nextText: string) => { if (isWeb) { diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 0c9e2aa5d0..bd1533d8af 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1439,6 +1439,7 @@ function ComposerContentImpl({ () => ({ locked: isComposerLocked, isLocked: () => isComposerLockedRef.current, + isLive: () => messageInputRef.current !== null, textSource, setText: replaceUserInput, insertText: insertUserInput, diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx index 52d52d0c7d..eaa96130c6 100644 --- a/packages/app/src/composer/session.tsx +++ b/packages/app/src/composer/session.tsx @@ -12,6 +12,11 @@ export interface ComposerInput { locked: boolean; /** `locked` as it is now. A command a plugin kept from an earlier render asks this. */ isLocked: () => boolean; + /** + * The input is on screen. A retained composer in a workspace that is not shown has none, so it + * cannot take the cursor-aware commands below. + */ + isLive: () => boolean; textSource: ComposerTextSource; setText: (text: string) => void; /** Inserts at the cursor, replacing the selection. */ diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index faf3b9215f..2e5090d88f 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -1,5 +1,5 @@ import { usePathname } from "expo-router"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useCommandCenterActions } from "@/command-center/provider"; import { useToast } from "@/contexts/toast-context"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; @@ -9,6 +9,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createPluginClientStateSource } from "../client-state/source"; +import { publishFocusedAgent } from "../focused-agent"; import { hostIdFromPathname } from "../routes"; import { useInstalledPlugins } from "../registry"; import { buildPluginCommandCenterContributions } from "./contributions"; @@ -37,6 +38,12 @@ export function PluginCommandCenterActions() { normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) ); }); + const handedAgentId = agentExists ? focusedAgentId : null; + // The agent a plugin's `{ focused: true }` composer names is the one handed to agent items. + useEffect(() => { + publishFocusedAgent(serverId && handedAgentId ? { serverId, agentId: handedAgentId } : null); + return () => publishFocusedAgent(null); + }, [handedAgentId, serverId]); const client = useHostRuntimeClient(serverId ?? ""); const installed = useInstalledPlugins(); const plugins = useMemo( @@ -54,23 +61,13 @@ export function PluginCommandCenterActions() { plugins, state: stateSource, workspaceId: workspaceExists ? workspaceId : null, - agentId: agentExists ? focusedAgentId : null, + agentId: handedAgentId, navigation: createPluginNavigation({ serverId, workspaceId }), reportError(error) { toast.error(error instanceof Error ? error.message : String(error)); }, }); - }, [ - agentExists, - client, - focusedAgentId, - plugins, - serverId, - stateSource, - toast, - workspaceExists, - workspaceId, - ]); + }, [client, handedAgentId, plugins, serverId, stateSource, toast, workspaceExists, workspaceId]); useCommandCenterActions({ sourceId: serverId ? `plugins:${serverId}` : "plugins", diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts index 5d0c3cef3a..191edec873 100644 --- a/packages/app/src/plugins/composer-items/model.test.ts +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -96,6 +96,7 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) { const input: ComposerInput = { locked, isLocked: () => locked, + isLive: () => true, textSource: { getSnapshot: () => state.text, subscribe: () => () => {} }, setText: (text) => { state.text = text; diff --git a/packages/app/src/plugins/composer-items/source.test.ts b/packages/app/src/plugins/composer-items/source.test.ts new file mode 100644 index 0000000000..4e55fb813c --- /dev/null +++ b/packages/app/src/plugins/composer-items/source.test.ts @@ -0,0 +1,264 @@ +import { defineRpc } from "@getpaseo/plugin"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin"; +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; +import { QueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { queryClient } from "@/data/query-client"; +import { useDraftStore } from "@/stores/draft-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { buildPluginCommandCenterContributions } from "../command-center/contributions"; +import { publishFocusedAgent } from "../focused-agent"; +import type { InstalledPlugin } from "../types"; +import { pluginComposersSource } from "./source"; + +const serverId = "host-1"; + +function plugin(): InstalledPlugin { + return { + id: "review", + serverId, + clientBundle: "bundle", + lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), + queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, + subscribe: () => { + throw new Error("Subscriptions are not used here"); + }, + cleanup: () => {}, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + filePanels: [], + composerItems: [], + newWorkspaceItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [ + { + id: "issues", + title: "Issues", + pickerTitle: "Issues", + searchPlaceholder: "Search", + icon: "Bug", + search: defineRpc({ + name: "issues.search", + input: z.object({}), + output: z.array(z.object({})), + }), + }, + ], + themes: [], + timelineTransformers: [], + timelineRenderers: [], + }; +} + +function agent(id: string, archivedAt: Date | null = null): Agent { + return { + serverId, + id, + provider: "claude", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date(), + updatedAt: new Date(), + lastUserMessageAt: null, + lastActivityAt: new Date(), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "default", + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + workspaceId: "workspace-1", + model: null, + parentAgentId: null, + labels: {}, + archivedAt, + }; +} + +beforeEach(() => { + useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, {} as never); + store.setAgents( + serverId, + new Map([ + ["agent-1", agent("agent-1")], + ["agent-2", agent("agent-2", new Date())], + ]), + ); +}); + +afterEach(() => { + publishFocusedAgent(null); + useSessionStore.getState().clearSession(serverId); +}); + +describe("a plugin's composers by reference", () => { + it("names a running agent by id, and nothing for an unknown or archived one", () => { + const source = pluginComposersSource(plugin()); + expect(source.resolve({ agentId: "agent-1" })).toHaveLength(1); + expect(source.resolve({ agentId: "nope" })).toEqual([]); + expect(source.resolve({ agentId: "agent-2" })).toEqual([]); + }); + + it("follows the agent the Command Center hands out, and is empty when there is none", () => { + const source = pluginComposersSource(plugin()); + let notified = 0; + const stop = source.subscribe(() => { + notified += 1; + }); + expect(source.resolve({ focused: true })).toEqual([]); + + publishFocusedAgent({ serverId, agentId: "agent-1" }); + const [focused] = source.resolve({ focused: true }); + expect(focused?.context).toBe("agent"); + expect(notified).toBeGreaterThan(0); + + // Leaving the workspace for a plugin screen or New workspace publishes no agent. + publishFocusedAgent(null); + expect(source.resolve({ focused: true })).toEqual([]); + + // Another host's focused agent is not this plugin's. + publishFocusedAgent({ serverId: "host-2", agentId: "agent-1" }); + expect(source.resolve({ focused: true })).toEqual([]); + stop(); + }); + + it("is not told when another component creates a query while it renders", () => { + const source = pluginComposersSource(plugin()); + let notified = 0; + const stop = source.subscribe(() => { + notified += 1; + }); + // `useQuery` does this during a render, and a subscriber that updates then makes React warn + // that one component updated another while rendering. + queryClient.getQueryCache().build(queryClient, { queryKey: ["created-during-a-render"] }); + expect(notified).toBe(0); + stop(); + }); + + it("returns the same array until a composer changes", () => { + const source = pluginComposersSource(plugin()); + const first = source.resolve({ agentId: "agent-1" }); + expect(source.resolve({ agentId: "agent-1" })).toBe(first); + first[0]?.insertText("hello"); + const next = source.resolve({ agentId: "agent-1" }); + expect(next).not.toBe(first); + expect(next[0]?.prompt).toBe("hello"); + }); +}); + +describe("a Command Center item reaching an agent's composer by id", () => { + const snapshot: PluginAgentSnapshot = { + id: "agent-1", + workspaceId: "workspace-1", + provider: "claude", + status: "idle", + createdAt: "2026-08-16T10:00:00.000Z", + updatedAt: "2026-08-16T10:00:00.000Z", + lastActivityAt: "2026-08-16T10:00:00.000Z", + title: null, + cwd: "/repo", + model: null, + currentModeId: null, + thinkingOptionId: null, + requiresAttention: false, + attentionReason: null, + parentAgentId: null, + labels: {}, + metadata: {}, + }; + + const workspace: PluginWorkspaceSnapshot = { + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Repo", + projectRootPath: "/repo", + directory: "/repo", + projectKind: "git", + kind: "checkout", + name: "Repo", + title: null, + status: "running", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + }; + + function markForReview(rejection?: Error) { + const patches: unknown[] = []; + const client = { + async setAgentMetadata(_agentId: string, patch: unknown) { + patches.push(patch); + if (rejection) throw rejection; + }, + } as unknown as DaemonClient; + useSessionStore.getState().clearSession(serverId); + useSessionStore.getState().initializeSession(serverId, client); + useSessionStore.getState().setAgents(serverId, new Map([["agent-1", agent("agent-1")]])); + const installed = plugin(); + installed.commandCenterItems = [ + { + id: "mark", + title: "Mark for review", + icon: "Flag", + context: "agent", + async onSelect({ agent: handed, composers }) { + for (const composer of composers({ agentId: handed.id })) { + await composer.setMetadata({ needsReview: true }); + } + }, + }, + ]; + const reported: unknown[] = []; + const [item] = buildPluginCommandCenterContributions({ + plugins: [installed], + state: { + subscribe: () => () => {}, + getWorkspace: () => workspace, + getAgent: () => snapshot, + }, + workspaceId: "workspace-1", + agentId: "agent-1", + navigation: { + openSettings() {}, + openSurface() {}, + openWorkspacePanel() {}, + openAgentPanel() {}, + }, + reportError: (error) => reported.push(error), + }); + return { run: item?.run, patches, reported }; + } + + it("changes the agent's metadata through the daemon", async () => { + const { run, patches, reported } = markForReview(); + await run?.(); + expect(patches).toEqual([{ needsReview: true }]); + expect(reported).toEqual([]); + }); + + it("reports what the daemon rejected", async () => { + const { run, reported } = markForReview(new Error("metadata refused")); + await run?.(); + expect(reported).toEqual([new Error("metadata refused")]); + }); +}); diff --git a/packages/app/src/plugins/composer-items/source.ts b/packages/app/src/plugins/composer-items/source.ts index 16b851121e..233c794baa 100644 --- a/packages/app/src/plugins/composer-items/source.ts +++ b/packages/app/src/plugins/composer-items/source.ts @@ -4,13 +4,8 @@ import { agentComposer } from "@/composer/agent-composer"; import type { ComposerState } from "@/composer/model"; import { queryClient } from "@/data/query-client"; import { useDraftStore } from "@/stores/draft-store"; -import { - getLastWorkspaceSelection, - subscribeLastWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { getFocusedAgentId } from "../command-center/context"; +import { getFocusedAgent, subscribeFocusedAgent } from "../focused-agent"; import type { InstalledPlugin } from "../types"; import { toPluginComposer } from "./model"; @@ -44,13 +39,8 @@ function createSource(plugin: InstalledPlugin): PluginComposersSource { } function focusedAgentId(): string | null { - const selection = getLastWorkspaceSelection(); - if (selection?.serverId !== serverId) return null; - const layout = - useWorkspaceLayoutStore.getState().layoutByWorkspace[ - `${selection.serverId}:${selection.workspaceId}` - ] ?? null; - return getFocusedAgentId(layout); + const focused = getFocusedAgent(); + return focused?.serverId === serverId ? focused.agentId : null; } return { @@ -58,9 +48,12 @@ function createSource(plugin: InstalledPlugin): PluginComposersSource { const stops = [ useSessionStore.subscribe(listener), useDraftStore.subscribe(listener), - useWorkspaceLayoutStore.subscribe(listener), - subscribeLastWorkspaceSelection(listener), - queryClient.getQueryCache().subscribe(listener), + subscribeFocusedAgent(listener), + // The model catalog arrives as an update. A query another component creates while it + // renders is announced too, and a subscriber that updates then makes React warn. + queryClient.getQueryCache().subscribe((event) => { + if (event.type === "updated") listener(); + }), ]; return () => { for (const stop of stops) stop(); diff --git a/packages/app/src/plugins/focused-agent.ts b/packages/app/src/plugins/focused-agent.ts new file mode 100644 index 0000000000..d5a7121067 --- /dev/null +++ b/packages/app/src/plugins/focused-agent.ts @@ -0,0 +1,30 @@ +/** + * The agent the user has focused: the one the Command Center hands to agent items. The Command + * Center registration reads the router and the workspace layout, publishes the answer here, and + * publishes `null` outside a workspace. A plugin's `{ focused: true }` composer reads it from here, + * so both name the same agent and neither imports the navigation or layout stores. + */ +export interface FocusedAgent { + serverId: string; + agentId: string; +} + +let current: FocusedAgent | null = null; +const listeners = new Set<() => void>(); + +export function publishFocusedAgent(next: FocusedAgent | null): void { + if (current?.serverId === next?.serverId && current?.agentId === next?.agentId) return; + current = next; + for (const listener of listeners) listener(); +} + +export function getFocusedAgent(): FocusedAgent | null { + return current; +} + +export function subscribeFocusedAgent(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 28235213ac..c0085bba82 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -52,6 +52,11 @@ interface DraftStoreActions { update: (current: DraftInput) => DraftInput; }) => void; editDraftText: (input: { draftKey: string; text: string }) => void; + /** + * Sets the text on behalf of something other than the input, which then shows it. Use + * `editDraftText` for the input's own edits. + */ + replaceDraftText: (input: { draftKey: string; text: string }) => void; markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void; clearDraftInput: (input: { draftKey: string; @@ -71,6 +76,8 @@ interface DraftStoreActions { interface DraftStoreRuntimeState { attachmentFocusRequestByDraftKey: Record; + /** Counts `replaceDraftText`, so a mounted input knows to show text it did not type. */ + textReplacementRequestByDraftKey: 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. @@ -272,6 +279,7 @@ export const useDraftStore = create()( drafts: {}, createModalDraft: null, attachmentFocusRequestByDraftKey: {}, + textReplacementRequestByDraftKey: {}, metadataByDraftKey: {}, getDraftInput: (draftKey) => { @@ -349,6 +357,16 @@ export const useDraftStore = create()( }); }, + replaceDraftText: ({ draftKey, text }) => { + get().editDraftText({ draftKey, text }); + set((state) => ({ + textReplacementRequestByDraftKey: { + ...state.textReplacementRequestByDraftKey, + [draftKey]: (state.textReplacementRequestByDraftKey[draftKey] ?? 0) + 1, + }, + })); + }, + markDraftLifecycle: ({ draftKey, lifecycle }) => { set((state) => { const existing = state.drafts[draftKey]; diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 379a4c549f..16f3a23f0b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -96,10 +96,6 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { return selection; } -export function subscribeLastWorkspaceSelection(listener: () => void): () => void { - return lastWorkspaceSelectionStore.subscribe(listener); -} - export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null { return useSyncExternalStore( lastWorkspaceSelectionStore.subscribe, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 08ba83f5af..31af86a634 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5436,6 +5436,81 @@ test("a running agent rejects a model or thinking option its provider does not l expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined(); }); +test("a thinking option is checked against the model the agent runs, as the picker chooses it", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-runtime-model-")); + const provider: ProviderSnapshotEntry = { + provider: "codex", + status: "ready", + enabled: true, + models: [ + { + provider: "codex", + id: "configured", + label: "Configured", + isDefault: true, + thinkingOptions: [{ id: "configured-only", label: "Configured only" }], + }, + { + provider: "codex", + id: "runtime", + label: "Runtime", + thinkingOptions: [{ id: "runtime-only", label: "Runtime only" }], + }, + ], + modes: [], + }; + class RuntimeModelSession extends TestAgentSession { + constructor( + config: AgentSessionConfig, + private readonly runtimeModel: string, + ) { + super(config); + } + override async getRuntimeInfo() { + return { ...(await super.getRuntimeInfo()), model: this.runtimeModel }; + } + } + class RuntimeModelClient extends TestAgentClient { + constructor(private readonly runtimeModel: string) { + super(); + } + override async createSession(config: AgentSessionConfig): Promise { + return new RuntimeModelSession(config, this.runtimeModel); + } + } + async function agentRunning(runtimeModel: string) { + const manager = new AgentManager({ + clients: { codex: new RuntimeModelClient(runtimeModel) }, + registry: new AgentStorage(join(workdir, `agents-${runtimeModel}`), logger), + validateAgentSelection: async (input) => + validateAgentConfigurationAgainstProvider({ input, provider }), + logger, + idFactory: () => + `00000000-0000-4000-8000-${runtimeModel === "runtime" ? "000000000137" : "000000000138"}`, + }); + const snapshot = await manager.createAgent( + { provider: "codex", cwd: workdir, model: "configured" }, + undefined, + { workspaceId: undefined }, + ); + return { manager, id: snapshot.id }; + } + + // The session runs a listed model other than the configured one: its options are the ones shown. + const listed = await agentRunning("runtime"); + await expect(listed.manager.setAgentThinkingOption(listed.id, "configured-only")).rejects.toThrow( + "Thinking option 'configured-only' is not available", + ); + await listed.manager.setAgentThinkingOption(listed.id, "runtime-only"); + + // The session reports a model the catalog does not list: the picker falls back to the configured one. + const unlisted = await agentRunning("unlisted"); + await unlisted.manager.setAgentThinkingOption(unlisted.id, "configured-only"); + await expect( + unlisted.manager.setAgentThinkingOption(unlisted.id, "runtime-only"), + ).rejects.toThrow("Thinking option 'runtime-only' is not available"); +}); + test("setAgentThinkingOption adopts the session's effective clamped level", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-")); class ClampingSession extends TestAgentSession { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 968bf21988..636479bd98 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2282,6 +2282,35 @@ export class AgentManager { if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; ")); } + /** + * Checks the option against the model the agent runs, chosen as the model picker chooses it: the + * model the session reports when the provider lists it, otherwise the configured model, otherwise + * the provider's default. + */ + private async assertThinkingOptionListed( + agent: ActiveManagedAgent, + thinkingOptionId: string, + ): Promise { + const reported = agent.runtimeInfo?.model; + const candidates = [reported, agent.config.model].filter( + (model): model is string => typeof model === "string" && model.length > 0, + ); + for (const model of candidates) { + const issues = + (await this.validateAgentSelection?.({ + provider: agent.provider, + model, + thinkingOptionId, + })) ?? []; + const modelListed = !issues.some((issue) => issue.path[0] === "model"); + if (modelListed) { + if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; ")); + return; + } + } + await this.assertSelectionListed({ provider: agent.provider, thinkingOptionId }); + } + async setAgentModel(agentId: string, modelId: string | null): Promise { const agent = this.requireSessionAgent(agentId); const normalizedModelId = @@ -2317,11 +2346,7 @@ export class AgentManager { ? thinkingOptionId : null; if (normalizedThinkingOptionId) { - await this.assertSelectionListed({ - provider: agent.provider, - model: agent.config.model, - thinkingOptionId: normalizedThinkingOptionId, - }); + await this.assertThinkingOptionListed(agent, normalizedThinkingOptionId); } let notice: AgentProviderNotice | null = null; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index fb7920725a..6609af31a2 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2026,10 +2026,10 @@ export default function contribute(client: PluginClientContext) { } ``` -| Selector | Result | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `{ agentId }` | The composer of that agent on this host. Empty for an agent that is unknown or archived. | -| `{ focused: true }` | The composer of the agent whose tab is focused in the workspace you last viewed on this host. Empty when the focused tab is not an agent, and it follows focus as the user changes tabs. | +| Selector | Result | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{ agentId }` | The composer of that agent on this host. Empty for an agent that is unknown or archived. | +| `{ focused: true }` | The composer of the agent the Command Center hands to agent items: the focused agent tab of the workspace on screen. Empty when the screen is not a workspace, such as a plugin screen or New workspace, or when the focused tab is not an agent. It follows focus as the user changes tabs. | The result is always an array, empty or with one composer, so the same code reads either selector. `useComposers` re-renders when a composer changes. A composer from `composers()` is the state at the @@ -2038,13 +2038,15 @@ call; call `composers()` again for the next state. The commands are those of [`useComposer()`](#composer-items), with these differences for a composer you reach from outside: -- `insertText` inserts at the cursor while the agent's tab is open. With the tab closed it appends to - the agent's saved prompt, and the text is there when the tab opens. +- `insertText` inserts at the cursor while the agent's composer is on screen. While it is not, for + example when the user is in another workspace, it appends to the agent's saved prompt, and the text + is in the input when the user returns. `setPrompt` replaces the saved prompt the same way. - A running agent's composer is never locked. - `setProvider` rejects, as it does inside the agent's own composer. - `setModel` and `setThinking` reject, with the daemon's message, for a value the provider does not - list. `model` and `thinking` read `null` until the provider's catalog has loaded, then the value - the model picker shows. Pass an id the catalog lists. + list. Pass an id the provider's catalog lists. `model` and `thinking` read what the model picker + shows at that moment: until the provider's catalog loads that is the id the agent reports or was + created with, which may be an alias, and once it loads the catalog's own id. - Drafts and the New workspace screen are not addressable by reference. Only an item inside one reaches its composer, through `useComposer()`. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index b16c8d97a0..7359f2392c 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1704,19 +1704,19 @@ export default function contribute(client: PluginClientContext) { } ``` -| 选择器 | 结果 | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 | -| `{ focused: true }` | 你在此主机上最近查看的工作区中,标签页处于聚焦状态的 Agent 的 composer。聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 | +| 选择器 | 结果 | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 | +| `{ focused: true }` | Command Center 交给 Agent 项的那个 Agent 的 composer:屏幕上工作区中聚焦的 Agent 标签页。当前界面不是工作区(例如插件界面或新建工作区),或聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 | 结果始终是数组,为空或只含一个 composer,所以同一段代码可以读取任一选择器。composer 变化时 `useComposers` 会重新渲染。`composers()` 返回的 composer 是调用时的状态,要获取下一个状态请再次调用 `composers()`。 命令与 [`useComposer()`](#composer-items) 相同,从外部访问时有以下差异: -- Agent 的标签页打开时,`insertText` 在光标处插入。标签页关闭时,它会追加到该 Agent 已保存的提示词,标签页打开时文本就在那里。 +- Agent 的 composer 在屏幕上时,`insertText` 在光标处插入。不在屏幕上时(例如用户在另一个工作区),它会追加到该 Agent 已保存的提示词,用户回来时文本就在输入框里。`setPrompt` 以同样的方式替换已保存的提示词。 - 运行中 Agent 的 composer 从不锁定。 - `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。 -- 对于 Provider 未列出的值,`setModel` 和 `setThinking` 会以 daemon 的消息拒绝。在 Provider 的模型目录加载完成之前,`model` 和 `thinking` 读取为 `null`,之后是模型选择器显示的值。请传入目录中列出的 id。 +- 对于 Provider 未列出的值,`setModel` 和 `setThinking` 会以 daemon 的消息拒绝。请传入 Provider 目录中列出的 id。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。 - 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。 ## 新建工作区项 {#new-workspace-items}