Skip to content

0.20.0

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.

0.21.0

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

Minor Changes

  • 08760a6: <aparte-chat> now paints its own background (--aparte-bg) and text colour (--aparte-text), so a light or dark chat stays readable on any page. To let your page show through as before, set --aparte-bg to your page’s colour, or write aparte-chat, [data-aparte-chat] { background: none; }.

    The palette sets a text colour and a ground together, and the chat used to paint only the text: its background was the host page’s. A dark chat on a white page wrote light text on white, and so did a chat on a light-only site seen from a dark system, where the default follows prefers-color-scheme with no attribute at all; a light chat on a dark page went the other way. Three of those four combinations were unreadable. The rule applies to <aparte-chat> and to the [data-aparte-chat] root the React, Vue and Svelte wrappers render, at zero specificity, so a rule of yours wins whichever stylesheet loads last. color is set too: what you render inside the chat — a tool card, a slot — now inherits the palette’s text colour instead of the page’s.

    A site that is light only can keep the chat light on a dark system with data-aparte-theme="light" on <html>. @aparte/core

  • 7830394: New @aparte/plugin-marked/streaming entry: setupMarkedStreamingProvider() formats a reply with marked while it streams and once it is done, so a finished reply no longer changes when it settles.

    ts
    import { setupMarkedStreamingProvider } from "@aparte/plugin-marked/streaming";
    
    setupMarkedStreamingProvider(); // in place of setupMarkedProvider() + setupStreamingMarkdownProvider()

    While a reply streams, the block being written is completed by Vercel’s remend before marked parses it: **bold shows bold before its closing ** arrives, a ** with nothing after it yet is held back instead of shown as typed, a link shows as a link from its first character and becomes clickable once its URL is complete, and an image appears once its URL is. Only that block is drawn again at each chunk; the blocks above it keep their DOM, so a selection in them survives the stream. Every block goes through the config’s sanitizer, external links to their own tab included. The options are marked’s, as for setupMarkedProvider, and apply to the streaming and the finished render alike.

    remend is a new dependency of the plugin (Apache-2.0, no dependencies of its own), imported by the /streaming entry only: the main entry’s graph is unchanged. @aparte/plugin-streaming-markdown stays the choice that only appends to the page. @aparte/plugin-marked

  • 34ca7d3: The plugin now parses with @paulrichez/streaming-markdown, which installs with it: you no longer install a parser beside the plugin, and you can remove streaming-markdown. Your code does not change.

    bash
    npm uninstall streaming-markdown

    @paulrichez/streaming-markdown is a maintained fork of Damian Tarnawski’s streaming-markdown, which has had no release since 0.2.15 (May 2025). It is now a regular dependency rather than a peer: nothing in the plugin’s API exposes it and there is nothing to configure, so there was no version for you to choose. Same API, plus four fixes that show while a reply streams:

    • A bracket inside a link’s text no longer breaks the link: [[1]](url) and [a [b] c](url) render as links before the reply settles, not only after.
    • Backticks in the middle of a line inside a code block no longer end the block early, as in function_call(```).
    • A table or a blockquote inside a list item stays in the item, and a table’s rows no longer show their indentation as text.
    • An underscore inside a word is no longer emphasis: snake_case_name and [read_me.md](url) stay as written. @aparte/plugin-streaming-markdown

Patch Changes

  • @aparte/engine@0.21.0 @aparte/core

Version-only bumps (no changes of their own): @aparte/engine, @aparte/runner-ag-ui, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.

0.22.0

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

Minor Changes

  • 5aa792c: A TanStack AI route now needs relaxedRunFraming: true: createAgUiRunner({ endpoint: '/api/chat', relaxedRunFraming: true }). Without it the runner rejects TanStack AI’s streams, as AG-UI 1.0 asks a client to.

    The runner now follows AG-UI 1.0 and passes the protocol’s own conformance corpus — the 68 streams of spec/1.0/conformance, vendored with their MIT license and replayed through it on every build. What changes:

    • It declares protocolVersion: "1.0" on every request, and warns when an agent declares a newer version or one it cannot read.
    • It rejects the streams 1.0 tells a client to reject — a run not opened by RUN_STARTED, an event after RUN_FINISHED (or after RUN_ERROR, but a new run), content for a message never opened, a message, tool call, step or sub-agent still open when the run finishes, a chunk stream that loses or changes its identity, a step or a sub-agent finished before it started, a known field missing or of the wrong type. What is open is tracked per owner — the agent, or each sub-agent — as 1.0 requires. The reply ends with an error naming the rule, below what was drawn. TanStack AI, 1.0 included, closes each model step with a RUN_FINISHED and answers a resume before its RUN_STARTED: relaxedRunFraming reads that framing and holds every other rule — what was opened must still be closed, by the end of the stream.
    • It drops what 1.0 does not describe — an unknown event type, an undescribed property, an unknown role or outcome — and reports it to the new onWarning option (console.warn by default).
    • It keeps the agent’s state. STATE_SNAPSHOT and STATE_DELTA (a JSON Patch, applied whole or not at all) are kept for the thread and sent back as the next run’s state; every request used to send state: {}.
    • A RUN_ERROR no longer cuts the stream: it is read to the end, as 1.0 asks, and the error lands below what was drawn. When the agent runs again in the same stream, the new run decides how the reply ends and the failure goes to onWarning. The error carries data: { agUi: 'run-error' }; a rejected stream carries data: { agUi: 'malformed' }.
    • Images go to the agent as AG-UI 1.0 content parts: a base64 data URL as a data source, anything else as a url source. They used to be left out.
    • An interrupt the page cannot answer — input_required, or one naming no toolCallId — ends the reply without an error and is answered cancelled when the thread resumes or runs again. It used to fail the reply.
    • A tool result in content parts keeps the parts as the row’s structured result; the model reads their text.
    • Older agents: the new peerProtocolVersion option names an agent older than 1.0. Its stream is read by that version’s rules (the retired THINKING_* events are translated in any case; sub-agent events are dropped for 0.0.57 and older, images flattened to text for 0.0.39 and older), and the request leaves out protocolVersion. @aparte/runner-ag-ui

Patch Changes

  • @aparte/engine@0.22.0 @aparte/core

Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.

0.23.0

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

Minor Changes

  • 494cd79: The approval question reads “Allow {tool}?” (French “Autoriser {tool} ?”) and names the call as its row does — the label and what it targets — instead of the bare identifier.

    The question substituted the tool’s identifier whatever the app had declared, while the row could read “Delete a file a.ts”. {tool} is now the row’s own line: the declared label (else the identifier), then the summary’s target. The default wording changed from “Run {tool}?” to “Allow {tool}?”, which reads with a label that is an action (“Allow Write file CHANGELOG.md?”) as well as with an identifier; an app that set its own approvalAsk keeps it. The arguments block is unchanged. @aparte/core

  • 494cd79: Read an error segment’s machine code from code: it is no longer in details, and the alert no longer prints it.

    A failed turn carried its AparteErrorCode in details, which the alert draws, so the person read UNKNOWN_ERROR or MAX_TURNS_EXCEEDED under a sentence that already said what happened. The code is the segment’s code now, stamped on the alert as data-error-code and handed to a custom error renderer (setErrorRenderer receives code). details stays for text an app chooses to show. The turn limit’s code is a new member, AparteErrorCode.MAX_TURNS_EXCEEDED. @aparte/core

  • 494cd79: A hidden tool call — registerToolDisplay(…, { hidden: true }), or a renderer that draws nothing — now stays in the message and in the model’s history.

    A call whose renderer returned '' at tool-start was never added to the message, and the history a later turn sends is rebuilt from the message: from the next turn on, the model no longer saw a call it had made, nor its answer. A reload drew the default row for it instead. Every call is added now; a hidden one draws an empty hidden placeholder, live and restored, and an approval it needs is still asked. A turn of nothing but hidden calls renders no chrome.

    isToolHidden(name) says which wins: a hide, unless a renderer is registered for the tool more specifically — a '*' hide leaves a tool with its own renderer (a plugin’s receipt) drawn.

    If you hid a tool with registerToolRenderer(name, { render: () => '' }), that call is now saved and sent to the model on later turns, as every other call is. registerToolDisplay(name, { hidden: true }) says the same thing more directly. @aparte/core

  • 494cd79: A settled reasoning block reads “Thought” (thought) instead of “Thinking”; turn on showDuration to read “Thought for 8s” (thoughtFor). A locale of your own needs these two keys translated.

    It read “Thinking” while the model reasoned and “Thinking” forever after, so a finished block looked like one still running. A custom locale that translated thinking and not thought now shows the English “Thought” on settled blocks: add thought (and thoughtFor if you show durations).

    The duration stays the app’s to show: core measures it in the browser, from the first reasoning delta to the last, which is the model’s thinking time only when the provider streams its reasoning — one that sends it in one piece would read “1s”, and a reload shows it only if the stored segment kept its meta. setSegmentDefaults('thinking', { showDuration: true }) turns it on for every block, read when the block is drawn so a restored one follows it too; a block’s own showDuration wins. The duration is formatted by Intl in the locale’s tag (“8s”, “1m 12s”), with a one-second floor. An app’s own label still wins, and wrapping the renderer is still how a product writes its own format — the built-in update now rewrites the label when the block settles, so a wrapper writes its own after delegating. @aparte/core

  • 494cd79: Describe how a tool’s calls are shown with registerToolDisplay(name | RegExp | '*', { label, icon, summary, formatInput, hidden, open }); registerToolRenderer, registerToolSummary and registerToolInputFormatter accept the same patterns and '*'.

    '*' means every tool nothing more specific describes, and an MCP server’s dynamic names (mcp__github__…) can be described with a RegExp. One entry per matcher; calls merge into it; the entry that applies is the exact name’s, else the first pattern registered, else '*'’s — whole, never a mix. Registering a display tells the chats on the page, so a label reaches rows already drawn.

    On the row, label replaces the identifier (in the chat’s font), icon is an icon-provider key (unknown keys fall back to tool), and the identifier stays on the row as data-tool-name. Without a display the row reads as it did: core never turns an identifier into words. registerToolSummary and registerToolInputFormatter are now the summary and formatInput fields of that entry; getToolSummary and getToolInputFormatter resolve patterns too.

    A display that sets or drops hidden or open redraws the messages already on screen, so a “hide tool calls” setting reaches a restored conversation; a label, an icon or a summary only relabels them, keeping what the reader opened. icon offers the icon names (AparteIconName) and takes any key registered with registerIcons.

    A tool renderer’s stylesheet is injected once per renderer, not once per tool name. @aparte/core

  • 494cd79: Open tool rows by default with setSegmentDefaults('tool_call', { collapsed: false }), or one tool’s rows with its display’s open: true.

    The row was collapsed always and read no field. It reads the segment’s collapsed now, as reasoning does, else the tool’s display; closed stays the default, and an update never forces it. @aparte/core

  • 494cd79: The tool row says who refused a call and why it stopped, in the reader’s language; a refused row no longer shows the model’s sentence as its Output.

    A note follows the state word: “You said: “…”” when the person refused and typed instead, “Refused by policy”, “Not run: an earlier call was refused”, and for a stop “Timed out”, “Not available here”, “No one could approve it” or “Turn limit reached”. A plain Stop needs no note. A refused row shows no Output: its result is the sentence written for the model — a policy’s reason included — and the person reads the note.

    The segment keeps it: refusal?: { by, instruction? } and abortCause? sit beside result, which stays the sentence the model read. toolRefusal is exported from @aparte/core. A host approvalResolver that refuses with a reason and no by is read as a policy, so its row says “Refused by policy”; return by: 'person' if a person refused. New locale keys: toolRejectedSaid, toolRefusedByPolicy, toolNotRunAfterRefusal, toolTimedOut, toolUnavailable, toolNoApprover, toolTurnLimit. A segment saved before this keeps its old Output. @aparte/core

  • 494cd79: tool-rejected now says who refused (by) and carries the person’s instruction; tool-aborted says why (cause); toolRefusal() writes the refusal sentence.

    by is 'person', 'policy' or 'earlier-refusal' (a call not run because another call of the same step was refused). cause is 'stopped', 'timeout', 'no-handler' or 'no-resolver'. An approval resolver may return by; without it a reason is read as a policy’s, as before.

    The sentence the model reads is unchanged — reason on the event and the tool message are the same string they were. toolRefusal(decision) returns it with by and instruction, so a runner that answers an agent the loop does not run writes the same one; with no reason of its own, its sentence follows by (a policy’s refusal never says “rejected by the user”). A handler that rejects with an AbortError of its own — no Stop, no timeout — is aborted with no cause. @aparte/engine

  • 494cd79: French for the new tool-row notes, the reasoning label and duration, the turn-limit message, the ask-user declined line and the artifact “not created” state; the approval question reads « Autoriser {tool} ? ». @aparte/locale-fr

Patch Changes

  • 62c4abb: The scroll to a new message is no longer cut short when it starts while an earlier scroll is still running.

    A send that lands during a glide re-targets it — a second smooth scrollTo, continuous where an instant write is a cut — and the engine runs that new smooth scroll from where the view is, for as long as a first one takes. The window in which the viewport holds back instant writes still ended where the earlier glide’s did, so it closed in the middle of the re-targeted run and the next pin jumped the last few pixels (measured in CI on React and WebKit: a write 4 px short of the bottom, ~330 ms into the send). A re-target now opens a window of its own, and re-arms the pin that catches a glide that missed. @aparte/core

  • 494cd79: The turn-limit message follows the chat’s locale (maxTurnsReached, with {n} the limit).

    It was a hard-coded English sentence (“Stopped after 3 tool calls to prevent an infinite loop.”) in a chat set to any language. The English default now reads “Stopped after 3 turns to avoid an endless loop.”, and the message is read from the chat’s own config, so a client on the global config answering a chat with its own locale writes it in the chat’s language. @aparte/core

  • 494cd79: A tool’s Input and Output are coloured as JSON only when they are JSON, scroll past 300px, sit on the code surface, and each has a copy button; the row fits a phone.

    Both parts went to the highlighter as json whatever they held, so a prose result came back tinted as an object; a part is JSON when it parses to an object or an array, and a JSON result is pretty-printed. A part’s body is capped by --aparte-tool-part-max-height (300px, the reasoning block’s cap), scrolls, and is a focusable, named region; its text is on the code surface at the small size whether or not a highlighter coloured it, and its label now sits in .aparte-tool-part-head beside the copy button — the code block’s, now one shared helper. On a narrow screen the state word never yields: the note and the target give way first, then the name, cut with an ellipsis, and the note carries its full text as its title. @aparte/core

  • 494cd79: The waiting dots show again between a finished tool call and the model’s next words, and never while a call waits for approval.

    They showed only until the first segment arrived, so after a tool row read “Done” the reply sat still while the model worked on its next turn. They show while the reply streams and its last segment is a call that has finished or that the app hides, and leave as soon as the next step writes — never while a call waits for a person’s approval, hidden or not. @aparte/core

  • 494cd79: A refused AG-UI tool call’s row now says who refused and what they wrote; the agent receives the same sentence as before.

    The runner wrote its own copy of the refusal sentence. It takes toolRefusal from @aparte/core now, so its tool-rejected carries by and instruction like the loop’s. @aparte/runner-ag-ui

  • 494cd79: A refused, stopped or failed create_artifact shows “Not created” instead of a card with Download; one awaiting approval shows that it is waiting.

    The card drew from the model’s input whatever the call became, so the person was handed the result of an action they had just refused. A call that ran shows the card; a pending call keeps it (the handler answers at once); one waiting for a decision shows a quiet block; one that never ran shows its badge, its title and “Not created” (artifactNotCreated), with no buttons. @aparte/plugin-artifacts

  • 494cd79: A declined question’s receipt line follows the locale (questionDeclined), and receipt: false keeps the call in the model’s history.

    The receipt printed ASK_USER_DECLINED — “The user declined to answer.”, the sentence the model reads — under a question in any language; the constant keeps its value for the model. receipt: false registered a renderer that draws nothing, which kept the call out of the history; it declares the tool hidden through registerToolDisplay now. @aparte/plugin-ask-user

Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.

0.24.0

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

Minor Changes

  • 6b28491: <aparte-conversation-list> shows what each conversation is doing: its title shimmers while an AI answers in it, and a mark appears while its run is paused on the page (a server run waiting on an approval, a question or a browser tool). With a conversation controller, nothing to write.

    setConversationStatus(id, 'running' | 'waiting' | null) and getConversationStatus(id) on the list; the row carries data-status, a screen reader hears the state with the title (new locale keys conversationRunning, conversationWaiting), and the mark (.aparte-conv-item__status) is an empty slot until a state draws it, so the look is yours in CSS. The conversation controller sends the new aparte-conversation-status event on window as turns start (running), pause on the person (waiting) and end (null), always about the conversation the turn started in; every list applies it. New types: AparteConversationStatus, AparteConversationStatusDetail.

    A state is kept for the page, not per list: every list draws it, one that connects later (a drawer, a remount) included, keyed by conversation id. The controller follows the turn in flight by its message id, so a replaced turn’s end does not clear the new turn’s state, and a chat unbound mid-turn (a route change) leaves no row running. @aparte/core

  • 010ed41: AparteClient sends the new aparte-message-resumed when a paused turn goes on after the page answered its paused calls, and a turn that starts from a saved pause now sends aparte-message-paused right after aparte-message-start.

    aparte-message-paused had no other half: a listener tracking a turn heard it stop to wait and never heard it run again until aparte-message-done, and a turn resumed after a reload said “start” while it was asking. New type: AparteMessageResumedEventDetail; APARTE_DEFAULT_UI_EVENTS lists the event.

    resumeDeferred no longer starts a reply a turn is already running into: loading the open conversation again (a click on it) while its pause waited put the same question twice and started the same reply a second time. @aparte/core

  • 6b28491: New kit recipe .aparte-shimmer: a light sweeping across a label while something runs.

    Put it on the span that holds the words of a step in progress (“Searching the web…”, “Thinking”). It is drawn through the text fill, so the element keeps a real colour; it holds still under prefers-reduced-motion, and reads as plain text in forced colours and where background-clip: text is not supported. New tokens: --aparte-shimmer-base and --aparte-shimmer-shine (muted to full ink, which keeps its order in both themes) and --aparte-duration-shimmer (2s). The reasoning line, a quiet tool row and a running conversation’s title wear it. @aparte/core

  • 6b28491: The reasoning label now shimmers while the block streams. Nothing to change in your code; the light stops when the block settles.

    “Thinking” was a still word above a block that was filling up; conversational chats sweep a light across it while the model is still reasoning, and AI Elements and assistant-ui do it by default. It follows the block’s own isStreaming: true, so a block written by hand or restored from storage stays still, and it never moves under prefers-reduced-motion. The label is drawn through its text fill while it shimmers, so a colour you set on .aparte-thinking-label shows once the block settles (recolour the sweep with --aparte-shimmer-base / --aparte-shimmer-shine). To keep the label still:

    css
    .aparte-thinking-label.aparte-shimmer {
      animation: none;
      background-image: none;
      -webkit-text-fill-color: currentColor;
      color: inherit;
    }

    The client now marks a streamed reasoning block isStreaming: true while it arrives (it was left unset), and settles it at the model’s first tool call and at a new step as well as at its first answer token, so a block followed by a tool call no longer reads “Thinking” while the tool runs. Only the answer folds it: a tool call does not close a block the reader opened. @aparte/core

  • 140517e: registerToolDisplay takes details: false for the line alone, or a function that draws what is behind it in place of Input and Output.

    Behind a tool row’s line, core shows the arguments and the result, raw: a developer’s view, and it stays the default. A chat for people had no way out of it but hiding the call or rewriting the whole row. details: false keeps core’s line with no disclosure at all, so nothing raw is one click away; on '*' it covers every tool nothing more specific describes. A function returns what stands in for Input and Output under core’s line, a list of sources for a search, say: an element, or a string inserted as HTML after the config’s HTML sanitizer (the one Markdown goes through), since what it shows comes from a tool’s result. It is called on every update of the call, and on a language switch, whose words are written into the nodes already there; '' means nothing behind the line yet, and the row gets its disclosure once there is something. A function that throws costs the row its detail, never its line, and never brings the raw parts back.

    hidden and a tool renderer keep their precedence. Registering or dropping details redraws the rows already on screen, as hidden and open do. New type: AparteToolDetails. @aparte/core

  • 6b28491: registerToolDisplay takes quiet: the line a person reads, with a shimmer while the call runs and no “Done” once it is done.

    The default row is an agent’s: a spinner and “Running”, then ”✓ Done”, and it stays the default. quiet: true drops the spinner and the word while the call runs (the label shimmers instead), and shows nothing once it succeeded: the label’s tense says it, so give it one that reads the status (“Searching the web…” / “Searched the web” / a plain “Search the web” for the rest). A wait for approval, a refusal, a failure and a stop still show their word and note; the words a quiet row does not show are still read by a screen reader. The target is set in the chat’s font and the chevron moves to the end of the line; the row carries data-quiet. Registering or dropping it redraws the rows on screen. @aparte/core

  • 6b28491: French for the conversation list’s new states: « Réponse en cours », « Attend votre réponse ». @aparte/locale-fr

Patch Changes

  • @aparte/engine@0.24.0 @aparte/core

Version-only bumps (no changes of their own): @aparte/engine, @aparte/runner-ag-ui, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.

0.25.0

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

Minor Changes

  • 0d50cfd: Server-rendered pages now hydrate cleanly: an aparté element waits while it, or an ancestor, carries defer-hydration, and draws itself when releaseDeferredHydration(root) removes the attribute.

    Core’s elements draw themselves when they connect (light DOM: their children, their attributes, a style on the chat’s root). On a server-rendered page that happened as soon as @aparte/core loaded in the browser — defining the elements upgrades the ones the server sent — so the framework then hydrated a DOM it had not rendered: React threw error 418 and discarded the server’s HTML, measured in a Next production build. A held element does nothing until it is released; its upgrade is then replayed as the browser would have run it (the observed attributes it has, then connectedCallback). An element removed while held is never torn down, and replays its attributes when it connects later. Removing the attribute any other way releases a microtask later — or at once, when releaseDeferredHydration is then called on that root, as a wrapper does after a hydration that dropped the attribute.

    The wrappers render the attribute on their root and release it themselves. For server-rendered elements of your own, put defer-hydration around them and call releaseDeferredHydration once your framework has the page; an element class of yours that draws itself on connect honours it after deferHydrationOf(MyElement), called before customElements.define. Without the attribute nothing changes. It is the Web Components Community Group’s defer-hydration, the one Lit’s server rendering follows, widened from an element to the elements under it. @aparte/core

  • 02351e4: AparteChatHost.takeEcho(list) tells a wrapper whether a list the parent hands back is the host’s own report coming back, or the parent’s word.

    Call it before applying the list a two-way binding gives you. It answers true for a report the parent has not handed back yet — however late it lands — and takes it with every older one; for any other list it answers false and clears them, so a list the parent restores later is applied. The four first-party wrappers use it; a wrapper of your own over AparteChatHost gets the same rule. @aparte/core

  • 908340b: Draw a tool call or a segment with your own component: defineMountedToolRenderer(mount) mounts it, updates it and unmounts it with the element, and aparteGlobalConfig.overrideToolRenderer(match, renderer) — a method on any AparteConfig — returns the function that takes it off again.

    defineMountedToolRenderer and defineMountedSegmentRenderer(type, mount) turn a mount function into a complete renderer: core creates the host, calls the mount once when the host is in the page, forwards every change of the call to the returned update (never a remount, so a component keeps its state when the result arrives) and calls destroy when the host leaves for good. A card receives one AparteToolCallView (toToolCallView): id, name, input, status, result, structuredResult, deferred, refusal, abortCause and the segment. New types: AparteToolCallView, AparteMountedView, AparteToolMount, AparteSegmentMount.

    The registries are live: registering, unregistering or overriding a tool renderer — or a segment renderer — redraws the calls (or segments) already on screen that it now draws, and only those; the renderer that drew them gets its cleanup. config.overrideToolRenderer and the standalone overrideSegmentRenderer(renderer, config?) stack: the newer declaration wins, taking off an older one leaves it, and taking off the last restores what was there before — core’s own renderer, for a built-in segment type like code. The redraw happens in a microtask, gathered per config (ten components declaring cards in one render cost one pass; an override taken off in the same tick costs nothing), reaches a chat with its own config when the global registration it inherits changes, and follows a chat whose own config is attached after its first rows. A rendering-version bump — a Markdown or highlight provider arriving late — keeps the cards a mounted renderer holds mounted, and so does the end of a turn: setSegments() with the same segments (the host re-syncs the finished message) hands those cards the final state instead of drawing them again. A restore function does nothing after the config’s reset(), and leaves a registration made directly since in place. @aparte/core

  • 986df98: Unmount what you mounted in a segment or a tool call in cleanup(element, segment): core now calls it, once, when the element leaves for good.

    AparteSegmentRenderer.cleanup was declared and never called; it is called now, with the segment’s latest state, and AparteToolRenderer gains the same hook. It runs when the element is replaced by a new render(), when its segment is removed, when its message is redrawn in a new frame, and when its message leaves the page — but not for update(), not for relabel(), and not for a message a framework only moved (a bubble taken out and put back before the next microtask keeps its elements). A bubble that comes back after a longer absence draws its segments again. A renderer that replaces its own element inside update() cleans up what it replaced; the bubble follows the new element.

    A tool call is updated, relabelled and cleaned up by the renderer that drew it, recorded when the row is set up — not by whichever renderer is registered at that moment, which could hand core’s own row, or another renderer’s markup, to an update() that never drew it. @aparte/core

  • de8732e: A runner of your own now receives the tools the request allows and its toolChoice: input.tools (each a RunnerTool — name, description, inputSchema) and input.toolChoice ('auto', 'none' or { name }).

    Core sends tools when the model declares function_calling, and the provider used to post the worker only the messages and the generation options, so a runner that emits tool_use had to hard-code its tool list — offering a small local model tools the app never enabled, and unable to honour toolChoice: 'none' or a forced tool. Both are absent when the request has none. The built-in runners take neither, and still warn when a turn needs a tool call they cannot carry. Closes #119. @aparte/provider-transformers

  • 7d9550d: Svelte 5 apps now need Svelte 5.20 or later (peer ^4.0.0 || ^5.20.0, on @aparte/svelte and on @aparte/plugin-model-selector’s optional Svelte peer); on Svelte 5, import from @aparte/svelte/runes; createAparteChat and createConversationManager are deprecated, and 0.26.0 moves today’s package root to @aparte/svelte/legacy.

    @aparte/svelte/runes is the chat written in Svelte 5: <AparteChat> with snippets (emptyState, aboveComposer, composer, toolbar, bubble), a bindable messages, callback props (onmessageSent, onaction, onmessagesChange, onmessageAppended, ontypingChange, onconversationCreated), a stable id from $props.id() and a server render that SvelteKit hydrates; <AparteUi>; createConversations() / AparteConversations, the saved conversations as reactive state; createAparteClient; and <AparteToolUI> / <AparteSegmentUI>. It needs Svelte 5.20 or later, and the peer range is now ^4.0.0 || ^5.20.0 for the whole package, the root entry included — and on @aparte/plugin-model-selector’s optional Svelte peer too, which follows the wrapper’s: an older Svelte 5 that is installed now fails npm install with ERESOLVE, even for an app that only uses the root component or the plugin.

    createAparteChat (use bind:messages) and createConversationManager (use createConversations()) are deprecated. In 0.26.0, @aparte/svelte becomes the runes component and today’s — the Svelte 4 syntax component and these two helpers — moves to @aparte/svelte/legacy, where it keeps working on Svelte 4 and 5. @aparte/plugin-model-selector, @aparte/svelte

  • cf0590e: @aparte/angular installs on Angular 20 and 21, not only 19.

    The peer range is ^19.2.0 || ^20.0.0 || ^21.0.0. The package is still compiled on 19.2 in Angular’s partial mode, which Angular guarantees a newer application can link, so one build serves the three majors. What makes the range a fact rather than a hope: the specs run on 19 (with zone.js, zoneless, and in a server render), and the repo’s Angular example now runs on 21, zoneless, driven in a real browser by the end-to-end suite. Angular 19 is past its upstream support window and leaves the range at 1.0. Angular 22 joins once it is exercised the same way.

    @aparte/plugin-model-selector follows: its optional @angular/core peer (for the ./angular entry) takes the same range, since a narrower one would fail npm install on Angular 20 and 21 even for an app that never imports that entry. @aparte/angular

  • c367c85: Bind [conversations] on <aparte-conversation-list> with no CUSTOM_ELEMENTS_SCHEMA: its directive now has that input.

    The element directives were generated from the elements’ attributes only, so a member that exists only as a JS property — the list’s rows — had no input, and [conversations]="…" did not compile unless the component switched template checking off for every unknown tag. The generator now also emits an input for each public property an element has no attribute for; it hands the value over as the property. <aparte-conversation-list>’s conversations is the only such member today. @aparte/angular

  • d6cf93b: Set AparteChatComponent inputs from code with componentRef.setInput(): they are signal inputs now. Templates do not change, and the per-message template can be written as content: <ng-template aparteBubble let-message>.

    Every input keeps its public name ([messages], placeholder, disabled, centerWhenEmpty, [bubbleTemplate]…), so a template binding works as before; what changes is the class member behind it, which is an Angular InputSignal rather than a writable property. Code that created the component and assigned instance.config = … sets it with componentRef.setInput('config', …) instead, which is how Angular sets any input from code.

    AparteBubbleDirective is new: <ng-template aparteBubble let-message> written inside <aparte-chat> draws each message, the same as passing a TemplateRef to [bubbleTemplate], which still wins when both are given. [(messages)] two-way binding is supported and tested: the chat reports its own changes through messagesChange, and a write from the parent emits nothing back.

    The component renders on the server: the host that drives the custom elements is created after the first render in the browser only, and nothing touches requestAnimationFrame during a server render. It also runs under zoneless change detection (the default for a new app since Angular 21); both are covered by specs. @aparte/angular

  • 15254e4: Draw a tool call with your own component: <AparteToolUI name="…"> (React, Vue, Svelte 5) or <ng-template aparteToolUI="…"> (Angular).

    The card renders from where you declare it, so it reads your app’s contexts — React context through a portal, Vue inject through Teleport, Svelte getContext (the snippet is mounted with the declaration’s contexts), Angular DI (the view is created where the template is declared, and it works zoneless). It is mounted once per call and updated in place when the call changes, so a component keeps its local state when the result arrives. It is unmounted when the call leaves the page or when the declaration unmounts, and the call goes back to core’s row; a declaration made while calls are already on screen takes them over. Of two declarations for one tool the newer wins, and removing the older keeps it. A server render declares nothing and draws nothing: the cards appear in the browser.

    AparteSegmentUI (<ng-template aparteSegmentUI="…"> with AparteSegmentUIDirective in Angular) does the same for a segment type. In Svelte both are in @aparte/svelte/runes. Vue also exports isAparteElement, the test to give compilerOptions.isCustomElement: it accepts the aparte-* elements, and leaves the wrapper’s own component names to Vue, so a kebab-case <aparte-tool-ui> gets Vue’s warning instead of becoming an inert element (write it <AparteToolUI>). @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

Patch Changes

  • 9bc30ea: Stopping a client that never started no longer touches window, so it is safe on a server.

    The constructor prepares the send handler without listening, and stop() only returned early when there was no handler — so it always removed listeners from window. On a server that is a ReferenceError; a SvelteKit page that started a client the documented way failed with a 500 (Svelte runs onDestroy at the end of a server render). A started client still removes its listeners on stop(), once. @aparte/core

  • 6fed78c: <aparte-approval-mode> and <aparte-model-selector> now wait for a server-rendered chat to hydrate before they draw.

    Placed in the toolbar of a server-rendered <AparteChat>, either tag drew itself as soon as its plugin was imported — before the framework had hydrated the page — so React found children it had not rendered and threw the server’s HTML away (error 418). Both now honour defer-hydration, like core’s own elements, and draw when the wrapper releases the chat. <aparte-ask-user> already did, through the <aparte-elicitation> it extends. An element of your own that draws itself on connect can do the same: call deferHydrationOf(YourElement) before customElements.define. @aparte/plugin-approval, @aparte/plugin-model-selector

  • cf0590e: @aparte/angular installs on Angular 20 and 21, not only 19.

    The peer range is ^19.2.0 || ^20.0.0 || ^21.0.0. The package is still compiled on 19.2 in Angular’s partial mode, which Angular guarantees a newer application can link, so one build serves the three majors. What makes the range a fact rather than a hope: the specs run on 19 (with zone.js, zoneless, and in a server render), and the repo’s Angular example now runs on 21, zoneless, driven in a real browser by the end-to-end suite. Angular 19 is past its upstream support window and leaves the range at 1.0. Angular 22 joins once it is exercised the same way.

    @aparte/plugin-model-selector follows: its optional @angular/core peer (for the ./angular entry) takes the same range, since a narrower one would fail npm install on Angular 20 and 21 even for an app that never imports that entry. @aparte/plugin-model-selector

  • 77d7493: <aparte-ui> no longer builds its element during a server render, so a hydrated Angular SSR page shows it once.

    It built the element in ngAfterViewInit, which Angular also runs on a server: the element was serialized into the HTML without its props or listeners, hydration kept it, and the browser appended a configured one beside it — two model selectors, two counters. It is now built in the browser only, the way React, Vue and Svelte build theirs in an effect or onMount. @aparte/angular

  • aa0a6d8: Under [(messages)], a message the chat appends right after one of its own changes is no longer dropped when the parent’s field comes back through [messages].

    The chat reports a change (messagesChange), the two-way binding writes it into the parent’s field, and the chat then appends without reporting — an optimistic assistant placeholder, or core’s append right after a flush of buffered chunks (an edit, a retry). At the next change detection the parent’s field came back through the input and reset the chat to the list it had reported, so the placeholder vanished and the streamed reply landed on the user’s message, or nowhere. The chat now recognises its own reports that have not come back yet and keeps its list when one does — the rule React, Vue and Svelte apply — while a list you restore later is still yours. @aparte/angular

  • ef17513: A server-rendered <AparteChat> now hydrates without a mismatch, in Next, Nuxt, SvelteKit and Angular SSR.

    The server’s HTML carries defer-hydration on the chat’s root, so core’s elements leave it alone until the framework has the page; the wrapper then releases them, before it builds the chat’s host. Before, the elements drew themselves as soon as @aparte/core loaded and the framework found a DOM it had not rendered — React threw error 418 and discarded the server’s HTML. An app with no server render holds nothing, so a slot’s component can talk to the elements it renders from its own mount hook: React reads “am I hydrating?” through useSyncExternalStore, Svelte draws the attribute on the server only, Angular — where it is on the <aparte-chat> host itself — binds it only while hydrating a host the server sent with it, and Vue releases from the root’s ref. On a server-rendered page, Vue’s ref still releases before any onMounted; in React, Svelte and Angular a slot’s component mounts before the chat does (its effect, onMount or ngAfterViewInit runs first), so on that first load it finds the elements live only once the chat has mounted. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 02351e4: A list you assign back to the chat — a reopened conversation, an undo — is now drawn, even when the chat reported it to you earlier.

    Under a two-way binding every list the chat reports comes back through your prop, sometimes late — an earlier report after its next change — and applying that echo would roll back what the chat did since without reporting. To tell an echo from your own word, the wrappers remembered every list the chat had ever reported, so a list you kept and assigned back later was taken for an echo and ignored: the chat went on showing the other conversation, and its next report wrote that conversation’s messages into the one you had reopened. Only the reports you have not handed back yet count now; any list the chat did not report clears them. React, Vue, Svelte (both entries) and Angular apply the same rule. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 2051a75: createAparteClient() no longer fails a server render, and a server render no longer leaves its keyResolver on the global config.

    It stopped the client in onDestroy, which Svelte runs at the end of a server render too, and stopping a client that never started read window there and failed every SvelteKit page that followed the docs. stop() now touches nothing of the browser for a client that never started (@aparte/core), so the stop stays in onDestroy — the one hook a server render runs — and takes back the key source the client put on the config when it was constructed. Moved to a hook a server never runs, each request would have added one to the config the whole process shares, holding whatever that request’s resolver closed over. @aparte/svelte

  • bf7de83: AparteChat and AparteUi are typed for Svelte 5 as well as Svelte 4: props, events and slots check under either.

    The package is now built with Svelte 5’s tooling, which emits each component’s type in a form both majors read (a Svelte 4 class constructor and a Svelte 5 component function). Verified with the packed tarball installed in two fresh projects, Svelte 4.2 with svelte-check 3 and Svelte 5.57 with svelte-check 4, both with skipLibCheck: false: the documented usage checks clean, and an unknown prop or a wrong prop type is reported in both. Nothing changes in the components themselves. @aparte/svelte

Version-only bumps (no changes of their own): @aparte/engine, @aparte/runner-ag-ui, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/locale-fr, @aparte/docs-mcp.

0.26.0 Latest

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

Minor Changes

  • f0b38dd: A page that registers a compactor now has its sends wait for it, and a request the model refuses as too long is compacted and sent once more. Nothing happens without a compactor: with none registered (setCompactor), every send, retry, edit and resume behaves as before. With @aparte/plugin-compaction set up, the composer shows Stop while a summary is being written before a send, and an overflow that used to end as an error card now ends as an answer, with the separator above the kept turns as the only sign.

    What is new, all additive:

    • Before each request goes out, the client calls compactor.beforeSend({ target, targetId, exclude, requestTokens }) once aparte-message-start is dispatched (so Stop is showing and the reply is waiting) and the request is built (files inlined). exclude is the user message and the reply of the turn in preparation; requestTokens is the estimate of the request. When it resolves true the client builds the request again, from the history as it now is. A Stop while it waits ends the turn as aborted, the way a Stop ends one today. A compactor that throws there is warned about and the request goes out as built.
    • When the model refuses a request with USAGE_CONTEXT_EXCEEDED before the reply has any text or tool call, the client calls compactor.compact({ force: true, reason: 'overflow', exclude }) and, when it compacted, sends the turn once more into the same reply. Once per turn. If there is no compactor, the compaction skips or fails, or the reply already said something, the error stands exactly as before.
    • The reply records contextEstimate, the estimate of the request it was sent with, before each send, so the context measure can calibrate the character heuristic against the usage the provider reports. A request that carries an image records none, since the heuristic cannot read it whole.
    • Both calls carry signal, the turn’s abort signal, so a programmatic abort() or stop() reaches a compactor that does not listen on window.
    • A retry or an edit under a framework wrapper rebuilds its request from the live transcript, so it carries a checkpoint written while it waited; the wrapper’s root now offers getMessage, as the viewport does.
    • AparteFormatAdapter.classifyError?(status, body) lets a provider name a failure a status cannot (an overflowing prompt is a 400). Core’s vendorError calls it for a direct call; createAparteChatHandler calls it on the server and forwards the code as error.code, still without the vendor’s prose, and the backend transport reads error.code when it is an AparteErrorCode value. A vendor’s own code (context_length_exceeded) is not one and is ignored on the client.
    • The server loop keeps the overflow code too: createAgentHandler sends the code an error carries in its run-error line (AGENT_RUN_FAILED only when it carries none), and createAgentStreamRunner throws a code that is an AparteErrorCode value as the error’s own code, so a server-side overflow is compacted and sent again like a direct one. A code that is not one of ours stays PROVIDER_ERROR, with the raw value in data.code.
    • createAparteChatHandler no longer relays the prose of an error event the vendor streamed after its 200. The browser gets The vendor reported an error. and the code when it is an AparteErrorCode value; the original goes to the server’s log.
    • The stream error event takes an optional code (AparteStreamEventMap), and AparteError.from keeps a valid .code of the error it wraps, so a code a provider streamed after a 200 reaches the client.

    Why. Compaction that only runs after a turn cannot help the turn that overflows, and a send that races a summary carries the history the summary is about to replace. Classification stays in the provider because what a server writes for an overflow is its wire format, not core’s to know. @aparte/core

  • 37b82d9: Register what compacts the context with aparteGlobalConfig.setCompactor(compactor | null), and read it back with getCompactor(). The separator’s Retry and the gauge’s Compact now use it. Nothing for you to change.

    What is new, all additive:

    • AparteCompactor, AparteCompactorRequest, AparteCompactorOutcome and AparteCompactorReason (types). A compactor has compact(request) (never rejects: the outcome says ok), settled(targetId?), and optionally beforeSend(request) and preview(request). A request names the chat by its element (target) and, when it has one, its targetId, because a chat inside a shadow root is not found by id; force, exclude and reason (manual, retry, overflow, auto) say how it is asked.
    • A chat reads the compactor of its own config first (resolveConfig(chat).getCompactor()), then the global one. setCompactor notifies like every other registration, so a mounted gauge hears that Compact now became possible.
    • The separator’s Retry asks the compactor when one is registered, with force: true and reason: 'retry', so it runs even when automatic compaction has stopped after failures. With none registered it dispatches aparte-compact as before.

    Why. Core cannot import the compaction plugin, and an event answers nothing: the caller needs to know the summary is written before it goes on, and a button needs to know it is still working. @aparte/plugin-compaction registers itself here when it is set up; core never compacts on its own. @aparte/core

  • 39c3d7f: A message can now carry a checkpoint, contextSummary: from it on, the model reads the summary in place of every message before it, and nothing before it is deleted. The client sends the checkpointed history on send, retry, edit and resume. Nothing changes for a conversation with no checkpoint.

    What is new, all additive:

    • AparteMessage.contextSummary?: AparteContextSummary ({ summary, count, createdAt }, exported as a type). It sits on the first kept message, which is a user message, so the history still alternates its roles.
    • AparteMessage.contextEstimate?: number, the estimate in tokens of the request that produced a reply. The client writes it on each reply when it sends (none when the request carries an image the estimate cannot count), and the context measure reads it back to calibrate itself.
    • historyFromCheckpoint(path) returns { summary?, messages }: the messages from the LAST checkpoint on the path, and its summary. A legacy compaction: true message before that checkpoint is dropped with the rest. With no checkpoint the path comes back unchanged. It is exported from both entries, so a server or a history function can use it.
    • applyRetention keeps a checkpoint the trim cuts past: the new first message inherits the latest checkpoint’s contextSummary (same summary and date, count raised by the messages cut after it), in the flat list and in the branch tree. Before, a retained conversation lost both the originals and their summary.

    What a host that builds its own history should know. A history function still receives the full transcript, checkpoint and everything before it; to honour a checkpoint, run the messages through historyFromCheckpoint and send its summary first (the client sends it as a user message under the same preamble a compaction: true message gets). The default 'viewport' history does this itself.

    Why. Compaction used to replace the summarised turns with a summary message, so the turns, their attachments and their branches left the screen and the store. A checkpoint turns compaction into a view over a kept history: only what goes to the model is shortened. @aparte/plugin-compaction writes the checkpoints, and the bubble draws a separator above the message that carries one.

    How the tree behaves, so a host is not surprised. A checkpoint is a node shared by every path through it. Editing the checkpoint message edits that node in place and keeps the field. Editing a message above it clears everything after the edited message, the checkpoint included, so the full history is sent again. A retry of a reply above the checkpoint forks before it and sends no summary: it would describe messages the model is also reading. @aparte/core

  • 37b82d9: auto-compact on <aparte-context> is deprecated. It still works, and your page keeps compacting as before, but it will be removed in a future major version: @aparte/plugin-compaction now decides when to compact (after a turn, and before a send that would overflow), so the gauge no longer needs to ask. Remove the attribute once your page runs the plugin’s new release.

    The gauge is now a button that opens a panel with what fills the window, and it no longer goes blank after a compaction.

    What you may have to change:

    • After a compaction or a Restore, the gauge shows an estimate with a ~ prefix (~12K / 128K) instead of going blank until the next turn. A reading you used to wait for is now there at once; an estimate is never drawn as if it were the provider’s number. aparte-context-threshold fires on the estimate as it does on a reported number.
    • The gauge’s markup changed. The reading (.aparte-context__text) is inside a <button class="aparte-context__toggle">, and a hidden panel (.aparte-context__panel) follows it. The meter keeps its role="meter" and its label. CSS that targeted the gauge’s direct children needs a look.
    • A turn the model refused as over its window (aparte-message-error whose error code is USAGE_CONTEXT_EXCEEDED, for this gauge’s chat) turns it danger whatever the number says, until the next reply reports.

    What is new, all additive:

    • A disclosure: the button has aria-expanded and aria-controls; click, Enter and Space open it, Escape and a click outside close it, and nothing opens on hover. The panel lists the system prompt, the tools, the summary of a compacted history, the messages, “other”, what is free, and the total against the window. The parts are estimates and say so; they are scaled down for display so they never add up to more than the total.
    • With a compactor registered (setCompactor) whose preview() returns something, the panel says “Compacting now would free ~N tokens” and offers Compact now (compact({ force: true, reason: 'manual' })). While it runs the element has data-compact="running", after a failure data-compact="failed" and Retry in the panel. Both come from the aparte-compact-start, -done and -error events of the gauge’s own chat. The gauge reads the config, and so the compactor, the system prompt, the tools and the window, from the chat it watches (target, else the chat above it), not from where it stands, so a gauge in a header reads a chat that carries its own config.
    • aparte-compact detail gains auto (set by the deprecated auto-compact, so the plugin can tell it from a button) and window (the window the gauge measured against). The AparteContext.measure getter returns the reading drawn.
    • Nine optional locale keys: contextSystem, contextTools, contextSummary, contextMessages, contextOther, contextFree, contextTotal, contextReclaim ({n}) and contextCompactNow. Retry reuses retry. A locale without them falls back to the built-in English.

    Why. The gauge drew the one number the last turn reported, so a compaction left it blank or wrong, and it said nothing about what the number was made of. Reading measureContext over the chat’s own transcript fixes the first; the panel answers the second, and puts the one action that helps (Compact now) next to the number that asks for it. @aparte/core

  • 37b82d9: Use measureContext({ messages, config, windowOverride }) to read how full the model’s window is: the same measure the context gauge draws and the compaction plugin decides on. Nothing for you to change.

    What is new, all additive:

    • measureContext returns { window?, used, estimated, parts: { system, tools, summary, messages }, other }. used is what the next request will carry while nothing the model reads has changed since the last reply: the provider’s reported usage, except for a reply that thought, where it is the reported input plus the answer as it goes back on the wire (a provider counts its reasoning tokens as output, and the reasoning is not sent again). After a message, a compaction or a Restore it is an estimate (estimated: true) of the request that would be sent now: the system prompts, the tool definitions, the checkpoint’s summary and the messages after it, scaled by how far the heuristic was off on the last reply (its input tokens over contextEstimate, kept between 0.5 and 3; 1 when the reply recorded none, and 1 when it called tools, because the estimate is of the first request and the usage of the last call, which also read the tool results). parts are the raw, unscaled estimates; other is what they cannot attribute (a server loop’s own prompt and tools, template tokens, the heuristic’s error), never below zero.
    • estimateTokens(text) and estimateWireTokens(messages). The second counts what the wire sends for those messages with the serialisation the client builds its history with: thinking is not counted, text and tool calls with their results are, a failed turn or one still in flight sends nothing.
    • AparteContextMeasure and AparteContextMeasureInput (types).

    Why. The plugin kept its own copy of the character heuristic and the gauge kept the number the last turn happened to report, so after a compaction the gauge was blank or stale and the plugin decided on figures the page never showed. They now read one function over one serialisation. The serialisation itself moved out of AparteClient into pure functions (wireMessages and friends in client/wire-history.ts); the requests it builds are the same as before. @aparte/core

  • 5aa437f: A message that carries a checkpoint (contextSummary) now shows a separator above it: “12 messages summarised for the model · Show summary · Restore”. The messages above it stay on screen and in storage. Restore takes the checkpoints off and the model reads the original messages again.

    What you may have to change:

    • A message written by an older compaction (compaction: true) is no longer drawn as a muted bubble. The bubble draws the separator in its place (“Earlier messages were summarised”, with Show summary, and no count or Restore), and its own row does not paint. If your CSS targeted aparte-chat-bubble[data-kind="compaction"] for the old dashed notice, those rules no longer match anything visible; the data-kind attribute is still written.
    • A custom bubble renders its own separator. The separator belongs to <aparte-chat-bubble>; a renderBubble (React), bubble slot (Vue, Svelte) or bubbleTemplate (Angular) that draws its own markup must read message.contextSummary itself.

    What is new, all additive:

    • <aparte-chat-bubble>.setContextSummary(summary | null, { legacy? }), and SyncableBubble.setContextSummary, which populateBubbleFromMessage calls for every message (with none, too, so a reused bubble loses its separator). The separator sits outside .aparte-message, inside the bubble, so a frame redraw or a custom shell keeps it.
    • States, driven by the compaction events: while a summary is written the separator says “Summarising the context…” (the shimmer recipe); if it fails it says “Couldn’t summarise the context” with Retry, which asks the chat’s registered compactor (force, reason 'retry') and falls back to dispatching aparte-compact when none is registered. The viewport (and, under a wrapper, the framework host) reads aparte-compact-start, -done and -error, acts only on those that name its own chat and a message of its own transcript (an untargeted event, which every chat receives, is checked against the message), and sets data-checkpoint="pending" or "failed" on the bubble whose id is detail.messageId. A -done for that message clears it; a skipped -done, another message’s id and another chat’s events are ignored; the next -start and a conversation switch clear it. The new data-checkpoint attribute is read-only for you.
    • Restore asks “Send the original messages to the model again?” first. It then removes contextSummary from every message of the active path (through the chat’s own updateMessage, so a framework’s list and your storage see it) and dispatches aparte-compact-done with restored: true.
    • Event details gain optional fields: aparte-compact-start { messageId }, aparte-compact-done { messageId, count, restored }, aparte-compact-error { messageId }. @aparte/plugin-compaction dispatches them.
    • The conversation is saved when a compaction finishes, once the message named by messageId carries its checkpoint, and again after a Restore. A host that still writes a compaction: true message keeps the old behaviour.
    • Ten optional locale keys: checkpointCount ({n}), checkpointCountOne, checkpointLegacy, checkpointGroup, checkpointShow, checkpointHide, checkpointRestore, checkpointRestoreConfirm, checkpointPending, checkpointFailed. Retry and Cancel reuse the existing retry and cancel. A locale without them falls back to the built-in English.
    • A new sheet, styles/components/checkpoint.css (aparte-checkpoint… classes), wearing the divider, ghost button and shimmer recipes.

    Why. The compaction plugin used to replace the summarised turns with a summary message, so the turns left the screen. With a checkpoint over a kept history the reader sees everything they wrote and one line saying what the model no longer reads; Restore is the way back.

    Detail for the curious. --aparte-message-content-text-user and -assistant were declared only by the old notice’s rule; they are now declared as initial on the root, which reads as unset exactly as before. The viewport’s renderable list gained contextSummary and compaction; the framework host’s updateMessage now reaches the bubble of a message that is not the last for content, segments, attachments, usage, finishReason, contextSummary and compaction, where it used to stay in the list until the next full sync.

    Also in the separator. Confirming Restore removes the separator while its button holds the keyboard; the focus moves to the message the checkpoint sat on rather than falling to the page. A second failure with a different reason redraws the separator with the new reason. The summary panel wears the prose rules (aparte-segment-content), so lists, quotes, links and code in a Markdown summary are drawn like the rest of the transcript.

    Deprecated: the compactionSummaryTitle locale key. Nothing reads it any more — the separator speaks through the checkpoint* keys — and it stays until a later minor so a locale that sets it keeps compiling. @aparte/core

  • eb3c0b1: A reply cut off at the model’s length limit now says so, and the done stream event, the stored message and aparte-message-done carry a new optional finishReason.

    Nothing to change in your code: a provider that reports no reason behaves as before. To see the note, a provider has to send finishReason: 'length' on its done event (@aparte/provider-openai-compat and @aparte/provider-ai-sdk do; so does a scripted turn of @aparte/provider-scenario).

    • AparteFinishReason is 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other'. It rides on AparteStreamEventMap.done, on AparteMessage.finishReason (persisted, so a reload still shows the note) and on AparteMessageDoneEventDetail.finishReason. It is the reason of the LAST model call of the turn: a tool turn that ends on a text answer reports that answer’s reason, never the earlier 'tool-calls'.
    • The bubble draws a small muted line after the content of a completed assistant reply whose reason is 'length', and nothing for any other reason or while the reply streams. The text is the new locale key responseCutOff, in .aparte-finish-note (no role="alert": it is a fact about the answer, not an error). bubble.setFinishReason() and populateBubbleFromMessage feed it, so wrapper-managed bubbles get it with the call they already make.
    • The server-loop path needed no change: createAgentStreamRunner replays each run-done line whole and createAgentHandler writes it whole; a test pins that. @aparte/core
  • eb3c0b1: runStreamAgent now emits finishReason on run-done: the reason the LAST model call of the run ended, when its provider reported one.

    A step that reports no reason does not inherit the one before it: the value is reset at the top of every model call, so a final answer that says nothing is not described by the 'tool-calls' of the turn before. StreamChatEvent’s done accepts the field (StreamFinishReason, the same union as core’s AparteFinishReason). A run whose providers report nothing emits run-done exactly as before. @aparte/engine

  • f0b38dd: A stream error event can now carry a code, and the error runStreamAgent throws for it carries the same code. Nothing for you to change: an event without a code throws the plain Error it always did.

    StreamChatEvent’s error variant gains code?: string (core’s event map gains the typed code?: AparteErrorCode; this is its mirror). The caller reads it from the thrown error: core’s AparteError.from keeps it when it is one of AparteErrorCode’s values, which is how an overflowing prompt a provider reported mid-stream (USAGE_CONTEXT_EXCEEDED) becomes something the client can act on instead of a sentence to show. @aparte/engine

  • 1f62ccc: The done event now carries finishReason, mapped from the AI SDK’s finish reason, so a reply the model stopped at its length limit shows as cut off.

    'stop', 'length', 'content-filter', 'tool-calls', 'error' and 'other' pass through; the SDK’s 'unknown' becomes 'other'. A value the SDK adds later is left off rather than guessed. @aparte/provider-ai-sdk

  • f0b38dd: An overflowing prompt reported by the AI SDK now carries code: 'USAGE_CONTEXT_EXCEEDED' on the error event, so core can compact and send the turn again. Nothing for you to change; any other error is the event it was.

    The bridge matches the vendor’s own sentence in the API error’s message, or in its responseBody when the message is generic (“maximum context length”, “context size has been exceeded”, exceed_context_size_error, “exceeds the available context size”, “prompt is too long”, context_length_exceeded; any case), the same readings @aparte/provider-openai-compat makes on a wire body. @aparte/provider-ai-sdk

  • f0b38dd: The provider now tells an overflowing prompt from a bad request: a 400 or 413 whose body says the prompt did not fit is USAGE_CONTEXT_EXCEEDED, and so is an error the server streams after its 200. Nothing for you to change; with @aparte/plugin-compaction, core compacts and sends the turn again instead of showing the error.

    What is new, all additive:

    • provider.classifyError(status, body) (the AparteFormatAdapter method core’s direct transport and createAparteChatHandler call): USAGE_CONTEXT_EXCEEDED for a 400 or 413 whose error says context_length_exceeded, or whose message, type or code matches “maximum context length”, “context length”, “context size has been exceeded”, “exceed_context_size_error”, “exceeds the available context size” or “prompt is too long” (any case). Every other status and body is left to the status.
    • The mid-stream error event the provider emits for the same payloads carries code: 'USAGE_CONTEXT_EXCEEDED'; so does the error parseText throws for a 200 body that is an overflow (LM Studio). Any other error carries no code, as before.

    Needs @aparte/core with the same release to be acted on; older cores ignore the code and show the error as they did. @aparte/provider-openai-compat

  • 45ed986: Errors a server sends mid-stream now surface as errors, a reply’s finish reason is reported, and the context window of LM Studio, Ollama, llama.cpp and vLLM models is read.

    Three changes, none of which asks you to touch your code:

    • Stream errors. A server that answers 200 and then streams data: {"error": {...}} (LM Studio on a context overflow, OpenAI and vLLM mid-stream) used to end the turn on an empty done: an empty reply marked completed. The line is now an error event carrying the server’s message, and the stream ends there without a done. The non-streaming path throws the same message for a 200 body that has error and no choices, where it returned ''.
    • Finish reason. The last non-null finish_reason travels on every done as finishReason: stop, length, tool-calls (tool_calls and function_call), content-filter, or other for anything else. A server that never says leaves it off.
    • Context window. fetchModels read only context_length, so a local model had no contextWindow: the gauge never showed and compaction never triggered while the server truncated silently. It now reads context_length, max_model_len, context_window or max_context_length from the /models entry, and the new server option ('lmstudio' | 'ollama' | 'llamacpp' | 'vllm') makes one extra request at the endpoint’s origin for the window the server actually serves: LM Studio’s /api/v1/models (a loaded model’s context_length), Ollama’s /api/ps, llama.cpp’s /props. A failing probe leaves the models without a window, with one warning. A contextWindow you declare on a model in models wins over anything read.

    The LMSTUDIO and OLLAMA presets now set server, and two presets are new: LLAMACPP (http://localhost:8080/v1) and VLLM (http://localhost:8000/v1).

    An unloaded LM Studio model, and an Ollama model not currently loaded, get no window rather than the trained maximum: that number is not what the server will run with, and a gauge drawn against it is worse than none. @aparte/provider-openai-compat

  • 1f62ccc: A scripted turn can end with { finishReason: 'length' } to set finishReason on its done event, so a demo can show the “cut off” note.

    A turn that declares none reports none, as before. @aparte/provider-scenario

  • d68ace6: The summary request is now bounded: streamed, capped by maxTokens, refused without a known window, trimmed to fit, and a summary that is cut off, failed or no shorter than what it replaces is refused.

    What you may have to change:

    • A model with no contextWindow no longer compacts by default. setupCompaction() reports { skipped: true, reason: 'no-window' } (new CompactionSkipReason) instead of sending everything but the last four messages in one request. Declare the window on the model, or size the request yourself: passing keepWithoutWindow (the opt-in; it keeps today’s behaviour), your own selector or your own summarize is enough.
    • A summarize of yours now receives stream: true and maxTokens on the request, where it used to receive stream: false and no limit. If it forwards the request to a model as is, it now gets a stream back; if it expects a string, set stream: false on the request you forward.

    What changed, and why. A live test against a local model with a 4096-token window showed the summary request was bounded by nothing:

    • The request is streamed and drained strictly. An error event fails the compaction with its message instead of applying the text received before it, and a done whose finishReason is 'length' fails with “The summary was cut off at the model’s length limit”. A plain string answer is still the whole summary.
    • request.maxTokens keeps the answer inside the window: a quarter of the window, at most reservedGeneration (2000, the room every request keeps for a reply) and never under the room the window walk holds back for the summary; reservedGeneration when there is no window. It is deliberately looser than that reservation (about 118 tokens on a 4096-token model), which a reasoning model spends thinking before it writes a word.
    • With a known window and the default summariser, the instruction plus the transcript of the dropped turns must fit the window less maxTokens, the reserved thinking and the safety margin. When they do not, the oldest turns that fit are summarised and the rest stay verbatim for the next compaction. If not even the oldest turn fits, the compaction fails with an error instead of sending it.
    • A summary whose estimate is not smaller than the turns it replaces fails with “The summary is no shorter than the turns it replaces”, and the transcript is left untouched. This one applies to a host’s summarize too. @aparte/plugin-compaction
  • 62c70d4: Compaction no longer deletes messages, and it is automatic once set up. A compaction now writes a checkpoint (contextSummary) on the first message it keeps, and the history the client sends resumes from it; the messages above stay on the screen and in storage. setupCompaction() also compacts by itself: after a turn that ends at 80 % of the window, before a send whose request would not fit, and when the model refuses a request as over its window (the turn is sent once more). Pass auto: false to keep it manual only (the gauge’s Compact now, a separator’s Retry, compact(), aparte-compact): none of the three automatic attempts is made, the overflow retry included.

    What you may have to change:

    • A page that only wanted the manual compaction passes auto: false. auto defaults to true, and is forced to false with listen: false. auto: false means manual only: compact() answers an 'auto' or 'overflow' request with a skip ('nothing-to-drop'), so the model’s overflow error stands as it did before the plugin. It acts only when a summariser can run (a provider registered for the current model, or your summarize), and only when a window is known: the model’s, or the new contextWindow option.
    • A CompactionTarget of your own needs updateMessage(id, updates). It is the one write a compaction makes. Without it the compaction fails with an error that says so, before the model is paid; there is no fallback that rewrites the transcript. clearAll and appendMessage are no longer called (they stay optional on the type). <aparte-chat-viewport> already has updateMessage.
    • DEFAULT_COMPACTION_PROMPT has a new value (the name is unchanged). It asks for fixed sections (the goal and constraints, the decisions and why, the open tasks, the facts and tool results still needed, the next step) in the language of the conversation, and keeps the clause that stops the model from answering the transcript. If you pass your own prompt, nothing changes for you.
    • summary in the outcome and in aparte-compact-done is what is stored: the model’s summary, then, on the first compaction of a conversation, the first user message verbatim (clipped at 1 500 characters) under a fixed English line. The conversation’s first request survives every compaction.
    • A selector you pass receives the model’s view (the messages from the latest checkpoint on, less the turn being prepared), and the cut is moved so the first message kept is a user message: it may keep more than your selector asked for, or skip with 'nothing-to-drop' when no user message can carry the checkpoint.
    • The “notice” message is gone from new compactions: no compaction: true message is written, and the compactionSummaryTitle locale key is no longer read (old stored conversations keep the title inside their summary message, and are drawn as a separator).

    What is new:

    • Options auto, threshold (default 0.8) and contextWindow (a number, or a function, for a server that does not declare its window). threshold is also what a compaction aims below: with the default selector it leaves the history the model reads at about threshold − 0.2 of the window, less the fixed costs (system prompt, tools, the reply’s room, the summary’s slot), so one compaction buys several turns. That holds for a person’s request and an overflow retry too, and a selector of your own is left alone. The window a context gauge sends with its aparte-compact request is read as a last resort for one more release.
    • The controller is registered as the config’s compactor (config.setCompactor) and unregistered by dispose(), so the gauge’s Compact now, a separator’s Retry, the client’s pre-flight and its overflow retry all call it. compact() takes the compactor request ({ target, targetId, force, exclude, reason }) as well as a chat id or nothing, resolves the chat from the element it is given (a chat in a shadow root is found), and a scoped setup refuses a chat outside its scope. New settled(), beforeSend() and preview().
    • A second compaction updates the first: the request carries “Summary so far:” and only the messages since, and asks for an updated summary. The no-gain refusal compares against what is replaced: the earlier summary plus the newly summarised messages.
    • Two failures in a row for a chat stop the automatic attempts until something succeeds, a person asks (reason: 'manual' or 'retry', or a request without auto), or the conversation is switched; with automatic compaction on, an overflow is still compacted and resent. After a compaction that still leaves the context at the threshold, the after-turn trigger waits until a turn ends below it (a request that would not fit is still compacted before it goes out), and never the same range twice. A Restore suspends the automatic attempts for that chat until a person asks again.
    • createCompactionSelector takes an optional estimate(message). The selector setupCompaction builds uses core’s estimateWireTokens, so it prices what the wire sends (a reply’s hidden thinking costs nothing). The selector it returns takes an optional second argument, CompactionLimits (scale, reserve, landAt, ceiling), which the default selector is called with on each compaction: the heuristic’s undercount as the context measure calibrates it (the selection, the summary request’s size and the pre-send comparison all use it), room for the turn being prepared, the share of the window to land at, and, after an overflow, a ceiling below what failed.
    • Chats compact independently: a summary being written for one chat no longer skips another’s pre-send compaction or overflow retry. settled(), beforeSend() and an overflow wait for their own chat’s summary only; running and abort() cover every chat of the setup.
    • A Restore cancels a summary still being written for that chat, and a summary is refused when the checkpoint it updates was taken off meanwhile, so a Restore is not undone seconds later.
    • The compactor request’s signal (and beforeSend’s) cancels the chat’s summary: a Stop, or a programmatic stop(), no longer pays for a summary whose turn is gone, whether or not the setup listens on window. It is not counted as a failure.
    • The summary request alternates roles: adjacent turns of one role are merged and the ask joins a trailing user turn, so a strict chat template (Mistral, Gemma on llama.cpp or LM Studio) no longer refuses it. An inline <think>…</think> block (or one left open, or a closing tag alone) is stripped from the summary before it is stored.
    • CompactionOutcome is core’s AparteCompactorOutcome; CompactionController extends core’s AparteCompactor. Both gain messageId and count. The aparte-compact-start, -done and -error events carry the messageId the checkpoint sits on.

    The checkpoint is the plugin’s half of core’s contextSummary, historyFromCheckpoint and setCompactor; the separator that draws it, and the history that resumes from it, are core’s. @aparte/plugin-compaction

  • 37b82d9: Adds the French text for the nine keys of the context gauge’s new panel, such as “Prompt système”, “Libre” and “Compacter maintenant”. Nothing to change in your code.

    The keys are contextSystem, contextTools, contextSummary, contextMessages, contextOther, contextFree, contextTotal, contextReclaim and contextCompactNow. The panel is what the gauge opens: what fills the model’s window, and a button to compact the conversation now. @aparte/locale-fr

  • 5aa437f: Adds the French text for the ten keys of core’s new context separator, such as “12 messages résumés pour le modèle”, “Afficher le résumé” and “Restaurer”. Nothing to change in your code.

    The keys are checkpointCount, checkpointCountOne, checkpointLegacy, checkpointGroup, checkpointShow, checkpointHide, checkpointRestore, checkpointRestoreConfirm, checkpointPending and checkpointFailed. The separator is the line a chat draws above the first message the model still reads verbatim, once the messages before it were summarised. @aparte/locale-fr

  • eb3c0b1: Adds the French text for core’s new responseCutOff key: “Coupée à la limite de longueur du modèle.”

    It is the note under a reply the model stopped at its length limit. @aparte/locale-fr

Patch Changes

  • 7a8eac4: Fix three problems after a compaction or a new chat: the model no longer receives two user messages in a row, the conversation title is no longer taken from the summary, and the context gauge no longer keeps the previous conversation’s reading.

    Nothing to change in your code.

    • Two user turns in a row. After a compaction the summary is a user message and the kept turns usually start with one; after a failed turn its user message stays while its error reply is not sent. Either gave the model adjacent user messages, which strict chat templates (Gemma, Mistral, llama.cpp with --jinja, vLLM) reject, and every following turn failed. Send, retry, edit and the resume of a paused turn now merge adjacent user messages into one on the final array (texts joined by a blank line, image parts kept). System, assistant and tool messages are never merged.
    • The title. AparteConversationManager took the title from the first user message, which after a compaction is the summary notice (“Conversation summary The user requested…”). The three places that look for it skip a compaction: true message.
    • The gauge. <aparte-context> listened to turns and compactions only, so after “New chat” the empty conversation still showed the last reading. It now clears on aparte-reset-done and, for a stored conversation, restores the last reply’s usage on aparte-conversation-loaded. A restored reading never dispatches aparte-compact: auto-compact is now asked only at the end of a turn, so a gauge already in danger no longer asks again on a window or config change. @aparte/core
  • b140b2b: Under a wrapper, a message the parent replaces with a new object is drawn again, whether or not it is the last one. A list you hand in (a controlled messages prop, a v-model, bind:messages, an Angular input) that changes an earlier message, for example a store-backed compaction target writing a checkpoint onto the first kept message, now reaches that message’s bubble.

    Nothing to change in your code. An update that keeps the same message object is still skipped, so a list you rebuild with the same objects costs nothing more than before; a list that copies every message on every render makes every bubble redraw on every render.

    Why. AparteChatHost.syncBubbles drew every message once and then only the last one on each render, so a change written through the parent’s list to an earlier message stayed in the list and never reached the screen. It was invisible with the host’s own updateMessage, which pokes the bubble itself, and it left the separator of a compaction missing on every framework-rendered transcript whose list was the app’s. The host now remembers the object each bubble was drawn from and compares it by reference, which an immutable update always changes. The same one change serves React, Vue, Svelte and Angular, since they share the host. @aparte/core

  • 8280572: A model listed without a context window gets it after its first reply: the client reads the provider’s model list again, once per model, so an LM Studio or Ollama model loaded on demand gets a gauge and compaction.

    A local server that loads a model on its first request has no loaded window to report when the list is fetched at start-up, so the model came without one and the gauge and the compaction had nothing to measure against for the whole conversation. After a reply the model is loaded and the provider’s probe finds the window. refreshProviderModels() now announces a change (aparte-config-change) when the current model’s window changes, and only then; and an empty answer — what a provider returns when its fetch fails — keeps the list already held instead of wiping the selected model’s capabilities. @aparte/core

  • 37b82d9: estimateTokens now comes from @aparte/core. The plugin re-exports it under the same name, so nothing changes for you.

    The gauge and the plugin used to keep their own copy of the character heuristic; core’s measureContext reads one, and the plugin’s budget now reads that one too. The function and its result are the same: ceil(characters / 3.8). @aparte/plugin-compaction

  • 5c7407a: The published types keep null and undefined again: [conversationId]="id()" with a string | null signal now compiles under strictTemplates.

    Code written against the old, wrong types may need a null check: on the conversation service, activeConversation() is AparteConversation | null and activeId() is string | null; on the chat, bubbleDirective() can be undefined. The runtime always returned those.

    The package was built with ng-packagr’s built-in tsconfig, which sets no strict, so the declarations were emitted without strictNullChecks and every inferred null / undefined vanished: conversationId, typingText, config, containerClass, containerStyle, the config of both tool directives. The build now passes -c tsconfig.lib.json, which is ng-packagr’s own options plus strict: the emitted JavaScript is byte for byte the same, only the declarations change. The Angular SSR fixture binds those inputs to nullable signals under strictTemplates, so a build that drops them again fails CI. Reported in #124. @aparte/angular

Version-only bumps (no changes of their own): @aparte/runner-ag-ui, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.