Skip to content

feat(composer)!: TipTap rich composer epic (#293) — stacked delivery [WIP] - #313

Merged
omridevk merged 15 commits into
mainfrom
tiptap-epic-293
Aug 8, 2026
Merged

feat(composer)!: TipTap rich composer epic (#293) — stacked delivery [WIP]#313
omridevk merged 15 commits into
mainfrom
tiptap-epic-293

Conversation

@omridevk

@omridevk omridevk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Epic #293 — rich composer input: TipTap field in ui-kit-tap, zero chat-kit API change, string-canonical state (chips lower to directive text; server payload byte-identical). Delivered as one PR built from individually gated stages.

Closes #293
Closes #294
Closes #295
Closes #296
Closes #299

Stages (each landed with full typecheck/build/test + fallow pass)

Numbers

  • Widget global bundle: 2,698,243 → 2,792,738 B gzip (+92.3 KiB, inside the ~+98 KiB accepted by the spec)
  • Chat kit: zero editor dependency, zero new exports (diff is pure deletion)

In flight on this branch

  • Codex (gpt-5.6-sol) review findings being resolved (stale-selectable-during-load, Escape ordering, exact-payload + stepwise-atomicity test strengthening)
  • Pane-container reinsert bug (pre-existing on main, blurs late-mounted composer focus; also suspected cause of the known scroll-reset): root-cause + structural fix in progress on a separate branch off main — the deferred lazy-mount autofocus criterion closes when it lands

Related, not closed by this PR

🤖 Generated with Claude Code

omridevk and others added 8 commits August 7, 2026 23:10
…put adapter (#294)

All textarea knowledge collapses into one widget-local adapter at the
composer render site: chat-pane focuses through the adapter handle,
draft persistence rides the composer store plus the adapter's
selection-offset callback, and the panel view appends drafts through
the shared store-side writer. Zero ui-kit-chat changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…actor, embed IT suite dedup

Rich-text field at textarea parity (typing/submit, Shift+Enter, IME guard,
paste lowering, undo/redo, external replace, handle insert/append/clear,
1-row min to 5-row cap with viewport scroll). Lowering offset/position
mapping split into per-block scan helpers; keydown/paste/attribute logic
extracted so every function clears the fallow complexity gate. Embed IT
boot boilerplate extracted into test/helpers/suite.ts and adopted by the
three duplicated suites. Drop unused @tiptap/extensions devDep from the
storybook app.

Cap-overflow paint report disproven at pixel level: the clipped line
renders below the field border inside the viewport; no glyphs escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…us restore on close

Panel open focuses the composer through the widget input adapter handle:
the adapter reports readiness on mount, the panel layout focuses via a
handle signal, and the FocusTrap no longer steals initial focus to the
resize separator (initialFocus resolves to false, trap-owned return
focus disabled). Panel close restores the host element captured at open
time (shadow-chain-aware capture before the widget takes focus), applied
after the shutter navigation commits so the still-active trap cannot
yank focus back into the closing panel; FAB focus remains the fallback
when nothing was captured.

Known limitation (reported, not shimmed): when the composer mounts long
after the open activation (slow draft / boot-with-open-panel), a
pre-existing section-level reconcile reinserts the pane container ~10ms
after composer mount and throws focus to body; the lazy-mount autofocus
acceptance item is blocked on fixing that reinsert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… typeahead listbox

Suggestion nodes on the TipTap suggestion utility for / and @, atomic chip
insertion with conditional trailing space, forward-delete symmetry, signal-
driven listbox with loading/empty/error states and stale-result discard,
combobox ARIA on the editable (expanded/controls/activedescendant).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g-splice trigger layer deleted

The widget's input adapter renders ui-kit-tap's RichTextField in place of
ComposerPrimitive.Input, bound to the composer store via the kit's public
hooks; live command/tool queries feed the field's trigger sources and chips
lower to the identical directive strings. The trigger machinery, slash and
mention adapter behaviors, directive formatter, trigger exports, and the
styled composer popover slot are removed; ComposerPrimitive.Input stays the
plain textarea primitive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oad restore, mount externals

Embed and app composer assertions move from input value to visible text; a
new integration suite proves byte-identical directive strings on the wire,
atomic chip backspace, failed-send restore, reload draft flattening, and IME
Enter-after-composition plus send-button-during-composition; the fake harness
grows scriptable slash commands; mount-externals asserts ui-kit-tap is
externalized; trigger story selection asserts settle before Selection.modify
so a pending editor selection sync cannot clobber the moved caret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@omridevk
omridevk requested a balanced review from Copilot August 7, 2026 23:04
@omridevk
omridevk marked this pull request as ready for review August 7, 2026 23:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the TipTap rich composer, replaces legacy string-splice triggers with atomic chips, and adds panel focus/draft persistence support.

Changes:

  • Adds rich text lowering, selection mapping, typeaheads, chips, and stories.
  • Integrates the rich composer into the widget and removes legacy chat-kit triggers.
  • Expands browser coverage, test infrastructure, focus restoration, and harness fixtures.

Reviewed changes

Copilot reviewed 63 out of 64 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
packages/ui-kit-tap/vitest.config.ts Adds Node-based tests.
packages/ui-kit-tap/tsconfig.json Includes tests and Vitest config.
packages/ui-kit-tap/test/selection-mapping.test.ts Tests offset mapping.
packages/ui-kit-tap/test/lowering.test.ts Tests document lowering.
packages/ui-kit-tap/src/trigger-suggestions.ts Implements trigger behavior.
packages/ui-kit-tap/src/trigger-popover.tsx Renders suggestion listboxes.
packages/ui-kit-tap/src/rich-text-field.tsx Adds the rich field primitive.
packages/ui-kit-tap/src/rich-text-field.stories.tsx Covers core field behavior.
packages/ui-kit-tap/src/rich-text-field-triggers.stories.tsx Covers trigger and chip behavior.
packages/ui-kit-tap/src/lowering.ts Adds projection and offset mapping.
packages/ui-kit-tap/src/index.tsx Exports rich field APIs.
packages/ui-kit-tap/package.json Adds editor testing dependencies.
packages/ui-kit-chat/test/trigger-popover-model.test.ts Removes legacy model tests.
packages/ui-kit-chat/test/trigger-adapters.test.ts Removes adapter tests.
packages/ui-kit-chat/test/directive-formatter.test.ts Removes formatter tests.
packages/ui-kit-chat/test/detect-trigger.test.ts Removes trigger detection tests.
packages/ui-kit-chat/src/styled/composer.tsx Removes the popover slot.
packages/ui-kit-chat/src/primitives/composer/trigger/types.ts Removes legacy trigger types.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-selection-resource.ts Removes string-splice selection.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-popover.tsx Removes trigger primitives.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-popover.stories.tsx Removes legacy stories.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-popover-model.ts Removes trigger model.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-popover-async.stories.tsx Removes async legacy story.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-navigation-resource.ts Removes legacy navigation.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-keyboard-resource.ts Removes legacy keyboard handling.
packages/ui-kit-chat/src/primitives/composer/trigger/trigger-detection-resource.ts Removes detection state.
packages/ui-kit-chat/src/primitives/composer/trigger/directive-formatter.ts Removes directive formatter.
packages/ui-kit-chat/src/primitives/composer/trigger/detect-trigger.ts Removes text trigger scanning.
packages/ui-kit-chat/src/primitives/composer/composer.tsx Decouples textarea from triggers.
packages/ui-kit-chat/src/index.tsx Removes legacy trigger exports.
packages/ui-kit-chat/src/behaviors/create-slash-command-adapter.ts Removes slash adapter.
packages/ui-kit-chat/src/behaviors/create-mention-adapter.ts Removes mention adapter.
packages/harness-testkit/src/create-fake-harness.ts Adds fake command capability.
packages/extension-testkit/src/core-kit.ts Passes commands into fixtures.
packages/embed/test/reload-continuity.it.test.ts Uses shared suite and rich assertions.
packages/embed/test/recording-attachment.it.test.ts Updates contenteditable assertion.
packages/embed/test/panel-focus.it.test.ts Tests panel focus behavior.
packages/embed/test/mount-externals.test.ts Verifies editor externalization.
packages/embed/test/model-selector.it.test.ts Uses shared widget suite.
packages/embed/test/helpers/suite.ts Adds shared browser setup.
packages/embed/test/helpers/boot.ts Supports harness commands.
packages/embed/test/embed.it.test.ts Updates rich composer assertions.
packages/embed/test/draft-selection.it.test.ts Tests persisted caret offsets.
packages/embed/test/composer-rich-input.it.test.ts Adds rich composer integration tests.
packages/embed/package.json Adds rich text dependency.
apps/storybook/.storybook/main.ts Prebundles TipTap extensions.
apps/conciv/test/draft-storage.test.ts Tests selection-aware storage.
apps/conciv/test/chat-pane.browser.test.tsx Updates rich input assertions.
apps/conciv/src/routes/panel.tsx Adds composer autofocus wiring.
apps/conciv/src/routes/panel.$sessionId.tsx Updates close navigation handling.
apps/conciv/src/routes/panel.$sessionId.$view.tsx Reuses draft append helper.
apps/conciv/src/routes/__root.tsx Adds host focus restoration.
apps/conciv/src/pane/trigger-sources.ts Provides command/tool sources.
apps/conciv/src/pane/trigger-menus.tsx Removes legacy trigger menus.
apps/conciv/src/pane/pane-composer.tsx Renders the rich input adapter.
apps/conciv/src/pane/draft-storage.ts Persists canonical selections.
apps/conciv/src/pane/composer-input-adapter.tsx Bridges composer and rich field.
apps/conciv/src/pane/chat-pane.tsx Integrates triggers, focus, and storage.
apps/conciv/src/lib/shutter.ts Returns navigation completion.
apps/conciv/src/lib/host-focus.ts Finds host focus targets.
apps/conciv/src/app/panel-focus.ts Shares composer focus handles.
apps/conciv/package.json Adds rich text dependency.
.changeset/tiptap-composer-final-cut.md Records the composer release change.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (1)

packages/embed/test/composer-rich-input.it.test.ts:159

  • This repeats the banned CSS implementation-detail selector in a widget integration test. Assert the committed draft through its user-visible text locator instead.
    const userMessages = page.locator('[data-role="user"]')
    await expectLocator(userMessages).toHaveCount(1)
    await expectLocator(userMessages.first()).toContainText('committed draft')

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +153 to +158
const keydown = ({event}: SuggestionKeyDown): boolean => {
const state = selectableState()
if (!state) return false
const delta = arrowDelta(event)
if (delta !== 0) return navigate(state, delta)
return plainEnter(event) && select(state)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e5c18d0: Escape now routes through zag's dismissable layer (PM's eventBelongsToView ignores already-defaultPrevented events, so handling it in the suggestion onKeyDown was a dead end); first Escape closes the typeahead, second cancels. Widget-context regression test added.

Comment on lines +155 to +157
const popoverHasOptions = () => (popover()?.items.length ?? 0) > 0
const enterBypassed = (view: EditorView, event: KeyboardEvent) =>
event.key !== 'Enter' || enterInsideComposition(view, event) || popoverHasOptions()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e5c18d0: only unshifted Enter is deferred to the suggestion handler; Shift+Enter reaches splitBlock while the typeahead is open. Story added.

Comment on lines +47 to +50
export async function makeDraftStorage(rpc: RpcClient, sessionId: string): Promise<PaneDraftStorage> {
const row = await rpc.drafts.get({sessionId}).catch(() => null)
let cache = row ? JSON.stringify({text: row.text, quote: null, grabs: row.grabs, attachments: []}) : null
let selection: SelectionOffsets | null = null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eb2359c (+ a3c83ec): restored offsets flow through initialSelection into the field's opening selection command, and the panel-open focus call no longer forces end-of-document. IT 'a reload restores the caret where the draft left it' guards it.

tooltip="Close chat"
class={`${CLOSE}${connectMode && disconnect ? '' : ' ml-auto'}`}
onClick={() => setShutter(router, false)}
onClick={() => void setShutter(router, false)}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3c83ec: all close paths go through one owner (usePanelChrome), so host-focus restore + FAB fallback run for the Close button too. Covered in panel-focus.it.test.ts.

Comment thread apps/conciv/src/routes/panel.tsx Outdated
const anchoredBottom = () => position().startsWith('bottom')
const anchoredRight = () => position().endsWith('right')
const close = () => setShutter(router, false)
const close = () => void setShutter(router, false)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3c83ec: resize-collapse close routes through the same usePanelChrome owner as every other close path.

Comment on lines +140 to +142
const userMessages = page.locator('[data-role="user"]')
await expectLocator(userMessages).toHaveCount(1)
await expectLocator(userMessages.first()).toContainText('hello')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the repair batch: the submit control is located by role/name; no CSS implementation-detail selectors remain in this file.

Comment on lines +140 to +143
const navigate = (state: TriggerPopoverState, delta: number): true => {
const activeIndex = (state.activeIndex + delta + state.items.length) % state.items.length
options.access.update({...state, activeIndex})
return true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the repair batch: the active option is scrolled into view on activedescendant change (suggestion-listbox.tsx, scrollIntoView block:nearest).

Comment on lines +42 to +45
slash: {
label: 'Commands',
items: (query) => matchingItems((commands.data?.commands ?? []).map(commandEntry), query),
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3c83ec: sources are async and await queryClient.ensureQueryData(...), so a trigger opened before first load resolves with the settled data instead of snapshotting []. Stale results are discarded by the suggestion plugin's abort handling.

Comment on lines +46 to +49
mention: {
label: 'Tools',
items: (query) => matchingItems((tools.data?.tools ?? []).map(toolEntry), query),
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3c83ec: same ensureQueryData treatment for the mention source (meta.tools).

omridevk and others added 7 commits August 8, 2026 03:43
…yer, Shift+Enter, dep peers

The trigger popover and MentionField's hand-rolled popover collapse into a
single SuggestionListbox positioned by Ark Popover against a virtual caret
anchor (getAnchorRect), so no component computes left/top itself. Its panel and
option surfaces come from ui-kit-system's shared list-panel tokens, which Menu
now consumes too.

ProseMirror ignores events whose default is already prevented
(eventBelongsToView), and zag's dismissable layer prevents Escape in the
document capture phase, so the editor could never see Escape once the panel was
open. Escape now routes through the layer's own onEscapeKeyDown into
exitSuggestion() with the trigger's plugin key.

Shift+Enter is handled before the typeahead's Enter claim, so a newline still
breaks the line while the panel is open instead of falling through to the
browser's contenteditable default.

Arrow navigation scrolls the active option into its listbox viewport.

@tiptap/suggestion and @floating-ui/dom are peers of our tiptap dependencies
that only resolved through this workspace's install layout; consumer apps could
not resolve them from the published dist. They are now declared, with a guard
test asserting every direct dependency's peers are provided.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t detach the pane

Every newly mounted component that reads useQuery(...).data suspends the
nearest Suspense boundary for as long as its brand-new resource is
unresolved, even with a warm cache. The nearest boundary was the one
TanStack Router's Match wraps around the route component, and Solid's
Suspense yields undefined while suspended, so the panel section's array
reconcile detached and re-appended the pane's container - blurring the
focused editor and resetting the transcript scroll.

The composer's action-row widgets, the extension surfaces and the draft
resource now own boundaries below the pane container and below the
message editor, so a widget waiting on data can no longer take the pane,
the viewport or the editor out of the document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel-open focus effect asked the field to focus at the end of the
document, so the selection offsets restored from the persisted draft were
overwritten the instant the panel opened and the first keystroke after a
reload landed after the last character instead of where the caret was.

Focusing at the field's current selection keeps the offsets that
openingSelectionCommand applied from the restored draft; an empty or fresh
composer still lands at the end because that command defaults there.

Also extracts the triplicated host-panel opening in the panel focus
integration test and reformats trigger-sources, both left by the checkpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t a hand-rolled walker

projectDocument now calls doc.textBetween with the schema's own leafText, so chips
declare their sigil+id projection in their node spec and lowering.ts holds zero
per-node knowledge. buildDocument assembles paragraphs via schema.node / schema.text
/ Fragment with no HTML parsing anywhere. Offset<->position mapping walks the flat
doc -> paragraph -> (text | leaf) shape with real PM accessors and derives a leaf's
lowered length generically from spec.leafText.

field-schema.ts is the single declaration of the composer's node set; the editor and
the lowering engine both build from it, and both mapping functions also accept a live
ProseMirror node so the field maps against the editor's own schema instead of a JSON
round trip. Adding an inline node to the schema now lowers and maps with no
lowering.ts edit, proved by a throwaway leaf node in the new test.

In the field, the attribute derivation moves out of the effect body, the impossible
null-paragraph branch and the duplicated chip renderText both disappear, and the
paragraph assembly is shared with lowering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… suspend

On a loaded CI runner the panel opened with an unfocused composer: the field
registered its handle from onMount while its DOM still lived in a suspended
Suspense boundary's detached fragment, so tiptap's focus command reached
HTMLElement.focus() on a disconnected node - a silent one-shot no-op that
nothing ever retried. Local runs resolved every query before the composer
mounted, so the boundary never suspended and the race never showed.

Two boundaries could detach it. The one wrapping the whole Thread let a
still-loading transcript take the composer down with it, so it now sits inside
the viewport and covers only the transcript. The composer's own boundary
re-suspended on the harness-metadata query, because reading useQuery(...).data
goes through the query resource; the image-input capability is read only once
that query has settled, which leaves the draft resource as the only thing that
boundary can ever wait on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… races

The rebind test waited for the panel dialog and then required zero copies of
the assistant reply. That zero is not the contract - it is the ~420ms hole
between the keyed remount tearing the pane down and the new subscription
replaying the session. The dialog wait ahead of it consumes that hole on a
loaded runner, so the count is already back to one when the first poll lands
and the assertion can never recover.

A rebind that preserves the session must preserve its transcript, so the test
now asserts the transcript comes back intact and unduplicated - exactly one
user message, exactly one reply - which holds from the moment the pane
remounts and stays true afterwards.

The reply count after the second turn is dropped rather than moved: the
restored first reply already satisfied it, so it never distinguished a
delivered second turn from a stale one. What that assertion was reaching for
is not true today - the turn sent after a rebind flashes into the transcript
and vanishes - and that belongs in its own change, not behind an assertion
that passes either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants