fix(plugins): a retained composer that is not showing takes plugin writes through the draft

On native a retained composer keeps its input mounted under display none, so the ref alone said
the input was live and a plugin insertText replaced the stale selection instead of appending to
the draft. The input is live only while its retained panel is showing.
This commit is contained in:
Mohamed Boudra
2026-11-04 14:10:40 +01:00
parent 3beb7707d6
commit 9d23013260
4 changed files with 72 additions and 3 deletions
+4 -2
View File
@@ -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
+4 -1
View File
@@ -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<ComposerInput>(
() => ({
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,
@@ -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 (
<RetainedPanelActivity active={active}>
<Probe />
</RetainedPanelActivity>
);
}
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());
});
@@ -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]);
}