diff --git a/docs/plugins.md b/docs/plugins.md index 4a045f29df..eb07576719 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -690,8 +690,10 @@ implementations provide it: mounted, checked on session changes and when the last holder lets go. 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 + a mounted input to show it. A workspace the user left stays mounted, display none. Web + drops the input ref there; native keeps the input, with a stale cursor and selection. + `ComposerInput.isLive()` is how the model tells: it needs the ref and a retained panel that is + showing (`useRetainedPanelActive`). 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`. It validates against the catalog of the agent's directory, the one the picker reads, and checks a thinking option against diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index bd1533d8af..135da3113b 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -117,6 +117,7 @@ import { } from "@/attachments/service"; import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; +import { useIsInputLive } from "./input/use-input-live"; import { resolveActiveSendBehavior } from "./input/state"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; @@ -1433,13 +1434,14 @@ function ComposerContentImpl({ }, [onChangeText, textSource], ); + const isInputLive = useIsInputLive(messageInputRef); const isComposerLockedRef = useRef(isComposerLocked); isComposerLockedRef.current = isComposerLocked; const composerInput = useMemo( () => ({ locked: isComposerLocked, isLocked: () => isComposerLockedRef.current, - isLive: () => messageInputRef.current !== null, + isLive: isInputLive, textSource, setText: replaceUserInput, insertText: insertUserInput, @@ -1450,6 +1452,7 @@ function ComposerContentImpl({ attachments, insertUserInput, isComposerLocked, + isInputLive, replaceUserInput, setSelectedAttachments, textSource, diff --git a/packages/app/src/composer/input/use-input-live.test.tsx b/packages/app/src/composer/input/use-input-live.test.tsx new file mode 100644 index 0000000000..92def416d6 --- /dev/null +++ b/packages/app/src/composer/input/use-input-live.test.tsx @@ -0,0 +1,50 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { useIsInputLive } from "./use-input-live"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); +afterEach(() => vi.unstubAllGlobals()); + +function render( + active: boolean, + input: { current: unknown }, + publish: (isLive: () => boolean) => void, +) { + function Probe() { + publish(useIsInputLive(input)); + return null; + } + return ( + + + + ); +} + +it("an input kept mounted in a panel that is not showing is not live", () => { + const input: { current: object | null } = { current: {} }; + let isLive: () => boolean = () => false; + const publish = (read: () => boolean) => { + isLive = read; + }; + const root = createRoot(document.createElement("div")); + + act(() => root.render(render(true, input, publish))); + expect(isLive()).toBe(true); + + act(() => root.render(render(false, input, publish))); + expect(isLive()).toBe(false); + + act(() => root.render(render(true, input, publish))); + expect(isLive()).toBe(true); + + input.current = null; + expect(isLive()).toBe(false); + act(() => root.unmount()); +}); diff --git a/packages/app/src/composer/input/use-input-live.ts b/packages/app/src/composer/input/use-input-live.ts new file mode 100644 index 0000000000..cf83ea6735 --- /dev/null +++ b/packages/app/src/composer/input/use-input-live.ts @@ -0,0 +1,14 @@ +import { useCallback, useRef } from "react"; +import { useRetainedPanelActive } from "@/components/retained-panel"; + +/** + * Whether the composer's input is on screen, as a read a saved plugin command can make later. A + * retained composer keeps its input mounted and hidden, and its cursor and selection are then + * stale, so only an input in a panel that is showing counts. + */ +export function useIsInputLive(input: { readonly current: unknown }): () => boolean { + const panelActive = useRetainedPanelActive(); + const panelActiveRef = useRef(panelActive); + panelActiveRef.current = panelActive; + return useCallback(() => input.current !== null && panelActiveRef.current, [input]); +}