fix(plugins): useComposers holds the composer it names, and a composer lives as long as its agent

PluginComposersSource.subscribe watched the stores directly and never the
composer model, so a component using useComposers did not keep the model's
catalog observer alive and reconnect repair skipped it. subscribe now takes the
selector and holds the model it names, following focus.

Dropping a composer when it went idle left a command a plugin had saved calling
a dead model after the tab reopened. A composer is now dropped when its agent
leaves the session and nothing watches it or has one mounted.
This commit is contained in:
Mohamed Boudra
2026-11-04 08:10:29 +01:00
parent e59183f2de
commit b37ab3e158
6 changed files with 148 additions and 59 deletions
+11 -2
View File
@@ -1,6 +1,7 @@
import {
createContext,
useContext,
useMemo,
useSyncExternalStore,
type ComponentType,
type ReactNode,
@@ -91,7 +92,8 @@ export type PluginComposerSelector = { agentId: string } | { focused: true };
/** What `useComposers` and `composers(selector)` read. Paseo implements it per installed plugin. */
export interface PluginComposersSource {
subscribe(listener: () => void): () => void;
/** Tells `listener` when the composers `selector` names change, and keeps them live meanwhile. */
subscribe(selector: PluginComposerSelector, listener: () => void): () => void;
/** The composers `selector` names now: one for a known running agent, none otherwise. */
resolve(selector: PluginComposerSelector): readonly PluginComposer[];
}
@@ -121,8 +123,15 @@ export function usePluginComposersSource(): PluginComposersSource | null {
export function useComposers(selector: PluginComposerSelector): readonly PluginComposer[] {
const source = useContext(PluginComposersContext);
if (!source) throw new Error("useComposers must run inside a plugin component");
// The selector is usually an inline literal; its content is what the subscription depends on.
const selection = "agentId" in selector ? selector.agentId : null;
const subscribe = useMemo(
() => (listener: () => void) =>
source.subscribe(selection === null ? { focused: true } : { agentId: selection }, listener),
[source, selection],
);
return useSyncExternalStore(
source.subscribe,
subscribe,
() => source.resolve(selector),
() => source.resolve(selector),
);