From 32b7d87352be4128556a582620765089de2d633d Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Thu, 16 Jul 2026 16:13:15 +0200 Subject: [PATCH 01/10] feat: add streaming-reveal extension to @tiptap/ai-toolkit --- .changeset/new-pots-admire.md | 5 + packages/ai-toolkit/package.json | 24 +- .../ai-toolkit/src/ai-insert-reveal.spec.ts | 65 +++++ packages/ai-toolkit/src/ai-insert-reveal.ts | 235 ++++++++++++++++++ packages/ai-toolkit/tsup.config.ts | 2 +- 5 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 .changeset/new-pots-admire.md create mode 100644 packages/ai-toolkit/src/ai-insert-reveal.spec.ts create mode 100644 packages/ai-toolkit/src/ai-insert-reveal.ts diff --git a/.changeset/new-pots-admire.md b/.changeset/new-pots-admire.md new file mode 100644 index 0000000000..f8776959fd --- /dev/null +++ b/.changeset/new-pots-admire.md @@ -0,0 +1,5 @@ +--- +"@tiptap/ai-toolkit": minor +--- + +Add the `AiInsertReveal` extension, exported from `@tiptap/ai-toolkit/streaming-reveal`, to fade in text as the AI streams it into a collaborative document. diff --git a/packages/ai-toolkit/package.json b/packages/ai-toolkit/package.json index 107c4947c6..2bc6ca18c0 100644 --- a/packages/ai-toolkit/package.json +++ b/packages/ai-toolkit/package.json @@ -39,6 +39,14 @@ }, "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./streaming-reveal": { + "types": { + "import": "./dist/streaming-reveal.d.ts", + "require": "./dist/streaming-reveal.d.cts" + }, + "import": "./dist/streaming-reveal.js", + "require": "./dist/streaming-reveal.cjs" } }, "scripts": { @@ -51,10 +59,22 @@ }, "devDependencies": { "@tiptap/core": "workspace:^", - "@tiptap/pm": "workspace:^" + "@tiptap/pm": "workspace:^", + "@tiptap/y-tiptap": "^3.0.7", + "yjs": "^13.6.23" }, "peerDependencies": { "@tiptap/core": "^3.0.1", - "@tiptap/pm": "^3.0.1" + "@tiptap/pm": "^3.0.1", + "@tiptap/y-tiptap": "^3.0.0", + "yjs": "^13.6.23" + }, + "peerDependenciesMeta": { + "@tiptap/y-tiptap": { + "optional": true + }, + "yjs": { + "optional": true + } } } diff --git a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts new file mode 100644 index 0000000000..9c8cbb1690 --- /dev/null +++ b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { describe, expect, it } from 'vitest' + +import { AiInsertReveal } from './ai-insert-reveal.js' + +/** + * Creates an editor with the {@link AiInsertReveal} extension and no + * collaboration, to prove the extension loads and degrades gracefully when the + * y-sync plugin it reads is absent. + * + * @return Promise resolving once the editor create lifecycle has finished. + */ +function createEditor(): Promise { + return new Promise(resolve => { + const editor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, AiInsertReveal], + content: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }], + }, + onCreate: () => { + resolve(editor) + }, + }) + }) +} + +describe('AiInsertReveal', () => { + it('is a named Tiptap extension', () => { + expect(AiInsertReveal.name).toBe('aiInsertReveal') + }) + + it('registers and degrades to a no-op when no collaboration y-sync plugin is present', async () => { + const editor = await createEditor() + + expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true) + // Without a y-sync plugin the decorations source resolves to nothing, so the + // editor renders normally rather than throwing. + expect(editor.getText()).toBe('Hello') + + editor.destroy() + }) + + it('applies configured className and durationMs', async () => { + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + AiInsertReveal.configure({ className: 'custom-reveal', durationMs: 300 }), + ], + onCreate: () => resolve(created), + }) + }) + + const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal') + expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 }) + + editor.destroy() + }) +}) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.ts b/packages/ai-toolkit/src/ai-insert-reveal.ts new file mode 100644 index 0000000000..da158273b0 --- /dev/null +++ b/packages/ai-toolkit/src/ai-insert-reveal.ts @@ -0,0 +1,235 @@ +import { Extension } from '@tiptap/core' +import type { Node as PMNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { relativePositionToAbsolutePosition, ySyncPluginKey } from '@tiptap/y-tiptap' +import * as Y from 'yjs' + +/** + * Configuration for {@link AiInsertReveal}. + */ +export type AiInsertRevealOptions = { + /** + * CSS class applied to each freshly-inserted run. Style this class in your app + * to define the fade (see the package docs for a default). Change it to run + * more than one reveal effect, or to avoid a class-name collision. + */ + className: string + /** + * How long (ms) each run keeps its reveal decoration before it is dropped. + * Keep it at or above your CSS animation duration so the fade can finish. + */ + durationMs: number +} + +/** + * Upper bound (chars) on a single revealed run. A normal streamed token is a + * few chars; this only guards against a mis-resolved relative position yielding + * an absurd range (e.g. spanning the whole document). + */ +const MAX_REVEAL_RANGE = 400 + +/** + * One freshly-inserted text run, anchored by Yjs relative positions so it stays + * valid across the whole-document rebuild that y-tiptap applies on every remote + * sync (a plain ProseMirror position would be collapsed by that rebuild). + */ +type RevealEntry = { + start: Y.RelativePosition + end: Y.RelativePosition + at: number +} + +/** Minimal shape of the y-sync plugin state we read. */ +type YSyncState = { + doc: Y.Doc + type: Y.XmlFragment + binding: { mapping: Map, PMNode | PMNode[]> } | null +} + +const aiInsertRevealKey = new PluginKey('aiInsertReveal') + +/** + * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming + * into the shared Y.Doc), one run per token, without mutating the document. + * + * It reads the authored signal directly: each remote Yjs transaction carries a + * delta describing exactly what was inserted and where. Those ranges are stored + * as relative positions and re-resolved to absolute positions on every render to + * drive view-only inline decorations. It does no document diffing and adds no + * marks, so it is inert to accept/reject and to persistence. Local edits are + * ignored via `transaction.local`, so a user typing sees no fade. + * + * Requires the Collaboration extension (its y-sync plugin) to be present. The + * actual fade is defined in CSS on the configured `className` (default + * `ai-insert-reveal`); without that stylesheet the decorations are added but + * invisible. + */ +export const AiInsertReveal = Extension.create({ + name: 'aiInsertReveal', + + addOptions() { + return { + className: 'ai-insert-reveal', + durationMs: 550, + } + }, + + addProseMirrorPlugins() { + const { className, durationMs } = this.options + + // Runs in insertion (time) order, so expired entries are always a prefix. + const entries: RevealEntry[] = [] + + const dropExpired = (now: number) => { + let firstActive = 0 + while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) { + firstActive += 1 + } + if (firstActive > 0) entries.splice(0, firstActive) + } + + return [ + new Plugin({ + key: aiInsertRevealKey, + + props: { + decorations: state => { + if (entries.length === 0) return null + const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined + if (!ystate?.binding) return null + + const now = Date.now() + const decorations: Decoration[] = [] + for (const entry of entries) { + const age = now - entry.at + if (age >= durationMs) continue + + const from = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.start, + ystate.binding.mapping, + ) + const to = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.end, + ystate.binding.mapping, + ) + if (from === null || to === null) continue + + const a = Math.min(from, to) + const b = Math.max(from, to) + if (a >= b || b - a > MAX_REVEAL_RANGE) continue + + // Seed the CSS animation from the run's real age so a re-render + // (y-tiptap rebuilds the doc on every token) resumes the fade at + // the correct point instead of restarting it. + decorations.push( + Decoration.inline(a, b, { + class: className, + style: `animation-delay: -${Math.round(age)}ms`, + }), + ) + } + + return decorations.length ? DecorationSet.create(state.doc, decorations) : null + }, + }, + + view: view => { + const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined + const fragment = initialState?.type ?? null + + let raf: number | null = null + let pruneTimer: ReturnType | null = null + + const rerender = () => { + if (view.isDestroyed) return + // Empty transaction: re-runs `decorations` so new entries paint (and + // expired ones are dropped). Kept out of the undo history. + view.dispatch(view.state.tr.setMeta('addToHistory', false)) + } + + // Coalesce bursts of tokens landing in the same frame into one render. + const scheduleRerender = () => { + if (raf !== null) return + raf = requestAnimationFrame(() => { + raf = null + rerender() + }) + } + + // After the stream pauses, one delayed render removes the last runs' + // decorations once their fades have completed. + const schedulePrune = () => { + if (pruneTimer !== null) clearTimeout(pruneTimer) + pruneTimer = setTimeout(() => { + pruneTimer = null + dropExpired(Date.now()) + rerender() + }, durationMs + 80) + } + + const onChange = ( + events: Array>>, + transaction: Y.Transaction, + ) => { + // Only remote edits, i.e. the AI. The local user's own typing is a + // local transaction and must not fade. + if (transaction.local) return + + const now = Date.now() + dropExpired(now) + + let captured = false + for (const event of events) { + const target = event.target + if (!(target instanceof Y.XmlText)) continue + + let index = 0 + for (const op of event.delta) { + if (typeof op.retain === 'number') { + index += op.retain + } else if (typeof op.insert === 'string') { + const length = op.insert.length + if (length > 0) { + entries.push({ + start: Y.createRelativePositionFromTypeIndex(target, index), + // Anchor the end to the run's last char (assoc < 0) so the + // next token appended here starts its own run instead of + // extending this one. + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + }) + captured = true + } + index += length + } else if (op.insert !== undefined) { + index += 1 + } + } + } + + if (captured) { + scheduleRerender() + schedulePrune() + } + } + + fragment?.observeDeep(onChange) + + return { + destroy: () => { + fragment?.unobserveDeep(onChange) + if (raf !== null) cancelAnimationFrame(raf) + if (pruneTimer !== null) clearTimeout(pruneTimer) + entries.length = 0 + }, + } + }, + }), + ] + }, +}) diff --git a/packages/ai-toolkit/tsup.config.ts b/packages/ai-toolkit/tsup.config.ts index 03b7c8d0b6..d09a9166d3 100644 --- a/packages/ai-toolkit/tsup.config.ts +++ b/packages/ai-toolkit/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts'], + entry: { index: 'src/index.ts', 'streaming-reveal': 'src/ai-insert-reveal.ts' }, tsconfig: '../../tsconfig.build.json', outDir: 'dist', dts: true, From 4b1c45ea4cf7a9a753e4082adb6ee6c9a3553444 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 11:12:01 +0200 Subject: [PATCH 02/10] cover AiInsertReveal behavior and simplify its plugin functions --- .../ai-toolkit/src/ai-insert-reveal.spec.ts | 126 ++++++++++++++++++ packages/ai-toolkit/src/ai-insert-reveal.ts | 125 ++++++++++------- 2 files changed, 205 insertions(+), 46 deletions(-) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts index 9c8cbb1690..1db90e8a1b 100644 --- a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts +++ b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts @@ -1,8 +1,10 @@ // @vitest-environment happy-dom import { Editor } from '@tiptap/core' +import { Collaboration } from '@tiptap/extension-collaboration' import StarterKit from '@tiptap/starter-kit' import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' import { AiInsertReveal } from './ai-insert-reveal.js' @@ -29,6 +31,71 @@ function createEditor(): Promise { }) } +/** + * Creates a collaborative editor bound to a fresh Y.Doc with {@link AiInsertReveal}, + * seeded with a single `Hello` paragraph. + * + * @param options - Optional reveal configuration forwarded to `configure`. + * @return Promise resolving to the editor and its backing Y.Doc. + */ +function createCollabEditor(options?: { + durationMs?: number +}): Promise<{ editor: Editor; ydoc: Y.Doc }> { + const ydoc = new Y.Doc() + return new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + options?.durationMs === undefined + ? AiInsertReveal + : AiInsertReveal.configure({ durationMs: options.durationMs }), + ], + onCreate: ({ editor }) => { + // Collaboration ignores the `content` prop (the empty Y.Doc wins when the + // y-sync plugin binds), so seed the shared doc with a local edit instead. + editor.commands.setContent('

Hello

') + resolve({ editor, ydoc }) + }, + }) + }) +} + +/** + * Applies a remote (non-local) insert into the first paragraph's text, mimicking + * an AI streaming into the shared document from another peer. Uses a second Y.Doc + * synced from `ydoc` so the resulting transaction has `local === false`. + */ +function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)) + const paragraph = remote.getXmlFragment('default').get(0) as Y.XmlElement + const xmlText = paragraph.get(0) as Y.XmlText + xmlText.insert(index, text) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) +} + +/** + * Collects the reveal decorations currently produced by the extension, resolved + * against the editor's live state (mirrors what the view renders). + */ +function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { + for (const plugin of editor.state.plugins) { + // biome-ignore lint/suspicious/noExplicitAny: reading the decoration prop generically + const set = (plugin as any).props?.decorations?.call(plugin, editor.state) + // biome-ignore lint/suspicious/noExplicitAny: DecorationSet.find returns internal decoration objects + const found = (set?.find?.() ?? []).filter( + (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', + ) + if (found.length > 0) { + // biome-ignore lint/suspicious/noExplicitAny: decoration internals + return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' })) + } + } + return [] +} + describe('AiInsertReveal', () => { it('is a named Tiptap extension', () => { expect(AiInsertReveal.name).toBe('aiInsertReveal') @@ -62,4 +129,63 @@ describe('AiInsertReveal', () => { editor.destroy() }) + + it('reveals a remote insert as a decoration over exactly the inserted run', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, ' WORLD') + + expect(editor.getText()).toBe('Hello WORLD') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + // The decoration spans exactly the 6 inserted characters. + expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length) + // The age-seeded animation-delay is present so the fade survives re-renders. + expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/) + + editor.destroy() + }) + + it("does not reveal the local user's own typing", async () => { + const { editor } = await createCollabEditor() + + // A local transaction (transaction.local === true) must be ignored. + editor.commands.insertContentAt(6, 'X') + + expect(editor.getText()).toBe('HelloX') + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('drops the reveal once its duration has elapsed', async () => { + const { editor, ydoc } = await createCollabEditor({ durationMs: 30 }) + + remoteInsert(ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + await new Promise(resolve => setTimeout(resolve, 60)) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('ignores an insert larger than the max reveal range', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, 'x'.repeat(401)) + + expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('tears down cleanly after a reveal without throwing', async () => { + const { editor, ydoc } = await createCollabEditor() + remoteInsert(ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + expect(() => editor.destroy()).not.toThrow() + }) }) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.ts b/packages/ai-toolkit/src/ai-insert-reveal.ts index da158273b0..7b48524d6d 100644 --- a/packages/ai-toolkit/src/ai-insert-reveal.ts +++ b/packages/ai-toolkit/src/ai-insert-reveal.ts @@ -49,6 +49,77 @@ type YSyncState = { const aiInsertRevealKey = new PluginKey('aiInsertReveal') +/** + * Resolves one reveal entry to an absolute range, or null if it has expired, its + * anchors no longer resolve (the run's text was deleted by a concurrent edit), or + * the span is empty or implausibly large. Returns the run's `age` so the caller + * can seed the fade from it. + */ +function resolveRevealRange( + ystate: YSyncState, + entry: RevealEntry, + now: number, + durationMs: number, +): { from: number; to: number; age: number } | null { + if (!ystate.binding) return null + + const age = now - entry.at + if (age >= durationMs) return null + + const from = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.start, + ystate.binding.mapping, + ) + const to = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.end, + ystate.binding.mapping, + ) + if (from === null || to === null) return null + + const a = Math.min(from, to) + const b = Math.max(from, to) + if (a >= b || b - a > MAX_REVEAL_RANGE) return null + + return { from: a, to: b, age } +} + +/** + * Extracts the freshly-inserted text runs carried by one remote Yjs event, + * anchored by relative positions. Returns an empty array when the event's target + * is not a text node or carries no string inserts. + */ +function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { + const target = event.target + if (!(target instanceof Y.XmlText)) return [] + + const runs: RevealEntry[] = [] + let index = 0 + for (const op of event.delta) { + if (typeof op.retain === 'number') { + index += op.retain + } else if (typeof op.insert === 'string') { + const length = op.insert.length + if (length > 0) { + runs.push({ + start: Y.createRelativePositionFromTypeIndex(target, index), + // Anchor the end to the run's last char (assoc < 0) so the next token + // appended here starts its own run instead of extending this one. + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + }) + } + index += length + } else if (op.insert !== undefined) { + index += 1 + } + } + return runs +} + /** * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming * into the shared Y.Doc), one run per token, without mutating the document. @@ -102,34 +173,16 @@ export const AiInsertReveal = Extension.create({ const now = Date.now() const decorations: Decoration[] = [] for (const entry of entries) { - const age = now - entry.at - if (age >= durationMs) continue - - const from = relativePositionToAbsolutePosition( - ystate.doc, - ystate.type, - entry.start, - ystate.binding.mapping, - ) - const to = relativePositionToAbsolutePosition( - ystate.doc, - ystate.type, - entry.end, - ystate.binding.mapping, - ) - if (from === null || to === null) continue - - const a = Math.min(from, to) - const b = Math.max(from, to) - if (a >= b || b - a > MAX_REVEAL_RANGE) continue + const range = resolveRevealRange(ystate, entry, now, durationMs) + if (range === null) continue // Seed the CSS animation from the run's real age so a re-render // (y-tiptap rebuilds the doc on every token) resumes the fade at // the correct point instead of restarting it. decorations.push( - Decoration.inline(a, b, { + Decoration.inline(range.from, range.to, { class: className, - style: `animation-delay: -${Math.round(age)}ms`, + style: `animation-delay: -${Math.round(range.age)}ms`, }), ) } @@ -185,30 +238,10 @@ export const AiInsertReveal = Extension.create({ let captured = false for (const event of events) { - const target = event.target - if (!(target instanceof Y.XmlText)) continue - - let index = 0 - for (const op of event.delta) { - if (typeof op.retain === 'number') { - index += op.retain - } else if (typeof op.insert === 'string') { - const length = op.insert.length - if (length > 0) { - entries.push({ - start: Y.createRelativePositionFromTypeIndex(target, index), - // Anchor the end to the run's last char (assoc < 0) so the - // next token appended here starts its own run instead of - // extending this one. - end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), - at: now, - }) - captured = true - } - index += length - } else if (op.insert !== undefined) { - index += 1 - } + const runs = collectInsertedRuns(event, now) + if (runs.length > 0) { + entries.push(...runs) + captured = true } } From 37c55cac4d80a5e28497dbb3506cdbd73243a70a Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 11:55:16 +0200 Subject: [PATCH 03/10] refactor: name the reveal source after its streaming-reveal entry --- .../src/{ai-insert-reveal.spec.ts => streaming-reveal.spec.ts} | 2 +- .../ai-toolkit/src/{ai-insert-reveal.ts => streaming-reveal.ts} | 0 packages/ai-toolkit/tsup.config.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/ai-toolkit/src/{ai-insert-reveal.spec.ts => streaming-reveal.spec.ts} (99%) rename packages/ai-toolkit/src/{ai-insert-reveal.ts => streaming-reveal.ts} (100%) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts similarity index 99% rename from packages/ai-toolkit/src/ai-insert-reveal.spec.ts rename to packages/ai-toolkit/src/streaming-reveal.spec.ts index 1db90e8a1b..54ca2defd0 100644 --- a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit' import { describe, expect, it } from 'vitest' import * as Y from 'yjs' -import { AiInsertReveal } from './ai-insert-reveal.js' +import { AiInsertReveal } from './streaming-reveal.js' /** * Creates an editor with the {@link AiInsertReveal} extension and no diff --git a/packages/ai-toolkit/src/ai-insert-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts similarity index 100% rename from packages/ai-toolkit/src/ai-insert-reveal.ts rename to packages/ai-toolkit/src/streaming-reveal.ts diff --git a/packages/ai-toolkit/tsup.config.ts b/packages/ai-toolkit/tsup.config.ts index d09a9166d3..c3269074b0 100644 --- a/packages/ai-toolkit/tsup.config.ts +++ b/packages/ai-toolkit/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: { index: 'src/index.ts', 'streaming-reveal': 'src/ai-insert-reveal.ts' }, + entry: { index: 'src/index.ts', 'streaming-reveal': 'src/streaming-reveal.ts' }, tsconfig: '../../tsconfig.build.json', outDir: 'dist', dts: true, From c3a99c6718310e2335f076d3da1bfb8039a0919b Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 12:23:45 +0200 Subject: [PATCH 04/10] chore: update lockfile for the streaming-reveal deps --- pnpm-lock.yaml | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca182adb0b..5afd25efdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,7 +150,7 @@ importers: version: 2.15.0(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) '@hocuspocus/transformer': specifier: ^2.15.0 - version: 2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) '@lexical/react': specifier: ^0.36.2 version: 0.36.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(yjs@13.6.23) @@ -411,6 +411,12 @@ importers: '@tiptap/pm': specifier: workspace:^ version: link:../pm + '@tiptap/y-tiptap': + specifier: ^3.0.7 + version: 3.0.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + yjs: + specifier: ^13.6.23 + version: 13.6.23 packages/core: devDependencies: @@ -3476,10 +3482,10 @@ packages: peerDependencies: '@tiptap/pm': ^2.7.0 - '@tiptap/core@3.27.4': - resolution: {integrity: sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==} + '@tiptap/core@3.28.0': + resolution: {integrity: sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==} peerDependencies: - '@tiptap/pm': 3.27.4 + '@tiptap/pm': 3.28.0 '@tiptap/extension-blockquote@2.14.0': resolution: {integrity: sha512-AwqPP0jLYNioKxakiVw0vlfH/ceGFbV+SGoqBbPSGFPRdSbHhxHDNBlTtiThmT3N2PiVwXAD9xislJV+WY4GUA==} @@ -3584,8 +3590,8 @@ packages: '@tiptap/pm@2.14.0': resolution: {integrity: sha512-cnsfaIlvTFCDtLP/A2Fd3LmpttgY0O/tuTM2fC71vetONz83wUTYT+aD9uvxdX0GkSocoh840b0TsEazbBxhpA==} - '@tiptap/pm@3.27.4': - resolution: {integrity: sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==} + '@tiptap/pm@3.28.0': + resolution: {integrity: sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==} '@tiptap/starter-kit@2.14.0': resolution: {integrity: sha512-Z1bKAfHl14quRI3McmdU+bs675jp6/iexEQTI9M9oHa6l3McFF38g9N3xRpPPX02MX83DghsUPupndUW/yJvEQ==} @@ -8678,10 +8684,10 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/transformer@2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 '@tiptap/starter-kit': 2.14.0 y-prosemirror: 1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: 13.6.23 @@ -9361,9 +9367,9 @@ snapshots: dependencies: '@tiptap/pm': 2.14.0 - '@tiptap/core@3.27.4(@tiptap/pm@3.27.4)': + '@tiptap/core@3.28.0(@tiptap/pm@3.28.0)': dependencies: - '@tiptap/pm': 3.27.4 + '@tiptap/pm': 3.28.0 '@tiptap/extension-blockquote@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: @@ -9467,7 +9473,7 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.9 - '@tiptap/pm@3.27.4': + '@tiptap/pm@3.28.0': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 From 357755108f41443300e67a5781b0bf7a3e556b5d Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 13:09:45 +0200 Subject: [PATCH 05/10] refactor: split streaming-reveal into single-purpose helpers --- packages/ai-toolkit/src/streaming-reveal.ts | 116 +++++++++++--------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 7b48524d6d..81fefadb5f 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -50,10 +50,9 @@ type YSyncState = { const aiInsertRevealKey = new PluginKey('aiInsertReveal') /** - * Resolves one reveal entry to an absolute range, or null if it has expired, its - * anchors no longer resolve (the run's text was deleted by a concurrent edit), or - * the span is empty or implausibly large. Returns the run's `age` so the caller - * can seed the fade from it. + * Resolves one reveal entry to an absolute range, or null if it has expired or no + * longer maps to a valid span. Returns the run's `age` so the caller can seed the + * fade from it. */ function resolveRevealRange( ystate: YSyncState, @@ -61,11 +60,20 @@ function resolveRevealRange( now: number, durationMs: number, ): { from: number; to: number; age: number } | null { - if (!ystate.binding) return null - const age = now - entry.at if (age >= durationMs) return null + const span = resolveSpan(ystate, entry) + return span === null ? null : { ...span, age } +} + +/** + * Resolves a reveal entry's relative-position anchors to an absolute, ordered, + * plausibly-sized span, or null if the run was deleted or its span is invalid. + */ +function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to: number } | null { + if (!ystate.binding) return null + const from = relativePositionToAbsolutePosition( ystate.doc, ystate.type, @@ -78,13 +86,14 @@ function resolveRevealRange( entry.end, ystate.binding.mapping, ) - if (from === null || to === null) return null + return from === null || to === null ? null : orderedSpan(from, to) +} +/** Orders two positions and rejects an empty or implausibly large span. */ +function orderedSpan(from: number, to: number): { from: number; to: number } | null { const a = Math.min(from, to) const b = Math.max(from, to) - if (a >= b || b - a > MAX_REVEAL_RANGE) return null - - return { from: a, to: b, age } + return a >= b || b - a > MAX_REVEAL_RANGE ? null : { from: a, to: b } } /** @@ -99,27 +108,39 @@ function collectInsertedRuns(event: Y.YEvent>, now: numb const runs: RevealEntry[] = [] let index = 0 for (const op of event.delta) { - if (typeof op.retain === 'number') { - index += op.retain - } else if (typeof op.insert === 'string') { - const length = op.insert.length - if (length > 0) { - runs.push({ - start: Y.createRelativePositionFromTypeIndex(target, index), - // Anchor the end to the run's last char (assoc < 0) so the next token - // appended here starts its own run instead of extending this one. - end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), - at: now, - }) - } - index += length - } else if (op.insert !== undefined) { - index += 1 - } + const { advance, inserted } = scanDeltaOp(op) + if (inserted > 0) runs.push(makeRun(target, index, inserted, now)) + index += advance } return runs } +/** + * Reads one delta op for the run scan: how far it advances the cursor, and the + * length of a string insert (0 for any non-string-insert op). + */ +function scanDeltaOp(op: { retain?: number; insert?: unknown }): { + advance: number + inserted: number +} { + if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 } + if (typeof op.insert === 'string') + return { advance: op.insert.length, inserted: op.insert.length } + return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } +} + +/** + * Builds one reveal entry. The end is anchored to the run's last char (assoc < 0) + * so the next token appended here starts its own run instead of extending this one. + */ +function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { + return { + start: Y.createRelativePositionFromTypeIndex(target, index), + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + } +} + /** * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming * into the shared Y.Doc), one run per token, without mutating the document. @@ -166,28 +187,26 @@ export const AiInsertReveal = Extension.create({ props: { decorations: state => { - if (entries.length === 0) return null const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined - if (!ystate?.binding) return null + if (entries.length === 0 || !ystate?.binding) return null const now = Date.now() - const decorations: Decoration[] = [] - for (const entry of entries) { - const range = resolveRevealRange(ystate, entry, now, durationMs) - if (range === null) continue - + const decorations = entries + .map(entry => resolveRevealRange(ystate, entry, now, durationMs)) + .filter((range): range is NonNullable => range !== null) // Seed the CSS animation from the run's real age so a re-render - // (y-tiptap rebuilds the doc on every token) resumes the fade at - // the correct point instead of restarting it. - decorations.push( + // (y-tiptap rebuilds the doc on every token) resumes the fade at the + // correct point instead of restarting it. + .map(range => Decoration.inline(range.from, range.to, { class: className, style: `animation-delay: -${Math.round(range.age)}ms`, }), ) - } - return decorations.length ? DecorationSet.create(state.doc, decorations) : null + // An empty set is equivalent to null here (no decorations rendered); + // the early return above covers the common no-entries case. + return DecorationSet.create(state.doc, decorations) }, }, @@ -236,19 +255,12 @@ export const AiInsertReveal = Extension.create({ const now = Date.now() dropExpired(now) - let captured = false - for (const event of events) { - const runs = collectInsertedRuns(event, now) - if (runs.length > 0) { - entries.push(...runs) - captured = true - } - } - - if (captured) { - scheduleRerender() - schedulePrune() - } + const runs = events.flatMap(event => collectInsertedRuns(event, now)) + if (runs.length === 0) return + + entries.push(...runs) + scheduleRerender() + schedulePrune() } fragment?.observeDeep(onChange) From d1b3421cdd6abdf86fd5dc1b5e4a611f72d98999 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 15:39:03 +0200 Subject: [PATCH 06/10] docs: remove inert biome-ignore directives and trim redundant comments --- .../ai-toolkit/src/streaming-reveal.spec.ts | 4 --- packages/ai-toolkit/src/streaming-reveal.ts | 29 +++---------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts index 54ca2defd0..81c6698a03 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -82,14 +82,11 @@ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { */ function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { for (const plugin of editor.state.plugins) { - // biome-ignore lint/suspicious/noExplicitAny: reading the decoration prop generically const set = (plugin as any).props?.decorations?.call(plugin, editor.state) - // biome-ignore lint/suspicious/noExplicitAny: DecorationSet.find returns internal decoration objects const found = (set?.find?.() ?? []).filter( (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', ) if (found.length > 0) { - // biome-ignore lint/suspicious/noExplicitAny: decoration internals return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' })) } } @@ -138,7 +135,6 @@ describe('AiInsertReveal', () => { expect(editor.getText()).toBe('Hello WORLD') const decorations = revealDecorations(editor) expect(decorations).toHaveLength(1) - // The decoration spans exactly the 6 inserted characters. expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length) // The age-seeded animation-delay is present so the fade survives re-renders. expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 81fefadb5f..d3043f89d9 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -11,8 +11,7 @@ import * as Y from 'yjs' export type AiInsertRevealOptions = { /** * CSS class applied to each freshly-inserted run. Style this class in your app - * to define the fade (see the package docs for a default). Change it to run - * more than one reveal effect, or to avoid a class-name collision. + * to define the fade (see the package docs for a default). */ className: string /** @@ -49,11 +48,6 @@ type YSyncState = { const aiInsertRevealKey = new PluginKey('aiInsertReveal') -/** - * Resolves one reveal entry to an absolute range, or null if it has expired or no - * longer maps to a valid span. Returns the run's `age` so the caller can seed the - * fade from it. - */ function resolveRevealRange( ystate: YSyncState, entry: RevealEntry, @@ -67,10 +61,6 @@ function resolveRevealRange( return span === null ? null : { ...span, age } } -/** - * Resolves a reveal entry's relative-position anchors to an absolute, ordered, - * plausibly-sized span, or null if the run was deleted or its span is invalid. - */ function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to: number } | null { if (!ystate.binding) return null @@ -89,18 +79,12 @@ function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to return from === null || to === null ? null : orderedSpan(from, to) } -/** Orders two positions and rejects an empty or implausibly large span. */ function orderedSpan(from: number, to: number): { from: number; to: number } | null { const a = Math.min(from, to) const b = Math.max(from, to) return a >= b || b - a > MAX_REVEAL_RANGE ? null : { from: a, to: b } } -/** - * Extracts the freshly-inserted text runs carried by one remote Yjs event, - * anchored by relative positions. Returns an empty array when the event's target - * is not a text node or carries no string inserts. - */ function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { const target = event.target if (!(target instanceof Y.XmlText)) return [] @@ -115,10 +99,6 @@ function collectInsertedRuns(event: Y.YEvent>, now: numb return runs } -/** - * Reads one delta op for the run scan: how far it advances the cursor, and the - * length of a string insert (0 for any non-string-insert op). - */ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { advance: number inserted: number @@ -126,12 +106,13 @@ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 } if (typeof op.insert === 'string') return { advance: op.insert.length, inserted: op.insert.length } + // A non-string insert (embed) advances one position but is not a revealed run. return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } } /** - * Builds one reveal entry. The end is anchored to the run's last char (assoc < 0) - * so the next token appended here starts its own run instead of extending this one. + * The end anchor binds to the run's last char (assoc < 0) so a token appended + * here starts its own run instead of extending this one. */ function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { return { @@ -204,8 +185,6 @@ export const AiInsertReveal = Extension.create({ }), ) - // An empty set is equivalent to null here (no decorations rendered); - // the early return above covers the common no-entries case. return DecorationSet.create(state.doc, decorations) }, }, From 326505ef384d84a34e7c1a179669279fe2df5745 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 16:59:13 +0200 Subject: [PATCH 07/10] chore: drop the unrelated core/pm bump from the lockfile --- pnpm-lock.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5afd25efdf..48345b1acc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,7 +150,7 @@ importers: version: 2.15.0(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) '@hocuspocus/transformer': specifier: ^2.15.0 - version: 2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) '@lexical/react': specifier: ^0.36.2 version: 0.36.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(yjs@13.6.23) @@ -3482,10 +3482,10 @@ packages: peerDependencies: '@tiptap/pm': ^2.7.0 - '@tiptap/core@3.28.0': - resolution: {integrity: sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==} + '@tiptap/core@3.27.4': + resolution: {integrity: sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==} peerDependencies: - '@tiptap/pm': 3.28.0 + '@tiptap/pm': 3.27.4 '@tiptap/extension-blockquote@2.14.0': resolution: {integrity: sha512-AwqPP0jLYNioKxakiVw0vlfH/ceGFbV+SGoqBbPSGFPRdSbHhxHDNBlTtiThmT3N2PiVwXAD9xislJV+WY4GUA==} @@ -3590,8 +3590,8 @@ packages: '@tiptap/pm@2.14.0': resolution: {integrity: sha512-cnsfaIlvTFCDtLP/A2Fd3LmpttgY0O/tuTM2fC71vetONz83wUTYT+aD9uvxdX0GkSocoh840b0TsEazbBxhpA==} - '@tiptap/pm@3.28.0': - resolution: {integrity: sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==} + '@tiptap/pm@3.27.4': + resolution: {integrity: sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==} '@tiptap/starter-kit@2.14.0': resolution: {integrity: sha512-Z1bKAfHl14quRI3McmdU+bs675jp6/iexEQTI9M9oHa6l3McFF38g9N3xRpPPX02MX83DghsUPupndUW/yJvEQ==} @@ -8684,10 +8684,10 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/transformer@2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) + '@tiptap/pm': 3.27.4 '@tiptap/starter-kit': 2.14.0 y-prosemirror: 1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: 13.6.23 @@ -9367,9 +9367,9 @@ snapshots: dependencies: '@tiptap/pm': 2.14.0 - '@tiptap/core@3.28.0(@tiptap/pm@3.28.0)': + '@tiptap/core@3.27.4(@tiptap/pm@3.27.4)': dependencies: - '@tiptap/pm': 3.28.0 + '@tiptap/pm': 3.27.4 '@tiptap/extension-blockquote@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: @@ -9473,7 +9473,7 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.9 - '@tiptap/pm@3.28.0': + '@tiptap/pm@3.27.4': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 From 14db45b2e5a7f04c015d3e9d5c997c766a845ae4 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Wed, 22 Jul 2026 14:56:57 +0200 Subject: [PATCH 08/10] pnpm lock --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0367c2bfa8..c2be625c4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,7 +414,7 @@ importers: version: link:../pm '@tiptap/y-tiptap': specifier: ^3.0.7 - version: 3.0.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: specifier: ^13.6.23 version: 13.6.23 From d6da91e70b03e236db456901be5998020e9e62c4 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Thu, 23 Jul 2026 14:17:12 +0200 Subject: [PATCH 09/10] docs: tighten streaming-reveal comments per review --- packages/ai-toolkit/src/streaming-reveal.ts | 31 ++++++++------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index d3043f89d9..4959ed2f5b 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -22,9 +22,10 @@ export type AiInsertRevealOptions = { } /** - * Upper bound (chars) on a single revealed run. A normal streamed token is a - * few chars; this only guards against a mis-resolved relative position yielding - * an absurd range (e.g. spanning the whole document). + * Upper bound (chars) on one revealed run: the text from a single streamed + * insert, usually a token of a few chars. This only guards against a + * mis-resolved relative position yielding an absurd range (e.g. spanning the + * whole document). */ const MAX_REVEAL_RANGE = 400 @@ -123,20 +124,11 @@ function makeRun(target: Y.XmlText, index: number, length: number, now: number): } /** - * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming - * into the shared Y.Doc), one run per token, without mutating the document. - * - * It reads the authored signal directly: each remote Yjs transaction carries a - * delta describing exactly what was inserted and where. Those ranges are stored - * as relative positions and re-resolved to absolute positions on every render to - * drive view-only inline decorations. It does no document diffing and adds no - * marks, so it is inert to accept/reject and to persistence. Local edits are - * ignored via `transaction.local`, so a user typing sees no fade. - * - * Requires the Collaboration extension (its y-sync plugin) to be present. The - * actual fade is defined in CSS on the configured `className` (default - * `ai-insert-reveal`); without that stylesheet the decorations are added but - * invisible. + * Fades in text inserted by remote Yjs transactions using view-only inline + * decorations anchored by relative positions. It never mutates the document, + * so it stays inert to accept/reject and to persistence, and local edits are + * ignored so a user's own typing does not fade. Requires the Collaboration + * extension and CSS on the configured `className` (default `ai-insert-reveal`). */ export const AiInsertReveal = Extension.create({ name: 'aiInsertReveal', @@ -175,9 +167,8 @@ export const AiInsertReveal = Extension.create({ const decorations = entries .map(entry => resolveRevealRange(ystate, entry, now, durationMs)) .filter((range): range is NonNullable => range !== null) - // Seed the CSS animation from the run's real age so a re-render - // (y-tiptap rebuilds the doc on every token) resumes the fade at the - // correct point instead of restarting it. + // y-tiptap rebuilds the whole doc per token, restarting the CSS + // animation; offset by the run's age to resume the fade instead. .map(range => Decoration.inline(range.from, range.to, { class: className, From 696d2cf0f62e97d1edea3aa2a392d3bf4ff59029 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 24 Jul 2026 10:29:53 +0200 Subject: [PATCH 10/10] docs: shorten remaining streaming-reveal comments --- .../ai-toolkit/src/streaming-reveal.spec.ts | 27 +++------------- packages/ai-toolkit/src/streaming-reveal.ts | 31 +++++-------------- 2 files changed, 11 insertions(+), 47 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts index 81c6698a03..678cae0d2d 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -8,13 +8,7 @@ import * as Y from 'yjs' import { AiInsertReveal } from './streaming-reveal.js' -/** - * Creates an editor with the {@link AiInsertReveal} extension and no - * collaboration, to prove the extension loads and degrades gracefully when the - * y-sync plugin it reads is absent. - * - * @return Promise resolving once the editor create lifecycle has finished. - */ +/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */ function createEditor(): Promise { return new Promise(resolve => { const editor = new Editor({ @@ -31,13 +25,7 @@ function createEditor(): Promise { }) } -/** - * Creates a collaborative editor bound to a fresh Y.Doc with {@link AiInsertReveal}, - * seeded with a single `Hello` paragraph. - * - * @param options - Optional reveal configuration forwarded to `configure`. - * @return Promise resolving to the editor and its backing Y.Doc. - */ +/** Creates a collaborative editor (fresh Y.Doc, {@link AiInsertReveal}) seeded with one `Hello` paragraph. */ function createCollabEditor(options?: { durationMs?: number }): Promise<{ editor: Editor; ydoc: Y.Doc }> { @@ -62,11 +50,7 @@ function createCollabEditor(options?: { }) } -/** - * Applies a remote (non-local) insert into the first paragraph's text, mimicking - * an AI streaming into the shared document from another peer. Uses a second Y.Doc - * synced from `ydoc` so the resulting transaction has `local === false`. - */ +/** Applies a remote insert via a synced second Y.Doc. */ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { const remote = new Y.Doc() Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)) @@ -76,10 +60,7 @@ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) } -/** - * Collects the reveal decorations currently produced by the extension, resolved - * against the editor's live state (mirrors what the view renders). - */ +/** Collects the extension's current reveal decorations from the editor's live state. */ function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { for (const plugin of editor.state.plugins) { const set = (plugin as any).props?.decorations?.call(plugin, editor.state) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 4959ed2f5b..4d0244e471 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -9,10 +9,7 @@ import * as Y from 'yjs' * Configuration for {@link AiInsertReveal}. */ export type AiInsertRevealOptions = { - /** - * CSS class applied to each freshly-inserted run. Style this class in your app - * to define the fade (see the package docs for a default). - */ + /** CSS class on each revealed run; style it in your app to define the fade. */ className: string /** * How long (ms) each run keeps its reveal decoration before it is dropped. @@ -21,19 +18,10 @@ export type AiInsertRevealOptions = { durationMs: number } -/** - * Upper bound (chars) on one revealed run: the text from a single streamed - * insert, usually a token of a few chars. This only guards against a - * mis-resolved relative position yielding an absurd range (e.g. spanning the - * whole document). - */ +/** Limits each revealed streamed insert to guard against mis-resolved positions spanning the document. */ const MAX_REVEAL_RANGE = 400 -/** - * One freshly-inserted text run, anchored by Yjs relative positions so it stays - * valid across the whole-document rebuild that y-tiptap applies on every remote - * sync (a plain ProseMirror position would be collapsed by that rebuild). - */ +/** Anchors each inserted run with Yjs relative positions so it survives y-tiptap document rebuilds. */ type RevealEntry = { start: Y.RelativePosition end: Y.RelativePosition @@ -111,10 +99,7 @@ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } } -/** - * The end anchor binds to the run's last char (assoc < 0) so a token appended - * here starts its own run instead of extending this one. - */ +/** End anchor uses assoc < 0 so an appended token starts a new run, not extends this one. */ function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { return { start: Y.createRelativePositionFromTypeIndex(target, index), @@ -124,11 +109,9 @@ function makeRun(target: Y.XmlText, index: number, length: number, now: number): } /** - * Fades in text inserted by remote Yjs transactions using view-only inline - * decorations anchored by relative positions. It never mutates the document, - * so it stays inert to accept/reject and to persistence, and local edits are - * ignored so a user's own typing does not fade. Requires the Collaboration - * extension and CSS on the configured `className` (default `ai-insert-reveal`). + * Fades in remote Yjs inserts with view-only decorations. It never mutates the + * document, so it is inert to accept/reject and persistence, and ignores local edits. + * Requires Collaboration and CSS for `className` (default `ai-insert-reveal`). */ export const AiInsertReveal = Extension.create({ name: 'aiInsertReveal',