Skip to content

Version 0.20.0 Latest

Every @aparte/* package ships at this version (they are released in lockstep).

Minor Changes

  • 9a0a1d1: New setupArtifacts({ open: true }) puts an Open button on the artifact card, which dispatches aparte-artifact-open with { artifact } so the app can show it in a pane, a dialog or a tab of its own.

    Off by default: the card cannot know where the app’s pane is, and a button nothing answers would be a promise the library cannot keep. The event bubbles out of shadow roots and is typed (ArtifactOpenEventDetail) wherever the plugin is imported. Its label is the new locale key openArtifact (“Open”, “Ouvrir”). @aparte/core, @aparte/plugin-artifacts, @aparte/locale-fr

  • 83ba2eb: New --aparte-message-content-padding-assistant token pads the assistant’s box, 0 by default; a skin giving the assistant a bubble sets it instead of writing a selector for the padding.

    --aparte-message-content-padding pads the user’s box only. The new rule weighs nothing, so a padding you already give .aparte-message-content still reaches the assistant’s box. The Theme guide’s “Give the assistant a bubble too” recipe now sets the two tokens and keeps one rule, for width: fit-content. @aparte/core

  • b04aeba: A browser tool that a server loop or an AG-UI agent calls now goes through the page’s approval gate before it runs: its needsApproval asks through the approval panel (or your approvalResolver), and the approval policy can allow or refuse it. A refusal goes back as the call’s answer, which the model reads.

    Until now the page ran such a tool as soon as the server or the agent called it: the gate it gets in the page’s own loop was skipped, so a needsApproval browser tool ran unasked and a plan-mode policy did not stop it. A tool with no needsApproval under no policy still runs at once.

    While the page asks, the call’s row says it is waiting and aparte-tool-approval-request fires, as for a call gated in the page’s own loop. A payload the approval carries is merged into the tool’s input. A refusal ends the pause’s later calls, as it ends the turn’s in the page: nobody is asked about them, none of them runs, and each is answered “Not run: an earlier call of the same step was refused.”, so the server does not pause on them again and an agent’s model gets an answer for every call. A call approved at a pause — TanStack AI asks to approve a client tool, then pauses again to run it — is not asked about again at the next pause; that approval is for that call only, not for a later one that reuses its id.

    @aparte/engine: a deferred call answered with { kind: 'decision', approved: false } is refused like a gated call — tool-rejected, the refusal as its tool message, and another turn so the model reads it — whether it is external (it used to be deferred again) or has no gate of its own (it used to run). @aparte/runner-ag-ui sends that refusal to the agent as the frontend tool’s result, and for a client-tool interrupt (TanStack AI) as the tool’s error with the reason — an execution interrupt answered { approved: false } read as a successful output. @aparte/core, @aparte/engine

  • cb6ac6d: New aparte-chat--compact class: the chat’s narrow spacing (messages, avatar, transcript, composer row) at any width, for a widget or a panel wider than 520px. Put it on <aparte-chat>, or pass it as the chat component’s className / class. @aparte/core

  • 8084683: A compaction is now saved as soon as it is done: the conversation controller listens for aparte-compact-done and saves the chat it names — and, when the transcript lives in a framework store, it waits for the store’s list to reach the chat before saving.

    Until now nothing told the controller: the summary and the dropped turns reached storage at the next message, so a reload in between brought the long transcript back.

    A binding you write for AparteConversationController can declare the new optional afterRender(cb). The controller then saves a compaction only once the summary is in getMessages(), checking again after each render, up to six times. The framework components already declare it. Without it the save runs on the next microtask. @aparte/core

  • d4c1d8a: <aparte-context> takes a used attribute (and property): the tokens in use for a conversation restored without a turn of this page, so the gauge shows its level before the next turn; that turn’s usage replaces it. @aparte/core

  • e4fbecb: A conversation search that matches nothing now says so: the list shows a status line (new locale key conversationSearchEmpty, “No conversation matches”) and carries data-no-match, and sidebar.filter(query) returns how many conversations match.

    The filter moved into <aparte-conversation-list> as filter(query), so the query holds through the list’s renders — a conversation added while a search was typed used to appear unfiltered. The sidebar’s search field and filter() call it on each list inside. @aparte/core, @aparte/locale-fr

  • 280e623: New @aparte/core/headless subpath: the entry without elements (client, chat host, transports, conversations, config, parsers, types), by name. Import it in a Web Worker started inside a page build — new Worker(new URL('./w.js', import.meta.url)) under Vite, or under webpack with its default target: 'web'.

    Those workers are resolved with the page’s conditions (browser, import), so @aparte/core hands them the entry with the elements, which fails at its first extends HTMLElement; no export condition can reach them. The subpath is the counterpart of @aparte/core/browser, which names the entry with the elements.

    The subpath covers what the worker imports from core itself: a provider or plugin in that worker imports @aparte/core by its bare name, which still resolves to the browser entry. For those, resolve the bare name to the subpath in the worker’s build only — the Elements page has the Vite recipe, which applies to vite build: under vite dev a worker’s modules come through the page’s own pipeline, so a provider in it still fails there. A plugin whose entry defines an element (approval, model selector) cannot run in a worker either way. @aparte/core

  • 83ba2eb: --aparte-message-content-text-user and --aparte-message-content-text-assistant now colour the message’s words; without them the words keep --aparte-content-color. If you set either token, check that colour: it used to stop at the box and never reach the text.

    The text and the segments inside a message each paint --aparte-content-color, so an ink set on the box around them reached no word — a skin with white words on a blue bubble had to scope --aparte-content-color on the message instead (that still works). Both tokens are now undeclared by default; the message hands its role’s token to the text and the segments as --aparte-message-ink, which they read with var(--aparte-content-color) as fallback. So a content colour set anywhere, one message included, reaches the words while no role token is set, and a rule of your own on .aparte-content keeps the weight it had and still wins. @aparte/core

  • f8e91ab: A message whose bubble has a name is now named after it for screen readers (“Message from Camille”); without one it is still “Your message” or “Assistant response”. The new locale key is messageFrom, with {name}.

    The accessible name followed the role alone, so every message on the assistant’s side — a human agent answering there included — was “Assistant response” while the page showed that person’s name above it. A custom shell whose root carries its own role keeps its own label. @aparte/core, @aparte/locale-fr

  • c1a2816: New details attribute on <aparte-model-selector>: each model shows its description and its contextWindow (“Long documents · 128K context”) on a line under its name; the trigger keeps the name alone.

    <aparte-option> takes that line as an .aparte-option__meta child, which the select’s trigger and its width leave out and its filter searches. The context window’s wording is the new locale key modelContextWindow (“{n} context”, “{n} de contexte”). @aparte/core, @aparte/plugin-model-selector, @aparte/locale-fr

  • a310139: A pasted image now attaches only on a composer that offers attachments (one with an <aparte-composer-add-attachment>, which attachments on <aparte-chat> renders). On a composer without it, pasting an image adds nothing.

    It used to attach on any composer: a chat that offered no attachments — no paperclip, no drop target — still collected a pasted screenshot and sent it with the message. Paste now follows the same signal drag and drop answers to. @aparte/core

  • 395583d: An open question or approval panel can now be put aside, to read the transcript behind it: a chevron in its corner leaves a one-line “Waiting for your answer” bar that brings it back. The request stays open meanwhile, and the composer’s button cannot send it until it is brought back: what is out of sight is not reviewed.

    The panel takes the composer over and caps at half the viewport, so the reply it asks about could sit right behind it with no way to read it. Both kinds get the button, first in the corner, before a question’s Skip. Two locale keys are new: elicitationHide (“Hide for now”) and elicitationWaiting (“Waiting for your answer”), translated in @aparte/locale-fr. --aparte-elic-dismiss-room defaults to 104px (was 72px) to make room for the button; widen it as before for a locale whose “Skip” is wider. @aparte/core, @aparte/locale-fr

  • c1e4f93: New registerIcons({ name: () => markup }) adds icons to the set without replacing it, and a name core does not draw now works everywhere an icon name does: an action’s icon, <aparte-composer-action icon>, <aparte-icon name>.

    TypeScript: if you merged an icon name into AparteIconProvider, getIcon on that name now returns string | undefined instead of string — add ?? '' (or !) where you need a string.

    setIconProvider replaces the whole set, so a plugin’s icons and the app’s could not both be registered; and the provider the components read listed core’s names only, so registerAction({ icon: 'share' }) drew nothing even when the app’s provider had a share. getIcon(name) is typed by overload: one of core’s names returns a string, any other a string | undefined — a name merged into AparteIconProvider (declare module '@aparte/core') included, since only the configurations it was registered on draw it. The merge makes the name one setIconProvider takes and AparteIconName lists; the Icons guide shows it. getIconProvider() keeps core’s names required and a merged one optional (new AparteCoreIconName type), and a name is looked up among the set’s own keys, so icon="constructor" draws nothing instead of a function. @aparte/core

  • b77d011: While <aparte-scroll-rail> is a child of the chat, the chat leaves it a lane at its end edge (new --aparte-scroll-rail-room) and the rail sits in it, so no line runs under the ticks; set it to 0px to let the rail float over the text as before.

    The rail floated over the transcript’s end edge without reserving room, so in a column that reaches that edge (a narrow chat, --aparte-message-max-width: none) the long lines ran under it. The lane is the rail’s column plus its inset, as end padding on the chat, so the whole column moves together: messages, composer, the welcome, the loading skeleton and the scrollbar, which now sits before the rail. It is reserved from the start rather than when the rail has two ticks, so nothing moves mid-conversation. The padding weighs nothing, so a padding of your own on the chat still wins. @aparte/core

  • 09ffa64: A segment type of your own now joins AparteSegment by declaration merging, so addSegment, appendMessage and AparteMessage.segments take it without as unknown as AparteSegment: add it to the new AparteSegmentTypes interface, one key per type.

    ts
    declare module "@aparte/core" {
      interface AparteSegmentTypes {
        chart: ChartSegment;
      }
    }

    AparteSegment was the closed union of core’s built-ins, and the segments guide showed the cast. @aparte/plugin-artifacts registers its artifact segment the same way, so artifactSegment(…) is an AparteSegment wherever the plugin is imported. @aparte/core

  • 14d95ee: New aparte-tabs__panel--flush modifier: a tab panel holding a code view, a preview frame or a table drops its inset and runs to its edges. @aparte/core

  • e8751d4: New registerToolInputFormatter(toolName, (input) => text): the approval panel and the default tool row show a tool’s arguments as that text instead of the JSON the model sent, the same text on both.

    Both read it from the chat’s configuration, so a chat with its own (attachConfig, a wrapper’s config prop) takes the formatter registered there. Without a formatter both still show pretty-printed JSON. The text is written as text, never markup, and a formatter that throws falls back to the JSON. getToolInputFormatter and unregisterToolInputFormatter go with it; reset() clears them. @aparte/core

  • 8047d37: New --aparte-top-inset, the counterpart of --aparte-bottom-inset: set it on the chat to the height of a header floating over the transcript, and the first message starts below it, a sent question is pinned below it, and a jump to a message (the scroll rail, scrollIntoView) lands below it.

    The scroll surface reads it as top padding and scroll padding; under a framework wrapper, where the viewport itself scrolls, as a spacer before the first message. The scroll rail’s landing check now measures from below the scroll padding, so it no longer reads the inset as a miss and scrolls again. The Layout guide shows the recipe; a header goes outside the chat, since a child of an overlay-composer chat floats at the bottom with the composer. @aparte/core

  • b521b41: The model list (fetchModels, and so the model selector) now holds only the models you registered with registerModel; a model that is merely in the browser cache is no longer offered as a chat model.

    The Cache API belongs to the origin, and the list merged everything it held: an app’s own embedder, or another library’s model, showed in the picker as a chat model with ['streaming']. To list a model you downloaded earlier, register it. listCachedModels() still reports everything downloaded, for a settings panel. @aparte/provider-transformers

  • b59ed84: Replies are no longer cut at 512 tokens: the default ceiling is 8192, and TransformersModelConfig.maxTokens sets one per model. A request’s own maxTokens still wins.

    With the built-in runners, whatever the ceiling, a reply also ends where the model’s context window ends, prompt included — read from the model’s config (max_position_embeddings, on the text half for a vision model) — so a ceiling larger than the window has room for no longer runs the model past its context, and Stop interrupts a reply either way. A runner of your own receives options.maxTokens only when the request or the model’s setting gives one, so it applies its own default, and is its own judge of the window; the README’s example does both, and now wires Stop.

    Why a ceiling at all, when the window already bounds a reply: decoding is greedy by default, and a small model stuck repeating itself would run to the end of a 32,768- or 131,072-token window. In the browser the attention cache grows with every token, so that ends in a GPU out-of-memory error rather than a stop. 8192 tokens is some 6,000 words.

    If you relied on the old ceiling, for instance to keep a small model brief, set maxTokens: 512 on that model. @aparte/provider-transformers

  • 6de5a41: The framework components’ typing line now defaults to the locale’s typing word (“Typing”, « Écrit… » in French) instead of the English ‘Assistant is thinking…’; typingText still overrides it.

    It was the one visible string a localised app had to translate by passing a prop, and the only one in the chat that ignored the locale. If you relied on the old English sentence, pass typingText="Assistant is thinking...". @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • f4587b3: The conversation-manager helpers of the four wrappers take the manager’s options and expose the manager: init(adapter, config, { retention, titleProvider }), and manager once init has run.

    useConversationManager (React, Vue), createConversationManager (Svelte) and ConversationManagerService (Angular) built the manager with no options and kept it private, so retention or a title provider meant building the manager yourself and registering it with setConversationManager, and what the helper does not mirror (setTitleProvider, ensureFull) was out of reach. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

Patch Changes

  • 439719e: The app shell now folds to one column when <aparte-sidebar> becomes a drawer, at the sidebar’s own breakpoint, instead of at a fixed 48rem; a sidebar with breakpoint="30rem" stays a column between 30 and 48rem.

    The shell hid every sidebar that was not a drawer under 48rem, so a breakpoint under 48rem left nothing in that band (no column, no drawer, a toggle that opened nothing), and one over 48rem hid the header’s toggle while its drawer was open. The toggle now shows while the shell holds a drawer, at any width. Under 48rem the class-only recipe and a sidebar that has not upgraded yet still fold out of the flow, as before, except a breakpoint="none" one, which never turns drawer and now keeps its column from the first paint. The new rules weigh what the ones they override do, so a rule of your own still wins. @aparte/core

  • e351c04: In a short window, the approval panel now scrolls its body instead of squeezing the tool’s arguments: the arguments keep their height.

    The panel is capped at half the viewport and does not scroll itself, and its parts were its direct flex children, so the arguments — the one block that could shrink — gave way first: 48px of a 66px block in a 560px-tall window, the thing being approved disappearing before anything else. Everything but the corner now sits in an .aparte-elic-body that scrolls, as the question panel’s does. @aparte/core

  • b62ec12: --aparte-avatar-image-user and --aparte-avatar-image-assistant now show their picture with no avatar provider: set the token on :root, the chat or aparte-chat-bubble and the slot appears for that role.

    An empty slot is hidden, so the token painted nothing unless a provider wrote something in the slot (the guide’s host.innerHTML = '<span></span>' workaround, no longer needed). The bubble now marks an empty slot whose role’s token names an image with data-image, read when the message is drawn and on every configuration change, and the hiding rule skips it. It also shows in a slot a provider leaves empty, for the role it does not draw. @aparte/core

  • eccfd79: A configuration change (a language switch, a theme) on a long transcript no longer stalls the page: on 200 messages it took ~125ms in Chromium, now ~15ms.

    Every message read its role’s avatar token on each change to decide whether to show the picture. It read it inside the message, which a message off screen does not style (content-visibility: auto), so each read paid a style pass of its own, and each mark it set dirtied the style the next read needed. The token is now read on the message element, and every message reads before any marks. @aparte/core

  • b62ec12: setAvatarProvider(null) now takes the avatars off the messages already on screen, not only off the ones drawn after.

    The bubble returned before touching the slot when no provider was registered, so what the old provider drew stayed. A custom shell’s own avatar markup is kept: only what a provider drew goes. @aparte/core

  • ef70a91: A custom bubble shell keeps the accessible names it wrote itself — its action bar’s and its branch arrows’ — through a language switch or any config change; the bubble refreshes only the names it wrote.

    Every config change used to rewrite the action bar’s and the branch arrows’ aria-label with the locale’s, a shell’s own labels included, so a shell that named them in its own words lost those names at the first language switch, model pick or late provider. @aparte/core

  • 0852861: center-empty (and centerWhenEmpty in the framework packages) no longer slides the composer down for a conversation a script restores before anyone has touched the page: the restore lands in one frame. A send, an opened conversation and a new chat still glide.

    The slide ran on any change out of the empty state, so a page restoring the last conversation right after load painted the welcome and slid it away under a reader who had done nothing, a layout shift (CLS 0.14 on a phone). The chat now sets data-glide at the first key or pointer in the page, or the first send, and the transition is keyed on it; the framework hosts set it on the wrapper’s root. @aparte/core

  • 9fc5087: A reply that ends while the transcript is re-laid out no longer leaves the view stopped short of the bottom with a scroll-to-bottom button over a reader who never scrolled (seen in Safari/WebKit, mostly under load).

    If your page jumps the transcript to an earlier message itself while a reply streams, call the viewport’s setAutoScroll(false) before the jump: the follow then stays off through the jump, smooth or not, and comes back when the reader returns to the bottom. The scroll rail does this for its own jumps. A find-in-page jump has no such hook, and may be pulled back to the bottom while a reply streams.

    When the transcript got shorter between two scroll events, the browser moved the scroll position down to the new end, and the viewport took that for the reader scrolling up: it stopped following, and the content that grew back stayed below the fold. A shorter transcript is now always the layout’s doing, never the reader’s; a jump that does not shorten it (a host’s scrollTo) still counts as the reader’s. A jump into history that was never drawn does shorten it, as its messages take their real height — hence the call above. @aparte/core

  • d7eea7b: The compaction notice is drawn on the muted ground, in muted small type, instead of as the user’s bubble.

    Its colours were properties on the box, at a weight the role’s rule beat, and its size never reached the text, which sets its own. The notice now sets the tokens the message reads (--aparte-message-content-bg-*, --aparte-message-content-text-*, --aparte-content-font-size) on aparte-chat-bubble[data-kind="compaction"], where a skin can set them too. @aparte/core

  • 0b3e123: Documentation only: the API comments (what your editor shows on hover, and the generated reference pages) now say what each export does today. No code changed.

    About a hundred and thirty doc comments told how the code got to where it is: an earlier behaviour, an audit, an issue number. They now describe the current behaviour for the caller. A reason a maintainer still needs stays beside the code as a plain comment. @aparte/core

  • 95aa8b4: The “send dropped: no model is selected” warning now names <aparte-model-selector auto-select persist>; auto-select alone writes nothing to the config, so following the old advice still dropped the send.

    The getModels() JSDoc also says what the model selector does now: a provider without fetchModels shows its getModels() list there. @aparte/core

  • 2eb1ae3: A custom bubble shell’s .aparte-action-bar now becomes a toolbar named from the locale (messageActions) when it declares no role of its own, as the default bubble’s does, and its unnamed branch arrows are named from the locale (previousResponse, nextResponse).

    The bar already got the roving tab stop, arrow keys between its buttons, but nothing told a screen reader it was a toolbar or what it was for: the shell contract lists class hooks, so a shell written from it carried no ARIA. A bar with its own role keeps it.

    An arrow counts as named, and is left alone, when it has an aria-label, aria-labelledby or title, or words in the button a reader can hear; a glyph like ‹ is not a name, nor an icon font’s ligature or an SVG title under aria-hidden. A label stamped over a visible “Précédent” made a screen reader say “Previous response”, and a voice command saying the visible word found nothing. To have your arrows and bar follow the locale, leave them unnamed. @aparte/core

  • 3352e6a: Documentation only, no behaviour change: what persist, the backend transport, the model-preference example and the Transformers.js cache budget actually do.

    • <aparte-model-selector persist>: it writes the pick into the config, which is what the next message is sent with. Without it the picker only fires aparte-model-change. It does not outlive a reload on its own; that takes setModelPreferenceProvider() and restoreModelPreference().
    • setModelPreferenceProvider: the example saved {p, m}, which restore cannot read. It now saves { provider, model } and calls restoreModelPreference().
    • AparteBackendTransport: the page still registers a provider under each id the route knows. A stub that lists the models is enough for an id with no client package; the JSDoc shows one.
    • setMaxCachedModels: the budget is how many models stay downloaded (disk), not how many are in memory.
    • Runners: tool_use is in what a runner can emit, not in what it is given. RunnerGenerateInput carries no tool definitions. @aparte/core, @aparte/provider-transformers, @aparte/plugin-model-selector
  • 83b4fb4: A name, a file name, a conversation title or a tool name containing $&, $', $` or $$ now shows as written in the labels and questions that quote it (“Remove {name}”, “Delete {title}?”, “Run {tool}?”, a message’s author).

    Those labels were filled with String.replace, which reads $ sequences in the text it inserts: a file called price$&.csv was announced as “Remove price{name}.csv”. @aparte/core

  • ff458c1: Importing @aparte/core (or the approval, artifacts, ask-user or model-selector plugin) from a Cloudflare Worker, a Vercel edge route or a Web Worker that webpack compiles with target: 'webworker' now resolves the DOM-free entry instead of the browser one, which failed at import on its first extends HTMLElement.

    The DOM-free entry was reachable through the node condition only, and those bundlers resolve workerd, edge-light or worker alongside browser and import. The exports maps now list worker, workerd, edge-light, deno and bun next to node, pointing at the same entry; a browser still gets the elements. A worker started inside a page build is not covered — new Worker(new URL('./w.js', import.meta.url)) under Vite, or under webpack with its default target: 'web': both resolve it with the browser’s conditions, so it gets the browser entry. For core, import the new @aparte/core/headless there; the plugins have no such subpath, and how to run a provider or a plugin’s tool in that worker is on the Elements page. check:node-import walks each published map with the conditions of webpack’s worker target, wrangler and a Vercel edge route. @aparte/core, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-model-selector

  • 95c7fdc: In a right-to-left page, a panel’s question no longer runs under its corner buttons: the room kept for them is now on the inline end, where they sit.

    The message and the tab rail reserved --aparte-elic-dismiss-room with a physical right margin while the corner sits at inset-inline-end, so in RTL the room was on the wrong side, and a question ran under its Skip. @aparte/core

  • 75068ee: Scrolling the transcript up by script (scrollTop = 0, scrollIntoView) right after a send is no longer undone: the view stays where it was moved instead of snapping back to the bottom.

    A send glides to the bottom and arms a timer that pins the view if the glide stopped short. When the timer fired in the same instant as a jump up, before the scroll event had been read, it took the jump for a short glide and pinned it back. It now leaves any move up to the scroll handler, which tells a reader’s or a host’s jump from the layout’s. @aparte/core

  • 1da5e87: A Markdown, streaming-Markdown or highlight provider set after a message was drawn now redraws that message; you no longer have to set them before the chat renders.

    The three setters did not notify, so a message drawn first kept its raw text for good: a static <aparte-chat-bubble content="**x**"> upgraded by core’s own import before setupMarkedProvider() ran, or a restored conversation drawn while a highlighter was still loading (setupShikiProviderFromHighlighter(await createHighlighterCore(…))). They now notify like the other renderers, and a message drawn with older providers is drawn again — in a microtask, once for several providers set one after another, or when it finishes streaming. A message already drawn with the current providers is not touched by other config changes. @aparte/core

  • 1c152c7: .aparte-mark now tints a menu row (.aparte-menu__item), and a marked row in a checkable menu keeps its check gutter, so its label stays aligned with the others.

    The row’s transparent ground, at the same weight in a later sheet, erased the tint. And the mark’s start-edge bar and the menu’s check share ::before: the gutter left the flow and the marked label moved 16px to the start edge. In a checkable menu the check is the row’s signal, so the bar is not drawn there. @aparte/core

  • d252f33: The warning registerAllComponents() prints when a test runner resolved core’s Node entry under a DOM now gives the alias the docs give, matching the bare specifier only: test.alias [{ find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }].

    It suggested { '@aparte/core': '@aparte/core/browser' }, a string alias, which is a prefix: it also rewrote @aparte/core/icons to @aparte/core/browser/icons, a path that does not exist. @aparte/core

  • 9a9f7c9: A chat’s own bubble frame (setBubbleShellRenderer on its config, or a framework’s config prop) now reaches the messages drawn before that config was attached; they are drawn again in it, content kept.

    A bubble drew its frame once, at connect, and under every wrapper the chat’s config is attached after the first render — so the messages there at first render kept the page-wide frame while every later one wore the chat’s. The bubble now redraws when the config above it changes and brings another frame — in a microtask, so a chat unmounting does not redraw its messages first, and after an open edit closes, not under it. @aparte/core

  • 99c28fa: A custom elicitation presenter no longer has to honour request.signal itself: a stop now rejects the request with an AparteElicitationAbortError whatever the presenter does. Listen to the signal only to take your own UI down.

    The config checked the signal only before calling the presenter, so one that never listened to it left the stopped turn waiting on a panel nobody would answer. @aparte/core

  • 7f52f79: setSegmentDefaults('thinking', { collapsed: false }) now opens the reasoning a provider streams on its own channel too, and keeps it open when the answer starts; without the default it still folds there.

    The stream adapter created that block with collapsed: true and folded it again at the first answer token, and a field the producer sets beats a default — so the default only ever reached a <think> block written in the text. @aparte/core

  • 39e0b6c: An approval the page gave to a paused call no longer approves a later call that reuses its id: a resumed run asks about every new call again.

    deferral.outcomes used to answer every call of the resumed run by id, not only the calls the run resumed. A vendor that sends no tool-call ids has them minted per stream (aparte-call-1, aparte-call-2, …), so the model’s next turn could carry an id the resume had been answered for. An approval given for the paused delete_file then ran a new delete_file with other arguments, with nobody asked, and a refusal refused a new call that nobody had refused. This reached a server loop (createAparteAgentHandler) and any host that drives runStreamAgent with deferral. The page applies the same rule to a browser tool a paused run asks it to run: an approval given at a pause answers the call it was given for, at the next pause, and not a later call that reuses its id. @aparte/core, @aparte/engine

  • b2b4fe9: Under the React, Vue, Svelte and Angular components, a message is now saved with every field written on it — metadata, attachments, the model a reply ran on — and comes back with them when the conversation is opened again. A turn paused on a server and resumed after a reload now goes back to the model it ran on, not to the default one.

    The saved conversation tree is built from the viewport’s own copy of the messages. The step that brought that copy up to date carried only the text, the segments, the status and the usage. Anything else (host.updateMessage(id, { metadata }), the model the client stamps on a reply) was saved in the flat list and missing from the tree, and a reopen loads the tree. A field the app clears — set to undefined, or left out of the message — now leaves the tree too, the model and the usage included; the text, the segments and the status, which every message carries, stay. The vanilla <aparte-chat> was not affected. @aparte/core

  • 04369ef: <aparte-select searchable> set or removed after the select is drawn now adds or removes the filter field, and moves the combobox role with it.

    It was read on the first render only, so a framework that binds it a beat late — Angular’s [searchable] inside an @if — got a select with no filter. @aparte/core

  • 2528971: A sent question is pinned under the top edge (or under --aparte-top-inset) every time, not now and then a third of the way down the view.

    The bottom spacer measures the last question and its reply to make room for the pin. A message that has never been on screen is laid out at a 320px estimate (content-visibility: auto) until it is shown, and the spacer was sized from those estimates; when the messages came into view at their real height, nothing re-measured it. The viewport now watches the height of the messages it measures. @aparte/core

  • a3413dd: The buttons that switch a stacked <aparte-split> (data-aparte-split-pane="start" / "end") now carry aria-pressed, true on the one for the pane in view, so a screen reader hears which pane is shown.

    A toggle (an id, or an empty value) is an action and gets no pressed state. Side by side, both panes are in view and neither button carries it. The split stamps the controls when it connects, on every pane change and when it stacks or unstacks. @aparte/core

  • 7a25388: A custom status renderer (setStatusRenderer) is now called again when <aparte-chat-status>’s text changes, so the line shows the new text.

    It ran only when the element first drew, and a text change wrote into the default text element, which a custom renderer does not have: the accessible name moved while the visible line kept the first text. @aparte/core

  • e51d8c4: The columns of a Markdown table keep the alignment the Markdown gave them (---: right, :---: centred).

    The renderer emits <td align="right">, which a browser reads as a text-align hint of the lowest weight, and the prose sheet set text-align: start on every cell, so the hint always lost. That rule now applies only to cells without align. @aparte/core

  • c093743: A code block inside a text segment you build yourself (setSegments, a restored or a server’s message) is now highlighted, like one in a streamed reply.

    The streaming parser turns a fence into a code segment, so the text renderer never highlighted, and the bubble’s own highlight pass skips a message that has segments — a fence in a hand-built text segment reached no highlighter at all. The text renderer now highlights its fences once the segment is settled, as the reasoning renderer already did.

    With no highlighter set, both now leave a code block as the Markdown renderer wrote it. The reasoning renderer used to put core’s bare <pre><code> in its place, which dropped the language-* class a page-level highlighter or a stylesheet looks for. @aparte/core

  • 11fd6dc: Opening a conversation and leaving it, or moving between the versions of a reply (‹1/2›), no longer moves it to “Today” in the conversation list: updatedAt changes only when a message does.

    Two comparisons counted looking as editing. The viewport hands the messages back stamped (each segment with its message id, index and isStreaming: false, a call nobody can answer any more closed) and with keys in another order, and the manager compared raw JSON — so a record the app or a server wrote without those stamps read as changed the moment it was opened. Both sides now go through the same adoption step, keys sorted. And the comparison read the active path only, so a branch switch counted as new content; with a tree on both sides it now reads every branch, and the branch shown is saved without dating the conversation. @aparte/core

  • c431e78: A reply from an AG-UI agent now reports the usage of its last model step, as a run in the page does, so the context gauge no longer overstates a multi-step run.

    The runner added every step’s usage together — TanStack AI reports one per model step — and <aparte-context> reads a reply’s usage as the size of the context, so a three-step run read as three times the context it used. @aparte/runner-ag-ui

  • b04aeba: A browser tool that a server loop or an AG-UI agent calls now goes through the page’s approval gate before it runs: its needsApproval asks through the approval panel (or your approvalResolver), and the approval policy can allow or refuse it. A refusal goes back as the call’s answer, which the model reads.

    Until now the page ran such a tool as soon as the server or the agent called it: the gate it gets in the page’s own loop was skipped, so a needsApproval browser tool ran unasked and a plan-mode policy did not stop it. A tool with no needsApproval under no policy still runs at once.

    While the page asks, the call’s row says it is waiting and aparte-tool-approval-request fires, as for a call gated in the page’s own loop. A payload the approval carries is merged into the tool’s input. A refusal ends the pause’s later calls, as it ends the turn’s in the page: nobody is asked about them, none of them runs, and each is answered “Not run: an earlier call of the same step was refused.”, so the server does not pause on them again and an agent’s model gets an answer for every call. A call approved at a pause — TanStack AI asks to approve a client tool, then pauses again to run it — is not asked about again at the next pause; that approval is for that call only, not for a later one that reuses its id.

    @aparte/engine: a deferred call answered with { kind: 'decision', approved: false } is refused like a gated call — tool-rejected, the refusal as its tool message, and another turn so the model reads it — whether it is external (it used to be deferred again) or has no gate of its own (it used to run). @aparte/runner-ag-ui sends that refusal to the agent as the frontend tool’s result, and for a client-tool interrupt (TanStack AI) as the tool’s error with the reason — an execution interrupt answered { approved: false } read as a successful output. @aparte/runner-ag-ui

  • 339a8e3: A scripted { usage } step that overrides inputTokens or outputTokens now moves totalTokens with it; set totalTokens too to pin it.

    The total was computed from the estimates before the step’s fields were merged over them, so { usage: { inputTokens: 900 } } left the estimated total in place — and the context gauge reads the total first, so overriding the input alone moved nothing on it. @aparte/provider-scenario

  • 180c862: setMaxCachedModels now counts and evicts only the models you registered; other Transformers.js weights in the browser cache are left alone.

    The Cache API belongs to the origin, so an app’s own pipelines (an embedder, a second library) store their weights next to the provider’s. The budget counted them and, past its limit, deleted them — a model the provider never loaded. @aparte/provider-transformers

  • 041a0f6: The two-models console warning no longer tells a page with no cache limit to set one.

    It said “with a cache budget of N, a switch can evict and reload gigabytes” and advised setMaxCachedModels(2) whatever N was — including 0, which is no limit, where that advice would lower it. It now says what serializing costs (each switch unloads one model and loads the other), and mentions deleted weights and setMaxCachedModels(2) only at a budget of 1, the one budget where a switch deletes the other chat’s model. @aparte/provider-transformers

  • d853df2: deleteCachedModel(id) now deletes only that model’s files; a model whose id starts with the same name keeps its weights.

    The cache was matched by substring, so deleting onnx-community/Qwen2.5-0.5B also deleted onnx-community/Qwen2.5-0.5B-Instruct, and getModelStatus reported a model as cached when only that longer sibling was. Both now compare the model id read from each cached file’s path, by equality. @aparte/provider-transformers

  • 2d32326: <aparte-approval-mode> no longer warns “found no approval setup” when you follow the documented order: the tag in the page, then setupApproval() in the script that imports the plugin.

    Importing the plugin defines the element, so a tag already in the page bound before the setupApproval() call on the next line and warned about a setup that was about to arrive. The check now waits for that script to finish; with no setup at all, it still warns once. @aparte/plugin-approval

  • 30118ab: An artifact card drawn from a tool result keeps its syntax colouring: an update that does not change the code no longer rewrites the pane as plain text.

    A card built from create_artifact’s result is settled from its first paint and still receives every update its tool segment gets. Each one set the pane’s textContent, which wiped the token spans the setup had painted, and with no stream ending there was no second highlight — a plain block in a Shiki frame. The pane is now written only when its content changes, and coloured again when it does. @aparte/plugin-artifacts

  • 5d6a6ae: Copy on an artifact card now copies the artifact as the card shows it; a streamed artifact used to copy an empty string.

    The card is set up before a streamed <artifact> sends its first chunk and is then filled by its updates. Download, Open and the preview read the latest content, but Copy read the content the card was set up with — empty, or the first chunk. The same held for a card from a tool call set up before its result arrived. @aparte/plugin-artifacts

  • 09ffa64: A segment type of your own now joins AparteSegment by declaration merging, so addSegment, appendMessage and AparteMessage.segments take it without as unknown as AparteSegment: add it to the new AparteSegmentTypes interface, one key per type.

    ts
    declare module "@aparte/core" {
      interface AparteSegmentTypes {
        chart: ChartSegment;
      }
    }

    AparteSegment was the closed union of core’s built-ins, and the segments guide showed the cast. @aparte/plugin-artifacts registers its artifact segment the same way, so artifactSegment(…) is an AparteSegment wherever the plugin is imported. @aparte/plugin-artifacts

  • 86bf359: With a global compaction setup and a chat’s own setup on one page, each chat is now summarised once, by the setup on its own configuration; an instance setup no longer answers a chat on the global configuration when a global setup exists.

    An unscoped setup on a configuration of its own answered every chat that resolved the global configuration too, so a global chat got two summaries, one from the wrong model. A chat is now answered by its own configuration’s setup, and only when that configuration has none by another.

    Two cases follow the same rule: a chat a setup is scoped to (scopeToTargetId) is answered by that setup alone, and a chat that is not on the page (a transcript in a store) is answered by the setup whose own resolveTarget knows it. A request for a chat that no setup knows is still answered by one, which reports the chat it could not find. With no setup on the global configuration, a chat on it is answered by the first setup that answers for a whole configuration, no longer by each of them. @aparte/plugin-compaction

  • 2c30b57: Without persist, <aparte-model-selector> now keeps the model the person picked when the config changes for another reason (a language, a provider, an icon); it follows only a change to the config’s own selection.

    It compared the config’s selection with the person’s pick, and a pick made without persist writes nothing to the config, so the next unrelated change — a language switch — put the picker back on the config’s default. It now compares the config’s selection with the one it last read. @aparte/plugin-model-selector

  • db2c709: <aparte-model-selector> now lists every provider you register, whenever you register it: before or after it mounts, with or without a model already selected, with fetchModels or a fixed getModels() list.

    Four cases used to leave it empty, stale or wrong until a reload:

    • A static import beside the code that registers the providers. Importing the plugin connects a tag already in the page, and the registration ran while its first load was in flight, before it listened for changes. It now listens first, and a change during a load runs the load again.
    • A provider registered after mount when a model was already selected (a key typed in the app’s settings). It now reloads when the set of providers changes, not only the selection.
    • A provider with no fetchModels. Its getModels() list is now shown.
    • The normal order under every wrapper: the element connects on the global config, then the chat’s own config is attached. A slow global /models answering after the chat’s load wrote the global models over the chat’s. A load that a mount or a new config superseded now discards what it brings back.

    The void import('@aparte/plugin-model-selector') placed after the registration, which some setups used to dodge the first case, is no longer needed. @aparte/plugin-model-selector

  • 1a37a46: onaparte-model-change={(e) => …}, the runes form of the selector’s event, now type-checks once @aparte/plugin-model-selector/svelte is imported, with the detail typed, like on:aparte-model-change. @aparte/plugin-model-selector

  • 0e44240: A light/dark theme pair now follows the nearest data-aparte-theme, not the page: a chat set light inside a dark page gets the light code theme, and a dark chat in a light page the dark one.

    Both rules of the pair’s stylesheet matched any ancestor — [data-aparte-theme="dark"] .shiki, and the system-dark one on :root — so a chat forced light kept dark code. Each attributed element now sets an inherited switch of aparté’s own name (--aparte-shiki-dark-on) for its subtree, so the nearest one decides, in every browser. It does not read color-scheme, which a host page sets too (Angular Material writes html { color-scheme: light dark }). Measured in Chromium, WebKit and Firefox, with and without a host color-scheme, the attribute on <html> or on a chat. @aparte/plugin-shiki

  • 309170b: The framework components’ typing line and their “Loading the conversation” announcement now follow a language switch (config.setLocale(...)); they used to stay in the language the chat was mounted in.

    Every other string in the chat is drawn by core’s elements, which listen to their config. These two are drawn by the wrapper itself, and none of the four re-read them on a config change: React did not subscribe, Vue’s computed and Svelte’s $: do not see the config, and Angular’s OnPush getters were not re-run. Each wrapper now subscribes to its config for as long as it is mounted. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 670537f: <AparteChat> now finishes its after-render work in a background tab too: a compaction that ends while the tab is hidden is saved at once, instead of when the reader comes back (and not at all if the tab is closed first).

    The component waited for the next animation frame, and a hidden tab runs none while React still commits there. It now uses the next task when the document is hidden. @aparte/react

  • 8ebc4f4: The runes form of an event handler, onaparte-send={(e) => …}, now type-checks on every aparté tag, with the event’s detail typed, like on:aparte-send.

    The tags were declared with on:aparte-* handlers only, so a Svelte 5 app writing the attribute form on a declared tag got “Object literal may only specify known properties” from svelte-check. @aparte/svelte

Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/plugin-marked, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/docs-mcp.