import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { PluginComposerCapability } from "./types"; type NativeComposerOperations = { insertText(text: string): boolean; focus(): boolean; submit(): Promise; }; export type PluginComposerCapabilityHandle = { api: PluginComposerCapability; /** * Marks the capability usable and returns the matching teardown. Wire this * as an effect body (`useEffect(() => handle.attach(), [handle])`) rather * than calling `revoke` from a cleanup directly: React StrictMode mounts, * unmounts and remounts the same component instance, so a cleanup-only * revocation would permanently kill a handle whose composer is still on * screen. */ attach(): () => void; revoke(): void; }; export function createPluginComposerCapability( operations: NativeComposerOperations, ): PluginComposerCapabilityHandle { let active = true; const revoke = () => { active = false; }; return { api: { insertText(text) { if (!active) return { status: "unavailable" }; if (!text.trim()) return { status: "ignored" }; return { status: operations.insertText(text) ? "inserted" : "unavailable" }; }, focus() { if (!active) return { status: "unavailable" }; return { status: operations.focus() ? "focused" : "unavailable" }; }, async submit() { if (!active) return { status: "unavailable" }; return (await operations.submit()) ? { status: "submitted" } : { status: "blocked" }; }, }, attach() { active = true; return revoke; }, revoke, }; } /** * Word-boundary spacing rule every composer adapter applies to an inserted * transcript, so the TipTap chat composer and the plain-textarea creation * composers behave identically: the text is trimmed, and a single space is * added when the character immediately before the insertion point is not * already whitespace. Returns "" for an insertion the caller should ignore. */ export function composerInsertionText(text: string, charBefore: string): string { const trimmed = text.trim(); if (!trimmed) return ""; const needsLeadingSpace = charBefore !== "" && !/\s/.test(charBefore); return needsLeadingSpace ? ` ${trimmed}` : trimmed; } /** * The key that distinguishes one mounted composer from another, for * `useStablePluginComposerCapability`. Every part matters: composers are not * remounted when the user switches task or session, so dropping a segment * lets a handle captured on one conversation act on the next. */ export function composerIdentity( surface: string, taskId: string | null, sessionId: string | null, ): string { return `${surface}:${taskId ?? ""}:${sessionId ?? ""}`; } /** * Builds the `PluginComposerCapability` a mounted composer hands to its * plugin action slot. * * The returned object is stable across ordinary re-renders. That is the whole * point: a plugin action is inherently asynchronous (record, upload, * transcribe, then insert and maybe submit), so it holds the capability across * many host re-renders. Rebuilding it whenever `disabled`, `submittable` or the * native submit callback changed would revoke the object the plugin is still * holding and turn a completed transcription into `unavailable`. * * Liveness comes from the operations ref instead: each render republishes the * current closures, so `submit()` revalidates the native gate at call time * rather than against a snapshot taken when the plugin first got the object. * The ref is published in a layout effect so a handler running after paint * cannot read the previous render's gate. * * `identity` is what stops that stability from becoming a leak. A composer is * not remounted when the user switches task or session — task chat's container * is keyed only by its clarification key — so without it, a handle captured * while recording on one session would happily insert into the next one, which * the composer contract forbids. Pass whatever distinguishes one mounted * composer from another (surface, task, session); changing it revokes the old * handle and issues a new one, while ordinary disabled/submittable re-renders * leave it alone. */ export function useStablePluginComposerCapability( operations: NativeComposerOperations, identity: string, ): PluginComposerCapability { const latest = useRef(operations); useLayoutEffect(() => { latest.current = operations; }); const handle = useMemo( () => createPluginComposerCapability({ insertText: (text) => latest.current.insertText(text), focus: () => latest.current.focus(), submit: () => latest.current.submit(), }), [identity], ); useEffect(() => handle.attach(), [handle]); return handle.api; }