Changelog

Every release of @intentface/chat, newest first.

@intentface/chat

0.5.2

Patch Changes

  • #80 07671d3 Thanks @rpvilo! - Documentation only, no runtime change. Every part of Attachments, Chip, Message, Reasoning and Steps now carries JSDoc saying what it is for and which element it renders, so editors show the same reference as the docs site. The package README is refreshed to match the rewritten docs.

0.5.1

Patch Changes

  • #85 b49fb7a Thanks @rpvilo! - Thread keeps the bottom pinned while its container is resized.

    Resizing with a long last turn. Dragging a chat window's height let the bottom drift out of view until the next content change re-pinned it, whenever the last turn was taller than its reserve: the content didn't change size, so nothing fired. The follow now observes the scroller as well as the content, so a resize re-pins in the same frame.

    No smooth scroll queued behind a resize. One resize settles over several observer passes in a frame: the scroller's box changes first, then the content resizes to the new reserve a pass later. That later pass read as content growth and queued a smooth scroll behind the instant re-pin. Re-pins now stay instant for the rest of a frame in which the scroller resized.

    New turns still land smoothly, streaming still follows smoothly, and scrolling up still releases the follow.

0.5.0

Minor Changes

  • #83 b50a489 Thanks @jonijuup! - Escape now reaches only the thing it was meant for, found by putting a Composer inside a floating Tabs.Popup.

    Tabs: an Escape something else handled no longer closes the panel. Tabs.Root's dismissOnEscape closed the open panel on every Escape, even one another handler had already used and marked with preventDefault(). A composer inside the popup stopping a reply, or closing its command list, closed the popup with the same press. Now each press peels one layer.

    Composer: Escape stops only the composer it was pressed in. While isGenerating, Composer.Submit (and useComposerSubmit) stopped on an Escape anywhere on the page. With two composers on a page, an Escape typed into one stopped the other, and an Escape in an unrelated text field stopped it too. The stop now runs from Composer.Root's onKeyDown, so it hears only Escapes from inside that composer, including parts a positioned Composer.Panel renders elsewhere on the page. A consumer's onKeyDown can skip it with event.preventPrimitiveHandler().

    Breaking: an Escape pressed after focus has left the composer (the page body, a button, the transcript) no longer stops generation. An app that wants that handles Escape on its chat's container, such as Thread.Root, and calls its own stop.

0.4.0

Minor Changes

  • #81 a7b9655 Thanks @jonijuup! - Four additions to Nav and Tabs, found by building a real app's file tree and page tabs on them. All are additive; nothing changes unless you opt in, apart from the new aria-current attribute, a disabled-trigger fix, and one rename (last below).

    Nav: a branch that is also a page. The new Nav.Toggle part is a caret for inside Nav.Trigger. Mounting it makes pressing the row (click, Enter or Space) activate it like Nav.Item, so its onClick or link routes, while the caret opens and closes the group. The caret is a real button that carries aria-expanded for the group, so name it with aria-label; like Nav.Action it stays out of the arrow-key order, where ArrowRight and ArrowLeft still open and close the group from the row. A press on the caret never follows a link row, and the caret respects a disabled trigger. Space now follows a link row as Enter does, on Nav.Item too.

    A disabled Nav.Trigger now also prevents the click's default action, as a disabled Nav.Item always has — so one rendered as a link no longer navigates while disabled.

    Nav: aria-current="page" on active rows. active on Nav.Item and Nav.Trigger previously produced only data-active, which assistive tech cannot see. Your own aria-current still wins.

    Tabs: openOnHover. Tabs.Trigger gains openOnHover, openDelay (50ms) and closeDelay (50ms). Resting the mouse on a tab opens it, and leaving closes it again unless the mouse heads into the popup, which a prediction cone tracks. A press on the tab, or a press or focus inside the popup, keeps it open. Hover never takes over a tab someone opened with a press, and only a mouse hovers.

    Tabs: change event details. onValueChange and onItemsChange receive a second argument, eventDetails, with reason, event, trigger, cancel() and isCanceled. Cancelling stops the change landing, so an editor can cancel a close in onItemsChange and ask about unsaved work first. The selection that follows a close is reported but cannot be cancelled, since its tab is already gone. The reasons are "trigger-press", "trigger-hover", "list-navigation", "close-press", "keyboard", "escape-key" and "imperative-action". Callbacks that take one argument keep working.

    Escape from inside a floating Tabs.Popup now hands focus back to its tab, instead of dropping it on the page as the popup closes.

    Breaking: event.preventBaseUIHandler() is now event.preventPrimitiveHandler(), and event.baseUIHandlerPrevented is now event.primitiveHandlerPrevented. Call it from your own handler on a part to skip the part's built-in handler. Development warnings now start with @intentface/chat: and link to the composition guide.

0.3.0

Minor Changes

  • #78 5872ad5 Thanks @rpvilo! - Add three app-shell primitives alongside the chat ones: Shell (a collapsible, resizable sidebar with hover-peek), Nav (a nav tree with roving focus, typeahead and a guide ladder) and Tabs (an open-ended, closable collection whose panel renders in the layout or anchored to its own tab). Each ships on the usual three-module layout, so a server component reaches every part.

    None of them persist anything or claim a global key: state goes out through callbacks (onOpenChange, onExpandedChange, onValueChange, onItemsChange, and the sidebar's onResize) and comes back in as default* props or, for the sidebar's width, as the --shell-sidebar-width custom property. Where it is kept, and which keystroke toggles what, stay the app's to decide.

    Also fixes the shared transition internals: useAnimationsFinished now runs its callback when there is no element rather than returning silently, which previously left a closing Composer.Panel or popover mounted for good, and gained a subtree option. useAnchorPositioning publishes --anchor-available-width and no longer animates a surface in from its pre-placement position.

0.2.1

Patch Changes

  • #76 fd5575b Thanks @rpvilo! - Thread no longer chases the live edge with a smooth scroll while the container around it is being resized. A consumer that animates the thread open — a collapsed pill springing to a panel, a drawer sliding in — saw the transcript land mid-viewport and then visibly scroll to the bottom over the length of the animation. It now lands at the bottom and stays there.

    The follow is driven by a ResizeObserver on the content column, which fires for two different things it could not tell apart: a token streaming in, and the viewport itself changing size. The second is not hypothetical for auto-scrolling threads — in every mode that lands at the top (follow, jump), the last turn reserves a viewport via --thread-turn-min-height: var(--thread-turn-area), and --thread-turn-area is derived from the thread root's clientHeight. So a container animating its height rewrites that variable each frame, resizing the content column each frame, and each resize was answered with a fresh smooth scroll to a target that had already moved.

    follow now compares the scroll viewport's own box against the previous callback's. Streamed content never changes it; a container animating open or a window resize always does. A composer docked over the transcript is out of the scroller's flow, so growing it moves no box and is not covered here. A changed box still pins to the live end — it just does so instantly, which is the whole difference between landing at the bottom and animating toward it. Width is compared alongside height, so a container that expands horizontally and reflows the transcript is covered too.

    The intent-only gating is unchanged: the resize branch is a synchronous clientWidth / clientHeight read inside the observer callback, not the one-frame-stale at-bottom snapshot that follow deliberately avoids consulting. Threads in a static container are unaffected — the viewport box never changes, so every callback takes the existing smooth path.

    autoScroll="bottom" never exhibited this, since it is the one mode that sets no reserve.

  • #76 fd5575b Thanks @rpvilo! - Thread no longer loses a few pixels of scroll position when the composer changes height. Adding or discarding an attachment — anything that grows or shrinks the dock while you are pinned to the bottom — nudged the transcript down by a handful of pixels and never gave them back. Repeated often enough, the thread drifted away from the live edge.

    useThreadInsets derives two custom properties from one dock measurement, and they are designed to cancel: the content wrapper pads by --thread-overlay-bottom-height, and the same inset is subtracted from --thread-turn-area, which the last turn reserves. Their sum is constant, so the scrollable height should never move when the dock resizes.

    It moved anyway, because the write order broke the cancellation. The padding was written first, then measureTopInset and root.clientHeight were read — both force a synchronous layout. That layout ran with the new padding against the old reserve, so scrollHeight dipped for exactly one frame. The browser clamps scrollTop to fit shorter content, and clamping is not reversed when the content grows back a frame later, so each dock resize cost a few pixels permanently.

    Every measurement is now read before either property is written, so both land in the same recalculation and no intermediate layout exists to clamp against. Measured across an attachment discard: --thread-turn-area climbs 504px → 566px over 23 frames while scrollTop, the scroll maximum, and the last turn's on-screen position all hold still. Previously scrollTop dropped 5351 → 5345 on the third frame and stayed there.

0.2.0

Minor Changes

  • #69 0fb9b14 Thanks @rpvilo! - Disclosure panels (Steps.Panel, Reasoning.Content) no longer stay pinned to the height they had when they opened. The panel measures its natural height for the transition and then releases that measurement once the transition settles, so an open panel tracks content that appears underneath it — a nested disclosure expanding, rows streaming in. Previously the measurement was written on open and never cleared, so any consumer following the documented height: var(…) pattern had its content clipped at the open-time height.

    Breaking: the measured height is published as --panel-height instead of --collapsible-panel-height. The old name leaked an internal: Collapsible is not a public part, cannot be imported, and appears nowhere in the docs, so a consumer styling Steps.Panel had to reach for a variable named after a component they could not see. The variable is now documented on both parts.

    diff
     [data-steps-panel] {
       overflow: hidden;
    -  height: var(--collapsible-panel-height);
    +  height: var(--panel-height);
       transition: height 150ms ease-out;
     }

    Note the value is present only while the open or close transition runs, which is what makes the fallback to auto work while open. That is the intended contract, not a gap.

    Two supporting changes, both internal:

    • useTransitionStatus is now called with enableIdleState and deferEndingState enabled. idle is the settled-open status the release gates on, and deferring ending by a frame leaves one frame where a closing panel is still at its open size — which is where the close has to be measured. Previously the close was measured on the ending frame, with the closed styles already applied, so it measured the clamped box.
    • The unmount is now gated on transitionStatus === "ending" rather than merely !open. With the ending state deferred, the earlier check could call getAnimations() before the closed styles landed, find nothing running, and cut the exit animation off. This also affects Composer.Panel, the other consumer of the shared transition hook.

    Measurement also neutralizes inline alignment properties for the read and restores them immediately, matching Base UI — inline alignment can distort a scroll-based measurement.

  • #67 414d132 Thanks @rpvilo! - Breaking: Thread.Root no longer takes dockSelector. The thread now measures the Thread.Composer slot ([data-thread-composer]) it already renders, instead of querying the composer's internals for a set of dock parts. Dock your composer in the slot and the measurement is automatic:

    diff
    -<Thread dockSelector="[data-my-dock]">
    +<Thread>
       <Thread.Viewport>{turns}</Thread.Viewport>
    -  <div data-my-dock>{composer}</div>
    +  <Thread.Composer>{composer}</Thread.Composer>
     </Thread>

    This is what the documentation already described — THREAD.md and the build-a-chat guide both said the thread measured Thread.Composer, while the code measured [data-composer-context-window], [data-composer-container] and only ever used the bottom-most match as the reference edge. The old default also meant Composer.ContextWindow was never reserved for: it sits above Composer.Container, so the container won the measurement and the context strip had to fit inside the 32px content gap or content slid underneath it. Docking the whole slot fixes that, because the slot's box covers every in-flow part.

    The rule is now positional rather than configured: anything inside Thread.Composer that should not push content up must be out of the slot's flow. Thread.ScrollButton and the default portaled Composer.Panel already are, so the standard composition is unaffected. An in-flow panel (Composer.Panel anchor={false}) now counts as part of the dock and the viewport insets around it, where previously it overlapped the last turn.

Patch Changes

  • #68 7be61d3 Thanks @rpvilo! - exports now points at ./dist permanently, so the registry metadata matches the tarball. Previously exports pointed at ./src/*.ts in the repo — so the docs app could consume package source with no build step — and a prepack/postpack pair swapped it to ./dist for packing. npm builds the packument from package.json as it stands after postpack, which restored the source paths, so every published version advertised ./src/*.ts for all 11 subpaths: files the tarball does not ship.

    Consumers were never affected, because Node resolves against the package.json inside the tarball, which always carried the correct ./dist paths. But npm view @intentface/chat exports reported paths that do not exist, which reads exactly like a broken publish — and npm was warning that publishConfig.exports "will stop working in the next major version of npm", so the mechanism had an expiry date regardless.

    The swap is gone: scripts/swap-exports.mjs, the postpack hook, and publishConfig.exports are all deleted, and prepack now just runs the build. The app gets source resolution from the repo instead of from the published exports map — a paths entry in tsconfig.json and a matching Turbopack resolveAlias in next.config.ts, both mapping the 11 subpaths to packages/chat/src. Verified by building the docs app with packages/chat/dist deleted entirely.

    No API change; nothing to migrate.

  • #71 9a378b5 Thanks @rpvilo! - Point homepage and the README at https://ui.intentface.com. The previous address, intentface.dev, does not resolve — so the link on the npm page and the two documentation links inside the shipped README were dead. The documentation now also lives at the root of that host rather than under /docs, so the paths lose that prefix.

    No code change; published metadata only.

  • #63 d73bb3f Thanks @rpvilo! - Fix Composer.Textarea accumulating phantom newlines during rapid editing. The editor's padding <br> is now marked with data-padding-break and recognized structurally instead of being inferred from position, so a native edit that strands it mid-document no longer reads it back as real content. The padding also stops consuming a caret position, keeping the DOM's position space aligned with the model's length.

0.1.2

Patch Changes

  • #65 987e276 Thanks @valstu! - Add the missing .js extensions to single-quoted specifiers in dist, which made the package unloadable under SSR.

    add-dist-extensions matched only double-quoted specifiers. The vendored files under src/internal/render came from Base UI's source and use single quotes, so 21 of their relative specifiers (13 in .js, 8 in .d.ts) shipped extensionless. Bundlers resolve those, which is why the browser was fine; Node's ESM resolver requires fully-specified paths and threw Cannot find module .../internal/render/useMergedRefs on the first import. Every part goes through useRenderElement, so importing any entry point on a server failed — a Next or TanStack Start app rendered nothing server-side and silently fell back to client rendering.

    The pattern now captures the quote character and backreferences it, so both styles are rewritten and the quote is preserved.

    The build now ends with a smoke test that imports every subpath in publishConfig.exports under Node — the entire consumer-reachable surface. The failure was a resolution error the rewrite script could not see (its own pattern was the blind spot) and publint does not resolve the internal graph, so the guard tests resolution itself: against the previous rewrite, 8 of the 11 entries fail to load; the next regression breaks the build instead of a consumer's server.

0.1.1

Patch Changes

  • #58 9f74772 Thanks @rpvilo! - Fix two quadratic-backtracking regexes that could hang the browser.

    parseChipSegments is the serious one, because message text is untrusted — it arrives from the model. The chip token pattern had two independent blowups. A long run of [ with no closing bracket made the label group consume to end-of-string, fail, backtrack over every position, advance one character and repeat: 355ms at 32k characters. Worse, [a](chip:x: repeated with no ) anywhere did the same through the value group from many start positions at once: 264ms at 88k characters. Both grow quadratically, so a large message could hang a tab for seconds.

    Every character class now excludes [, so no group can consume past the next one and the work per start position is bounded by the gap to it. Safe for anything encodeChipMarkdown produces — values are encodeURIComponent-escaped, queries come from URLSearchParams, prefixes are short identifiers — and labels containing raw brackets never parsed under the previous pattern either.

    detectActivePrefix had the same shape via /\S*$/, which retries from every position when the text ends in whitespace. It runs on every keystroke, so pasting a large single-token blob froze the editor — 16 seconds for a 200k-character run. Replaced with a backwards index scan, which is linear, allocation-free, and returns identical results.

    Both are covered by regression tests asserting a time budget that the previous implementations exceeded by two orders of magnitude.

0.1.0

Minor Changes

  • #19 563a527 Thanks @rpvilo! - Initial release: headless chat UI primitives for React, unstyled and animation-free.

    Eleven entry points. Each component entry exports a namespace whose parts you compose yourself — Composer.Root, Composer.Container, Message.Root, Message.Text — rendering semantic DOM with data-* state attributes, and every part takes a render prop for swapping the underlying element:

    • /composer — rich-text input over a purpose-built contenteditable engine: inline chips, / and @ prefix-triggered command lists with fuzzy scoring, attachments, and the ask-user questionnaire flow. One store per <Composer.Root>, or bring your own with Composer.createStore() to drive it from outside the tree.
    • /thread — scroll container with at-bottom detection, auto-follow, prepend-aware restoration, and dock/overlay inset measurement.
    • /message — message parts, turns, chip-segmented text, sources, actions, and selection.
    • /steps, /reasoning — tool-call timelines and reasoning disclosure.
    • /chip, /attachments, /ask-user — the remaining building blocks.
    • /types — the structural message contract plus part type guards.
    • /message-utils — part segmentation, turn grouping, and reasoning/source derivation.
    • /chip-markdown — the self-describing chip wire format.

    Works with React Server Components. The package ships one module per part, so a server component can render a static transcript directly — Message.Root, Message.Text, Chip.Root and friends resolve across the client boundary rather than coming back undefined. The parts remain client components, so hooks and event handlers behave as usual; reach for "use client" where you need them.

    The types are framework-agnostic: an AI SDK UIMessage satisfies ChatMessage structurally, so SDK messages pass straight into the utilities and components with no adapter. Beyond the React 19 peer, the package pulls in only @floating-ui/dom (anchored positioning) and nanoid (attachment ids) — no editor framework and no animation library.

    Styling is left entirely to the consumer; copy-paste styled sources and live previews live at https://intentface.dev/docs.