/** * AgentRewrite * ============ * * One canonical Tiptap command to perform an agent-driven replacement * of a range in the document - optionally with a typewriter animation * so the user sees the agent "typing" the new content in place of the * old one. * * Called by: * - the real agent's tool calls (applyDiff / rewrite / rephrase), * - the Playwright showcase demo script via the `__demo-chat` event. * * Why this lives in an extension (and not in App.tsx or the demo) * --------------------------------------------------------------- * The previous design had an ad-hoc `setTimeout` loop in App.tsx that * re-inserted characters at `startPos + i` - a fixed numeric origin. * Under Yjs collaboration any concurrent remote edit that shifted * `startPos` (e.g. another user inserting a character before it) would * desync the animation and land subsequent chunks inside the wrong * paragraph, producing a visible "write-over-itself" artifact. * * Here we track the insertion `cursor` in PM plugin state and remap it * through `tr.mapping` on every transaction - local OR remote. This * guarantees the typewriter resumes at the correct absolute position * no matter what Bob or Carol are doing elsewhere in the doc. * * Cancellation * ------------ * Each call to `agentRewriteRange` bumps a cancel token stored in * plugin state. Any still-scheduled animation step that observes a * different token aborts. That way, if the agent fires two * overlapping rewrites, the first one stops immediately instead of * fighting the second for control of the doc. * * Undo-friendliness * ----------------- * Every animation step is a small atomic `insertText` transaction, * so y-undo sees a tidy sequence of ReplaceSteps instead of one huge * debug-hostile mega-op. Undo works as expected (one step per chunk). */ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; export type AgentRewriteAnimation = "none" | "typewriter"; export interface AgentRewriteArgs { /** Absolute PM position of the start of the range to replace. */ from: number; /** Absolute PM position of the end of the range to replace. */ to: number; /** New text to insert in place of the deleted range. */ text: string; /** * "none" -> replace the range atomically in one transaction. * "typewriter" -> stream the replacement chunk-by-chunk with small * delays so the UI shows the agent "typing". * Default: "none" (safer for programmatic / non-demo callers). */ animation?: AgentRewriteAnimation; /** * Characters per animation tick. A random int in [min,max] is chosen * each step, which makes the typing feel organic. Default [1, 2]. */ chunkSize?: [number, number]; /** * Milliseconds between animation ticks. Default [22, 48]. */ chunkMs?: [number, number]; } interface AgentRewriteState { /** * Current insertion position for the in-flight typewriter animation. * Mapped through every transaction so local + remote edits never * shift the cursor out of sync with the actual doc content. */ cursor: number | null; /** * Monotonically-increasing token. Any call to `agentRewriteRange` * bumps this, which retroactively cancels any animation step still * scheduled from a previous call. */ cancelToken: number; } interface AgentRewriteMeta { cancel?: boolean; setCursor?: number | null; } const agentRewriteKey = new PluginKey("agent-rewrite"); declare module "@tiptap/core" { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Commands { agentRewrite: { /** * Replace `[from, to]` with `text`. When `animation === "typewriter"` * the insertion is streamed chunk-by-chunk; otherwise it's one * atomic transaction. */ agentRewriteRange: (args: AgentRewriteArgs) => ReturnType; /** Abort any in-flight typewriter animation immediately. */ cancelAgentRewrite: () => ReturnType; }; } } export const AgentRewrite = Extension.create({ name: "agentRewrite", addProseMirrorPlugins() { return [ new Plugin({ key: agentRewriteKey, state: { init: (): AgentRewriteState => ({ cursor: null, cancelToken: 0 }), apply(tr, prev): AgentRewriteState { let next = prev; // Remap the tracked cursor through any doc change (local or // remote). This is the whole point of using a plugin state // instead of a closure-captured number: Yjs pushes tr's for // remote edits and we ride their mapping. if (tr.docChanged && next.cursor !== null) { next = { ...next, cursor: tr.mapping.map(next.cursor) }; } const meta = tr.getMeta(agentRewriteKey) as | AgentRewriteMeta | undefined; if (meta) { if (meta.cancel) { // `cancel` wipes the cursor AND advances the token so // in-flight steps observe a mismatch and bail out. next = { cursor: null, cancelToken: next.cancelToken + 1, }; } if (meta.setCursor !== undefined) { // `setCursor` is the primitive used by the command to // re-arm the plugin with a fresh starting position // (AFTER any cancel has taken effect in the same meta). next = { ...next, cursor: meta.setCursor }; } } return next; }, }, }), ]; }, addCommands() { return { cancelAgentRewrite: () => ({ tr, dispatch }) => { if (dispatch) { tr.setMeta(agentRewriteKey, { cancel: true }); } return true; }, agentRewriteRange: (args) => ({ state, view, tr, dispatch }) => { const docSize = state.doc.content.size; const a = Math.max(0, Math.min(args.from, docSize)); const b = Math.max(0, Math.min(args.to, docSize)); if (b < a) return false; if (!dispatch) return true; // Capture `myToken` BEFORE the dispatch. Tiptap's // CommandManager turns out to batch the `tr` we mutate here // into its own dispatch cycle, so reading `view.state` // immediately after calling the provided `dispatch(tr)` can // return the PRE-commit state (cancelToken still = N, // cursor still = null). The only reliable source is the // state we started from (`state`): we know this command // will bump cancelToken by 1 and seed the cursor at `a`, // so we compute those values ourselves instead of reading // them back from a potentially stale view. const prevToken = agentRewriteKey.getState(state)?.cancelToken ?? 0; const myToken = prevToken + 1; const text = args.text ?? ""; const animation = args.animation ?? "none"; // ----- Atomic path ("none") ----------------------------------- // Do the whole replace in ONE transaction: cancel any // in-flight rewrite, replace [a, b] with `text`, clear the // cursor marker. Two separate dispatches produced double // content (insert + zombie old text) because Tiptap's // CommandManager does NOT update `view.state` between // `dispatch(tr)` and the next `view.state.tr.insertText` // call inside the same command - so the second tr // insertText ran against the PRE-delete state, effectively // adding the new phrase in front of the old one without // deleting it. if (animation === "none") { tr.setMeta(agentRewriteKey, { cancel: true, setCursor: null, } as AgentRewriteMeta); if (text) { tr.insertText(text, a, b); } else if (b > a) { tr.delete(a, b); } dispatch(tr); return true; } // ----- Typewriter path ----------------------------------- // Step 1: single atomic transaction that (a) cancels any // in-flight rewrite, (b) deletes the target range, and (c) // plants the insertion cursor at `a`. Done in one tr so the // deletion and cursor seed land together - avoids a visible // gap between delete and first insert. tr.setMeta(agentRewriteKey, { cancel: true, setCursor: a, } as AgentRewriteMeta); if (b > a) tr.delete(a, b); dispatch(tr); if (!text) { // Nothing to insert, just a deletion. Done. view.dispatch( view.state.tr.setMeta(agentRewriteKey, { setCursor: null, } as AgentRewriteMeta), ); return true; } // Typewriter: async stream of small insertText trs. Each step // reads the CURRENT cursor from plugin state (mapped through // every intervening tr, including remote Yjs updates), so the // animation is immune to concurrent edits. const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2]; const [delayMin, delayMax] = args.chunkMs ?? [22, 48]; let i = 0; // Closure-held cursor is our source of truth: seeded at `a`, // then synced with plugin state before each insert so remote // Yjs trs between our ticks remap the cursor correctly via // the plugin's mapping logic. Using a closure (rather than // reading plugin state blindly) guarantees the first step // doesn't bail out if Tiptap's command dispatch hasn't yet // committed the `setCursor: a` meta to the plugin state that // `view.state` exposes. let cursor = a; const step = () => { if (view.isDestroyed) return; const ps = agentRewriteKey.getState(view.state); // Cancellation only: token mismatch means another rewrite // started (or `cancelAgentRewrite` fired). if (ps && ps.cancelToken !== myToken) { // Another rewrite started or `cancelAgentRewrite` fired. return; } // Pull the mapped cursor from plugin state if available - // this accounts for any remote Yjs inserts that happened // since the last step. Fall back to the closure value on // the very first tick, where plugin state can still be // null due to Tiptap's command dispatch batching. if (ps && ps.cursor !== null) cursor = ps.cursor; if (i >= text.length) { // Done: clear the cursor marker so nothing lingers in // plugin state. The caller is responsible for any visual // cleanup (e.g. `clearAgentFocus`). view.dispatch( view.state.tr.setMeta(agentRewriteKey, { setCursor: null, } as AgentRewriteMeta), ); return; } const remaining = text.length - i; const maxTake = Math.max(1, Math.min(chunkMax, remaining)); const minTake = Math.max(1, Math.min(chunkMin, maxTake)); const take = minTake + Math.floor(Math.random() * (maxTake - minTake + 1)); const chunk = text.slice(i, i + take); // Insert at our closure-tracked cursor. The plugin's // apply() will advance the stored cursor via mapping so // subsequent remote trs remap it correctly. view.dispatch(view.state.tr.insertText(chunk, cursor)); cursor += chunk.length; i += chunk.length; const delay = delayMin + Math.random() * Math.max(0, delayMax - delayMin); setTimeout(step, delay); }; // Kick off on a macrotask so the initial delete tr has fully // applied (and the cursor seed is live in plugin state) // before the first step reads it. setTimeout(step, 0); return true; }, }; }, });