Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
32b7d87
feat: add streaming-reveal extension to @tiptap/ai-toolkit
ozdemircibaris Jul 16, 2026
4b1c45e
cover AiInsertReveal behavior and simplify its plugin functions
ozdemircibaris Jul 17, 2026
37c55ca
refactor: name the reveal source after its streaming-reveal entry
ozdemircibaris Jul 17, 2026
e1faf23
Merge remote-tracking branch 'origin/main' into feat/streaming-reveal…
ozdemircibaris Jul 17, 2026
c3a99c6
chore: update lockfile for the streaming-reveal deps
ozdemircibaris Jul 17, 2026
3577551
refactor: split streaming-reveal into single-purpose helpers
ozdemircibaris Jul 17, 2026
d1b3421
docs: remove inert biome-ignore directives and trim redundant comments
ozdemircibaris Jul 17, 2026
326505e
chore: drop the unrelated core/pm bump from the lockfile
ozdemircibaris Jul 17, 2026
101716b
Merge remote-tracking branch 'origin/main' into feat/streaming-reveal…
ozdemircibaris Jul 22, 2026
14db45b
pnpm lock
ozdemircibaris Jul 22, 2026
d6da91e
docs: tighten streaming-reveal comments per review
ozdemircibaris Jul 23, 2026
696d2cf
docs: shorten remaining streaming-reveal comments
ozdemircibaris Jul 24, 2026
6711609
fix(ai-toolkit): clamp streaming-reveal spans instead of dropping them
ozdemircibaris Aug 7, 2026
ddbf14d
feat: reveal only the AI's streamed inserts
ozdemircibaris Aug 10, 2026
22d0b32
Merge branch 'main' into feat/streaming-reveal-extension
ozdemircibaris Aug 10, 2026
571bb78
fix: resolve the duplicate y-tiptap instance that broke the reveal tests
ozdemircibaris Aug 10, 2026
c71e7f2
fix: stop revealing a document that arrives as one sync
ozdemircibaris Aug 10, 2026
c03e2d5
docs: tighten streaming-reveal comments
ozdemircibaris Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/new-pots-admire.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 22 additions & 2 deletions packages/ai-toolkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Comment thread
bdbch marked this conversation as resolved.
},
"scripts": {
Expand All @@ -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
}
}
}
221 changes: 221 additions & 0 deletions packages/ai-toolkit/src/streaming-reveal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// @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 './streaming-reveal.js'

/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */
function createEditor(): Promise<Editor> {
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)
},
})
})
}

/** 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 }> {
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('<p>Hello</p>')
resolve({ editor, ydoc })
},
})
})
}

/** 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))
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 extension's current reveal decorations, resolved against `state`. */
function revealDecorations(
editor: Editor,
state: unknown = editor.state,
): Array<{ from: number; to: number; style: string }> {
for (const plugin of editor.state.plugins) {
const set = (plugin as any).props?.decorations?.call(plugin, state)
const found = (set?.find?.() ?? []).filter(
(d: any) => d.type?.attrs?.class === 'ai-insert-reveal',
)
if (found.length > 0) {
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')
})

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<Editor>(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()
})

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)
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('reveals an insert at the very end of the document', async () => {
const { editor, ydoc } = await createCollabEditor()

remoteInsert(ydoc, 5, '!')

expect(editor.getText()).toBe('Hello!')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe(1)
expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size)

editor.destroy()
})

it('clamps a decoration that resolves past the end of the document', async () => {
const { editor, ydoc } = await createCollabEditor()

remoteInsert(ydoc, 5, ' WORLD')
expect(revealDecorations(editor)).toHaveLength(1)

// Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc.
const shortDoc = editor.schema.nodeFromJSON({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }],
})
const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc })

expect(decorations).toHaveLength(1)
expect(decorations[0].to).toBe(shortDoc.content.size)

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('clamps a run longer than the max reveal range to that many characters', async () => {
const { editor, ydoc } = await createCollabEditor()

remoteInsert(ydoc, 5, 'x'.repeat(401))

expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`)
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
// The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400).
expect(decorations[0].to - decorations[0].from).toBe(400)

editor.destroy()
})

it('drops a run whose resolved span no longer matches its inserted length', async () => {
const { editor, ydoc } = await createCollabEditor()

remoteInsert(ydoc, 5, ' WORLD')
// 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale.
remoteInsert(ydoc, 8, 'XYZ')

const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe(3)

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()
})
})
Loading
Loading