Files
kandev/apps/web/lib/plugins/composer-capability.ts
2026-08-12 20:29:18 +01:00

133 lines
4.7 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { PluginComposerCapability } from "./types";
type NativeComposerOperations = {
insertText(text: string): boolean;
focus(): boolean;
submit(): Promise<boolean>;
};
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;
}