feat(composer)!: TipTap rich composer epic (#293) — stacked delivery [WIP] - #313
Conversation
…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>
…keleton (31 unit tests green)
…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>
dfd2212 to
b461cc5
Compare
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| const popoverHasOptions = () => (popover()?.items.length ?? 0) > 0 | ||
| const enterBypassed = (view: EditorView, event: KeyboardEvent) => | ||
| event.key !== 'Enter' || enterInsideComposition(view, event) || popoverHasOptions() |
There was a problem hiding this comment.
Fixed in e5c18d0: only unshifted Enter is deferred to the suggestion handler; Shift+Enter reaches splitBlock while the typeahead is open. Story added.
| 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 |
| tooltip="Close chat" | ||
| class={`${CLOSE}${connectMode && disconnect ? '' : ' ml-auto'}`} | ||
| onClick={() => setShutter(router, false)} | ||
| onClick={() => void setShutter(router, false)} |
There was a problem hiding this comment.
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.
| const anchoredBottom = () => position().startsWith('bottom') | ||
| const anchoredRight = () => position().endsWith('right') | ||
| const close = () => setShutter(router, false) | ||
| const close = () => void setShutter(router, false) |
There was a problem hiding this comment.
Fixed in a3c83ec: resize-collapse close routes through the same usePanelChrome owner as every other close path.
| const userMessages = page.locator('[data-role="user"]') | ||
| await expectLocator(userMessages).toHaveCount(1) | ||
| await expectLocator(userMessages.first()).toContainText('hello') |
There was a problem hiding this comment.
Fixed in the repair batch: the submit control is located by role/name; no CSS implementation-detail selectors remain in this file.
| 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 |
There was a problem hiding this comment.
Fixed in the repair batch: the active option is scrolled into view on activedescendant change (suggestion-listbox.tsx, scrollIntoView block:nearest).
| slash: { | ||
| label: 'Commands', | ||
| items: (query) => matchingItems((commands.data?.commands ?? []).map(commandEntry), query), | ||
| }, |
There was a problem hiding this comment.
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.
| mention: { | ||
| label: 'Tools', | ||
| items: (query) => matchingItems((tools.data?.tools ?? []).map(toolEntry), query), | ||
| }, |
There was a problem hiding this comment.
Fixed in a3c83ec: same ensureQueryData treatment for the mention source (meta.tools).
…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>
…-flight, resize onCollapse finding
…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>
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
In flight on this branch
Related, not closed by this PR
🤖 Generated with Claude Code