Changelog
Every release of @intentface/chat, newest first.
@intentface/chat
0.5.2
Patch Changes
- #80
07671d3Thanks @rpvilo! - Documentation only, no runtime change. Every part ofAttachments,Chip,Message,ReasoningandStepsnow 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
b49fb7aThanks @rpvilo! -Threadkeeps 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
b50a489Thanks @jonijuup! - Escape now reaches only the thing it was meant for, found by putting aComposerinside a floatingTabs.Popup.Tabs: an Escape something else handled no longer closes the panel.Tabs.Root'sdismissOnEscapeclosed the open panel on every Escape, even one another handler had already used and marked withpreventDefault(). 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. WhileisGenerating,Composer.Submit(anduseComposerSubmit) 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 fromComposer.Root'sonKeyDown, so it hears only Escapes from inside that composer, including parts a positionedComposer.Panelrenders elsewhere on the page. A consumer'sonKeyDowncan skip it withevent.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
a7b9655Thanks @jonijuup! - Four additions toNavandTabs, 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 newaria-currentattribute, a disabled-trigger fix, and one rename (last below).Nav: a branch that is also a page. The newNav.Togglepart is a caret for insideNav.Trigger. Mounting it makes pressing the row (click, Enter or Space) activate it likeNav.Item, so itsonClickor link routes, while the caret opens and closes the group. The caret is a real button that carriesaria-expandedfor the group, so name it witharia-label; likeNav.Actionit 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, onNav.Itemtoo.A disabled
Nav.Triggernow also prevents the click's default action, as a disabledNav.Itemalways has — so one rendered as a link no longer navigates while disabled.Nav:aria-current="page"on active rows.activeonNav.ItemandNav.Triggerpreviously produced onlydata-active, which assistive tech cannot see. Your ownaria-currentstill wins.Tabs:openOnHover.Tabs.TriggergainsopenOnHover,openDelay(50ms) andcloseDelay(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.onValueChangeandonItemsChangereceive a second argument,eventDetails, withreason,event,trigger,cancel()andisCanceled. Cancelling stops the change landing, so an editor can cancel a close inonItemsChangeand 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.Popupnow hands focus back to its tab, instead of dropping it on the page as the popup closes.Breaking:
event.preventBaseUIHandler()is nowevent.preventPrimitiveHandler(), andevent.baseUIHandlerPreventedis nowevent.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
5872ad5Thanks @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) andTabs(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'sonResize) and comes back in asdefault*props or, for the sidebar's width, as the--shell-sidebar-widthcustom property. Where it is kept, and which keystroke toggles what, stay the app's to decide.Also fixes the shared transition internals:
useAnimationsFinishednow runs its callback when there is no element rather than returning silently, which previously left a closingComposer.Panelor popover mounted for good, and gained asubtreeoption.useAnchorPositioningpublishes--anchor-available-widthand no longer animates a surface in from its pre-placement position.
0.2.1
Patch Changes
-
#76
fd5575bThanks @rpvilo! -Threadno 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
ResizeObserveron 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-areais derived from the thread root'sclientHeight. 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.follownow 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/clientHeightread inside the observer callback, not the one-frame-stale at-bottom snapshot thatfollowdeliberately 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
fd5575bThanks @rpvilo! -Threadno 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.useThreadInsetsderives 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
measureTopInsetandroot.clientHeightwere read — both force a synchronous layout. That layout ran with the new padding against the old reserve, soscrollHeightdipped for exactly one frame. The browser clampsscrollTopto 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-areaclimbs 504px → 566px over 23 frames whilescrollTop, the scroll maximum, and the last turn's on-screen position all hold still. PreviouslyscrollTopdropped 5351 → 5345 on the third frame and stayed there.
0.2.0
Minor Changes
-
#69
0fb9b14Thanks @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 documentedheight: var(…)pattern had its content clipped at the open-time height.Breaking: the measured height is published as
--panel-heightinstead of--collapsible-panel-height. The old name leaked an internal:Collapsibleis not a public part, cannot be imported, and appears nowhere in the docs, so a consumer stylingSteps.Panelhad 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
autowork while open. That is the intended contract, not a gap.Two supporting changes, both internal:
useTransitionStatusis now called withenableIdleStateanddeferEndingStateenabled.idleis the settled-open status the release gates on, and deferringendingby 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 theendingframe, 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 callgetAnimations()before the closed styles landed, find nothing running, and cut the exit animation off. This also affectsComposer.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
414d132Thanks @rpvilo! - Breaking:Thread.Rootno longer takesdockSelector. The thread now measures theThread.Composerslot ([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.mdand the build-a-chat guide both said the thread measuredThread.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 meantComposer.ContextWindowwas never reserved for: it sits aboveComposer.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.Composerthat should not push content up must be out of the slot's flow.Thread.ScrollButtonand the default portaledComposer.Panelalready 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
7be61d3Thanks @rpvilo! -exportsnow points at./distpermanently, so the registry metadata matches the tarball. Previouslyexportspointed at./src/*.tsin the repo — so the docs app could consume package source with no build step — and aprepack/postpackpair swapped it to./distfor packing. npm builds the packument frompackage.jsonas it stands afterpostpack, which restored the source paths, so every published version advertised./src/*.tsfor all 11 subpaths: files the tarball does not ship.Consumers were never affected, because Node resolves against the
package.jsoninside the tarball, which always carried the correct./distpaths. Butnpm view @intentface/chat exportsreported paths that do not exist, which reads exactly like a broken publish — and npm was warning thatpublishConfig.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, thepostpackhook, andpublishConfig.exportsare all deleted, andprepacknow just runs the build. The app gets source resolution from the repo instead of from the published exports map — apathsentry intsconfig.jsonand a matching TurbopackresolveAliasinnext.config.ts, both mapping the 11 subpaths topackages/chat/src. Verified by building the docs app withpackages/chat/distdeleted entirely.No API change; nothing to migrate.
-
#71
9a378b5Thanks @rpvilo! - Pointhomepageand the README athttps://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
d73bb3fThanks @rpvilo! - FixComposer.Textareaaccumulating phantom newlines during rapid editing. The editor's padding<br>is now marked withdata-padding-breakand 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
987e276Thanks @valstu! - Add the missing.jsextensions to single-quoted specifiers indist, which made the package unloadable under SSR.add-dist-extensionsmatched only double-quoted specifiers. The vendored files undersrc/internal/rendercame 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 threwCannot find module .../internal/render/useMergedRefson the first import. Every part goes throughuseRenderElement, 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.exportsunder 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
9f74772Thanks @rpvilo! - Fix two quadratic-backtracking regexes that could hang the browser.parseChipSegmentsis 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 anythingencodeChipMarkdownproduces — values areencodeURIComponent-escaped, queries come fromURLSearchParams, prefixes are short identifiers — and labels containing raw brackets never parsed under the previous pattern either.detectActivePrefixhad 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
563a527Thanks @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 withdata-*state attributes, and every part takes arenderprop 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 withComposer.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.Rootand friends resolve across the client boundary rather than coming backundefined. 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
UIMessagesatisfiesChatMessagestructurally, 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) andnanoid(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.