* i18n: add settings reorganization keys
* feat(settings): add tab/section types and tab metadata
* feat(settings): add useSettingsContext guard hook
* feat(settings): add pure settings search filter with tests
* feat(settings): extract selectors and add control wrappers
* feat(settings): add setting registry, memory and billing controls, integrity test
* feat(settings): add Section and Advanced disclosure with test
* feat(settings): add content pane with tab and search views
* feat(settings): add sidebar and dialog shell with tests
* refactor(settings): wire new dialog and remove superseded containers
* fix(settings): restore speech external engine option, escape-to-clear search, results a11y
- SpeechControls.tsx: read sttExternal/ttsExternal from useGetCustomConfigSpeechQuery
instead of hardcoding false, so external engine options appear on qualifying deployments
- Sidebar: Escape clears search input when non-empty, stops propagation to avoid closing dialog
- Content: persistent aria-live="polite" wrapper covers both populated results and empty state
- context: useMemo on returned ctx object so Content's useMemo deps are referentially stable
- locales/README.md: update stale path from deleted General.tsx to Selectors.tsx
* refactor(settings): reorganize categories, remove advanced disclosure, add About
- Re-categorize settings into logical groups (username display -> Chat/Messages,
keep-screen-awake -> Accessibility, fork/prompts surfaced into Chat sections)
- Dissolve thin Personalization tab; move Memory into Data & Privacy
- Remove the Advanced collapsible; all settings always visible, destructive
actions grouped in an always-visible Danger zone
- Wire the new About tab into the registry-driven dialog
- Standardize spacing with bordered, evenly-divided section cards
- Use semantic text-text-* / border tokens so dark mode renders correctly
- Sync LangSelector language-loading indicator from dev
* feat(settings): move archived chats to the account menu
Add an Archived chats item to the account dropdown next to My Files,
opening the archived chats table in a modal. Removes it from the
settings dialog where it no longer fit the data/privacy grouping.
* feat(settings): polish About panel and use shared CopyButton
- Flatten the build-info into a single divided key/value list (drop the
redundant inner card now that it sits inside a section card)
- Replace the hand-rolled copy button with the shared animated CopyButton
- Shorten the copied label so it fits the button without clipping
* fix(settings): set primary text color on setting rows for dark mode
Leaf control labels rendered without a text color and fell back to the
browser default (black), making them invisible on the dark panel. Set
text-text-primary on the section and search-results row containers so
labels inherit a visible color, matching the old container behavior.
* fix(settings): use visible icon for dialog close button
The plain multiplication-sign close button had no text color and was
invisible on the dark panel. Replace it with the lucide X icon using
text-text-secondary/hover:text-text-primary so it shows in both themes.
* fix(nav): drop focus ring on account menu items, use hover background only
The account-settings popover drew a 2px ring around the active menu item.
Remove that override so items show only the standard hover background,
consistent with every other menu.
* fix(settings): replace native search clear with a real X button
The settings search used type=search, whose native WebKit clear control
rendered as a blue X. Switch to a text input and add a real lucide X
clear button styled text-text-secondary, shown only when there's a query.
* fix(speech): disable dependent dropdowns and switches when STT/TTS is off
Add a disabled prop to the shared Dropdown component, then gate the
speech engine/voice/language dropdowns and the automatic-playback switch
on their parent toggle (speechToText / textToSpeech), matching the
controls that already disabled correctly.
* feat(settings): mobile drill-in navigation for settings tabs
On small screens the horizontal scrolling tab row is replaced with a
full-width vertical list (with chevrons); tapping a tab drills into its
content with a Back header. Searching shows results full-width. Desktop
keeps the side-by-side sidebar + content layout unchanged.
* chore(settings): remove orphaned i18n keys, fix import order and review notes
- Drop the i18n keys left unused after the refactor (old Commands/Balance/
Personalization tab labels, the Speech simple/advanced labels, and the
former About section headings)
- Sort imports in the rebased files the lint-staged hook never touched
- Guard the language fallback against an empty navigator.languages
- Import the RefObject type instead of leaning on the React namespace
* feat(settings): searchable language dropdown
Add an opt-in searchable mode to the shared Dropdown (Ariakit Select +
Combobox) and use it for the language selector, which has 40+ options.
The trigger styling is unchanged so it stays consistent with the other
settings rows; only the popover gains a filter input.
Accessibility: the filtered listbox is labeled, the empty state is moved
out of the listbox and announced via an aria-live status region, and the
decorative selected-state checkmark is hidden from assistive tech.
* fix(settings): restore guards dropped in dialog refactor
- Fall back to the General tab when the active tab becomes hidden
(e.g. About when buildInfo is disabled) instead of rendering an
empty panel.
- Normalize a deprecated/invalid engineTTS (e.g. 'edge') back to
browser during speech init so read-aloud controls keep rendering.
- Hide the cloud browser voices toggle unless Browser TTS is active.
* test(e2e): match agent-creation toast exactly to avoid SR-announce collision
The agent builder spec asserted the creation toast with a non-exact
getByText, which also matched Radix Toast's transient role="status"
announce region ("Notification Successfully created ..."), causing a
strict-mode violation. Mirror the mcp spec by using { exact: true }.
* fix(settings): render the active panel as a tabpanel
Wrap the non-search settings body in Tabs.Content so the selected
panel gets role=tabpanel with Radix's id/aria-labelledby wiring,
resolving the aria-controls target on each tab trigger. Search
results stay a labeled live region (the tab list is hidden during
mobile search, so a tabpanel aria-labelledby would dangle).
* 🔖 fix: Decrement Bookmark Counts When Deleting Conversations
Deleting a bookmarked/tagged conversation removed the conversation but never decremented the affected ConversationTag counts, leaving stale bookmark counts in the UI.
- Add decrementTagCounts helper that atomically decrements tag counts (clamped at 0, deduped per conversation) in deleteConvos, covering single delete, clear-all, and account deletion.
- Invalidate the conversationTags query in the single-delete and clear-all client mutations so counts refetch.
- Add deleteConvos tag-count tests.
* 🔒 fix: Guard tag-count decrement on actual deletion and message-failure
Addresses Codex review findings:
- Guard the decrement on deleteConvoResult.deletedCount > 0 so a losing concurrent delete (double-click/two-tab) does not decrement counts for a conversation it did not actually remove.
- Move the count adjustment to run immediately after the conversation deletion, before message cleanup, so a deleteMessages failure cannot leave bookmark counts permanently stale.
- Add regression tests for both cases.
* 🔀 fix: Refresh project stats after message cleanup in deleteConvos
Addresses Codex finding: bundling refreshChatProjectStatsForUser into a Promise.all before deleteMessages let a stats-refresh error abort the function and orphan the deleted conversations' messages. Split the steps so the (best-effort) tag-count decrement still runs before message cleanup (counts reconciled even if messages fail), while project-stats refresh runs after, matching the original ordering.
* ✅ test: Add e2e coverage for bookmark counts on conversation delete
Two mock-harness specs for the deleteConvos bookmark-count behavior:
- Deleting the only conversation carrying a bookmark drops its count to 0.
- Deleting one of two conversations that share a bookmark leaves the count at 1.
Both assert the persisted server count via GET /api/tags after the real delete round-trip.
* chore: import order
* feat: add `convo.pinned`
We want to be able to pin convos (so users can easily find them), thus we
added a new field to the DB schema: `pinned`.
We also had to add an API method for pinning a convo. It's got thorough tests.
It's structured just like how /api/convos/archive works, only for pinning.
* feat: add 'pinned' section to conversation list
If there are any pinned conversations, they will appear above the normal
"chats" list, with a pinned icon next to them.
* feat: added pin/unpin to convo options
ConvoOptions now has a pin/unpin button which lets you change the
pin status of any given conversation.
* fix: adjust ellipsizing gradient on ConvoLink
Because it went across the whole ConvoLink, it would cover up any
children (i.e. icons) that appear after the title. However, the point
of the gradient is just to gradually make the title disappear, not
the icons.
This change places the gradient on the title only, so it achieves
the same ellipsizing effect without interfering with the display of
the child icons.
* Fixed import sorting
* 🪙 feat: Context-usage projection — data-provider + client wiring
Consumer side of the SDK-aligned context projection (agents
`projectAgentContextUsage`). Adds the `/api/endpoints/context-projection`
data-provider plumbing (endpoint, service, query key, `TContextProjectionRequest`)
and a `useContextProjectionQuery` gated to fire only when no fresh snapshot
covers the viewed branch.
Wires `useTokenUsage` precedence to: live snapshot → fresh persisted snapshot
(window matches the resolved one) → server projection → per-message estimate.
A model/window switch marks the baked snapshot stale (its `maxContextTokens`
no longer matches) and falls to the projection — closing the gauge's
window-switch (G1) and snapshot-less-branch (G2) gaps. Snapshot and projection
share the render-relevant fields, so they render uniformly.
Backend endpoint + agents version bump land in follow-up commits. Includes the
design spec (CONTEXT_PROJECTION_SPEC.md).
* 🪙 feat: Context-projection backend endpoint
POST /api/endpoints/context-projection → resolveContextProjection (packages/api):
reconstructs the viewed branch (parent-chain walk from messageId), resolves the
agent config (instructions/provider/model/maxContextTokens), reuses LibreChat's
stored per-message tokenCounts as the index map (no re-tokenizing), and calls
the agents SDK projectAgentContextUsage — no model call. Thin controller injects
db.getMessages/db.getAgent; route mirrors /token-config.
First cut targets message-windowing accuracy; tool-schema tokens are deferred to
a follow-up that reuses the full initializeAgent path.
* 🩹 fix: Codex review on context projection (G1 guard, IDOR, recount, summary)
- Guard `currentActive` against a stale window: a model/window switch on the
current branch left the live snapshot outranking the projection (G1 didn't
fire). Now defers to the projection unless streaming or the window matches.
- Scope branch lookups to the authenticated user (`getMessages` filter +
injected `userId`) — was loading any conversation by id (IDOR).
- Recount messages with no stored `tokenCount` via the tokenizer instead of
charging 0, so snapshot-less/imported histories don't under-report.
- Fall back (null) for already-summarized branches rather than projecting from
the full raw parent chain (the next call would send summary + tail); the
client's summary-baseline-aware estimate handles them until a follow-up
replays the summary boundary.
* 🩹 fix: Codex round 2 — drop agent load, summary marker, edit-invalidation
- Stop loading agent/model-spec config server-side (closes the agent-access
IDOR and the spec-prompt special-casing). Provider/model/window now come from
the client-resolved request (`limits.endpoint`/model — the agent's real
provider, not the `agents` endpoint, so the tokenizer is right). Agent/spec/
promptPrefix instructions are uniformly deferred to the full-fidelity follow-up.
- Detect summarized branches via the live path's `metadata.summaryUsedTokens`
marker (was the wrong `summaryTokenCount` field) and fall back to the
summary-aware estimate.
- Invalidate the projection query on in-place message edits via a branch
content `revision` in the cache key (the tail id is unchanged on edit).
Deferred (valid, not a regression): same-window endpoint/model switch keeps a
window-matched snapshot — needs endpoint/model persisted on the snapshot, which
lands with the fidelity follow-up. Smoke-tested: fits / prunes / summarized→null
/ no-window→null.
* 🛡️ fix: make context projection strictly additive (no-regression)
Revert the G1 window-match guard on the live/branch snapshot. When no explicit
maxContextTokens is set (the common default), the SDK's snapshot window is
reserve-derived (~0.9·(modelContext − maxOutputTokens)) while useTokenLimits
resolves the raw model context — so `snapshot.maxContextTokens === resolvedMax`
is false for the SAME model, and the guard would wrongly drop a valid
current-branch snapshot to projection/estimate post-stream (a regression in the
default case, per initialize.ts:1240-1243).
The projection now activates ONLY for snapshot-less branches (G2): the
precedence is live snapshot → persisted branch snapshot → projection → estimate,
where the first two are byte-for-byte the prior behavior and the projection just
slots ahead of the estimate. Window/model-switch (G1) detection needs the
snapshot to carry its model/window and defers to the fidelity follow-up.
* 🩹 fix: surface projections as estimates, not authoritative snapshots
A first-cut projection carries the SDK's windowing but omits instruction/tool
overhead, so rendering it as `isEstimate: false` showed a confident under-count
for snapshot-less branches. Mark projection-sourced views `isEstimate: true` +
`snapshotActive: false` (and drop the snapshot field) so they present as a
better estimate than sumBranch — improved used/window number, estimate framing,
no misleading granular breakdown with ~0 tools. Real snapshots stay
authoritative. (Codex round 3, projection.ts:139.)
* 🧹 chore: drop CONTEXT_PROJECTION_SPEC.md from the PR
* 🎨 style: fix import-sort order in projection.ts (CI sort-imports check)
* 🔧 chore: update @librechat/agents dependency to version 3.2.36 in package-lock.json and related package.json files
* chore: npm audit fix
* 🎨 style: fix import-sort order in data-service.ts (CI sort-imports check)
* 🩹 fix: drop dead calibrationRatio in projectionParams (tsc never error)
Inside the ternary, branchSnapshot is narrowed to null (the gate is
), so accessed a
property on (frontend typecheck failure). It was also dead — there is
never a snapshot to seed from in this branch — so just remove it.
* Revert "chore: npm audit fix"
This reverts commit 4cdb862d0c.
The installed @librechat/agents folds cache_creation + cache_read into
Anthropic usage_metadata.input_tokens (cache-inclusive), but
cacheSubsetProviders omitted anthropic, so splitUsage() took the additive
branch and billed cache tokens twice — at the full input rate and again at
the cache write/read rate. Verified live: a cache-read-heavy Sonnet call was
overcharged 10.7x.
Add Providers.ANTHROPIC to cacheSubsetProviders (single source of truth for
backend billing and client usage normalization). Bedrock stays additive: its
Converse path passes AWS inputTokens through unmodified. Update the Anthropic
regression tests to production-accurate cache-inclusive fixtures.
Fixes#13795
* 🎤 fix: Keep Microphone Icon Visible On Initial Chat Render
AudioRecorder returned null while the parent ChatForm's textAreaRef was still null on first paint, hiding the mic icon until an unrelated re-render. Render the button disabled instead so the icon is always present.
Closes#13786
* 🎤 refactor: Drop Unused textAreaRef Dependency From AudioRecorder
Per Codex review: deriving the button's disabled state from textAreaRef.current could leave the mic permanently disabled until an unrelated re-render, since assigning a ref does not trigger one. The handlers never read the ref, so remove the dependency entirely along with the now-unused prop.
* 🪙 fix: Reconcile Context Gauge to Actual Provider Tokens
The context gauge could read several× too high (e.g. 213K when the real prompt
was 56K) and stay there across reloads. Root cause: the SDK's calibrationRatio is
`cumulativeProviderReported / cumulativeRawSent`, but a provider's server-side
web search injects large fetched content into the prompt that the SDK never sent
or counted — pinning the ratio at its cap (5) and multiplying every later message
estimate, including post-summary ones. The gauge rendered (and persisted) that
inflated estimate, never the provider's actual token count.
Fix: reconcile the snapshot to the call's ACTUAL prompt tokens (input + cache),
which already arrive in on_token_usage. Only messageTokens is calibration-scaled
(instructions/summary are raw tiktoken), so keep those and set messageTokens to
the remainder, recomputing free space. Shared `promptTokensFromUsage` +
`reconcileContextUsage` in data-provider; applied server-side in
buildPersistedContextUsage (reload-stable) and client-side in useUsageHandler on
each primary usage (corrects at turn-end, no follow-up needed). Also drop the
summary double-count from the Breakdown Messages row.
Deferred (separate agents PR): the SDK over-calibration also fires summarization
prematurely; fixing it needs decoupling real-content estimation from server-side
injection headroom without weakening pruning-overflow safety.
* 🪙 fix: Harden Token Reconciliation for Provider-less + Resume Paths
Codex review on the reconciliation:
- promptTokensFromUsage: when the provider is absent (custom/OpenAI-compatible
payloads), fall back to the same magnitude heuristic normalizeUsageUnits uses
(cache ≤ input ⇒ already included) so cached events aren't re-inflated.
- Resume: backfillUsage restores a primary call's usage without replaying a live
on_token_usage (Redis mode), so the live reconcile never ran and a reconnected
session stayed on the inflated estimate. New reconcileBackfill reconciles the
restored snapshot from the final primary call after contextHandler installs it.
* 🪙 fix: Reconcile Resume Snapshot Server-Side, Not via Backfill
Codex: the client reconcileBackfill scanned the resumed run's collectedUsage and
applied the final primary to the latest snapshot — but on a mid-call resume that
usage belongs to an EARLIER call, corrupting the restored gauge.
Move the resume reconciliation server-side: GenerationJobManager.persistTokenUsage
reconciles the stored contextUsage to a primary usage's actual prompt tokens as it
arrives. That usage is the post-invoke truth for the call the latest stored
snapshot precedes (no snapshot is captured between a call's pre-invoke dispatch
and its usage), so it's correct by construction and run-matched. A mid-call resume
(no usage yet) keeps the raw snapshot instead of mis-applying an earlier call's
tokens; it reconciles once the call completes. Removed client reconcileBackfill;
the live-path reconcile (non-resume) stays.
* 🪙 fix: Guard Reconciliation Against Replays and Snapshot Races
Two Codex concurrency findings on the reconciliation:
- Client: reconcile only on a NEWLY folded primary usage. A replayed duplicate
(folded=false on resume) can be an earlier tool-loop call sharing the run id,
which would overwrite the latest snapshot with an earlier, smaller prompt. Moved
the reconcile after the folded guard.
- Server: serialize the context-usage write through the same per-stream queue as
the token-usage write. persistTokenUsage reconciles the stored snapshot
(read-modify-write); an unserialized trackContextUsage could store a newer
snapshot between the read and write — or a stale reconciled write could land
after a newer snapshot — clobbering the newer run's gauge when calls interleave.
FIFO keeps each call's snapshot ahead of its own usage and behind the next.
* chore: import order in GenerationJobManager.ts
* 🪙 fix: Persist Context Snapshot + Summary Marker After Summarization
The post-summarization context is correctly compacted by the SDK, but the
breakdown wasn't reliably reaching the client, leaving the gauge on the
whole-history estimate (stuck at 100% forever once a conversation compacts).
Two server changes in buildResponseMetadata:
- Snapshot guard: persist the breakdown when a PRIMARY usage event follows the
latest snapshot (tracked via contextUsageSink.latestUsageIndex, recorded in
the on_context_usage handler) instead of a brittle snapshot-vs-primary count.
A summarization detour adds an extra snapshot whose only following usage is
tagged 'summarization', which the count guard could miscount and drop.
- Summary marker: whenever a turn compacts (summaryTokens > 0), persist a
lightweight metadata.summaryUsedTokens (the pre-invoke compacted context size)
UNCONDITIONALLY — so even when the full snapshot can't be saved (interrupted
final call) or never reaches the client, the per-message estimate has a signal
to cap the discarded history.
Tests: client.contextMetadata.spec (guard + marker, incl. marker-survives-drop)
and a real-pipeline summarization integration test.
* 🪙 fix: Cap the Context Estimate at the Summary Marker
When the gauge falls back to the per-message estimate (no usable snapshot on the
branch), sumBranch summed the ENTIRE branch history — after a summarization that
discarded most of it, this over-counts and pins the gauge at 100% in perpetuity.
sumBranch now stops at the deepest summarized response (metadata.summaryUsedTokens)
and records it as summaryBaseline; the walk counts only post-summary messages,
and useTokenUsage adds the baseline. So the estimate reflects the compacted
context (summary + recent turns), not the discarded history. USD/default
behavior unchanged when no marker is present.
Test: sumBranch caps a huge pre-summary history at the compacted baseline.
* 🪙 fix: Address Codex Review on the Summarization Marker
- Branch cost/usage is no longer truncated at the summary marker — sumBranch
caps only the CONTEXT-window count there and keeps accumulating provider
usage/cost to the root (cumulative spend isn't discarded by compaction).
- findBranchSnapshotAnchor stops at a summarized response with no snapshot of its
own, so it can't recover a stale PRE-summary snapshot and show discarded
history; the summary-baseline estimate is used instead.
- Abort path: buildAbortedResponseMetadata now persists the summaryUsedTokens
marker (pre-invoke, no completedOutputTokens ambiguity, so safe on abort) so a
STOPPED summarized turn isn't re-summed on reload.
- Marker baseline fallback now includes summaryTokens (a separate breakdown
field) so it doesn't under-report the compacted size. DRY'd into a shared
computeSummaryUsedTokens used by the completion and abort paths.
- Estimate popover surfaces the summary baseline as a row so the displayed rows
reconcile with the header total.
Tests: sumBranch cost-not-truncated + anchor-stops-at-marker (client);
computeSummaryUsedTokens fallback + abort marker (packages/api).
* 🪙 fix: Attribute Persisted Context Usage to the Snapshot Run
Match the post-snapshot primary usage to the latest snapshot's runId before
persisting metadata.contextUsage. Parallel/direct runs interleave snapshots and
usage (A snapshot → B snapshot → A usage → B no-usage); the prior index-only
guard persisted B's snapshot with A's output. finalCallOutputTokens now filters
completedOutputTokens to the snapshot's run. Untagged events (older lib/resume)
match any run for back-compat.
* 🪙 fix: Harden Summary Marker Against Tool-Loops, Stale Anchors, and Emit Races
Codex round on the summarization marker:
- Avoid double-counting earlier tool-loop outputs in the summary marker: those
outputs sit in BOTH the latest snapshot's pre-invoke baseline AND the response
message's tokenCount the client estimate adds on top. computeSummaryUsedTokens
now subtracts the run's prior primary outputs (priorRunOutputTokens) — the live
path bounds them by the snapshot's usage index, the abort path by all primaries
(an interrupted final call emits none). Single-call turns subtract 0.
- Stop treating pre-summary anchors as active: sumBranch no longer sets
containsAnchor once the context is capped at a summary marker, so a stale
pre-summary snapshot can't override the summary-baseline estimate.
- Capture latestUsageIndex BEFORE awaiting emitEvent: a yield (resumable SSE /
Redis) during parallel runs could let this call's own usage advance the index
past the event that proves the snapshot completed, dropping a valid breakdown.
* 🪙 fix: Subtract Summarization Output from the Summary Marker
recordCollectedUsage folds the summarization call's completion into the response
message's tokenCount, while the generated summary is also in the snapshot baseline
as summaryTokens. The client estimate (summaryBaseline + responseTokenCount) thus
counted the summary twice — inflating the gauge after compaction even on a
single-call turn whenever the full snapshot is unavailable. priorRunOutputTokens
now also counts summarization-tagged output (still excluding subagent/sequential,
which recordCollectedUsage keeps out of the reported total), so the marker
subtracts it. Updated unit + guard tests.
* 🪙 fix: Refine Marker Subtraction for Summarization RunId and Abort Boundary
Two Codex follow-ups on the marker-subtraction logic:
- Subtract summarization output regardless of runId: the summarize detour is its
own model-end call that may carry a distinct runId, but its output still lands
in this response's tokenCount AND the snapshot baseline (summaryTokens). It is
now counted unconditionally (still within the response's own usageEmitSink),
while primaries keep the parallel-run runId filter.
- Don't subtract primaries on the abort path: the job stores no snapshot/usage
boundary, so a primary that completed AFTER the latest snapshot is NOT in the
baseline; subtracting it would cancel real output and under-report. priorRun-
OutputTokens gains an includePrimary flag (false for abort) — abort subtracts
only the always-pre-snapshot summarization output.
* 🪙 fix: Run-Scope Summary Subtraction and Stop Subtracting on Abort
Two Codex follow-ups, resolved by reverting the round-4 detour:
- Run-scope the summarization subtraction: the summarize detour inherits the
graph run id (traceConfig spreads config.metadata.run_id), so its usage shares
the answer snapshot's runId — it is NOT a distinct run. priorRunOutputTokens now
filters summarization by runId like primaries, so a parallel sibling run's
summary (different runId, in the sibling's baseline) is no longer subtracted from
this branch's marker. Drops the includePrimary flag added last round.
- Stop subtracting on the abort path: abort tokenCount is countTokens(text)
(abortMiddleware) or absent (agents route) — it does not fold in summarization or
earlier-call output the way recordCollectedUsage does, so the marker must keep
the full baseline. buildAbortedResponseMetadata now subtracts nothing.
* 🔢 fix: Prevent "approximately" tildes from rendering as markdown subscript
`remark-supersub` splits text nodes on every `~`; an even number of tildes
wraps the in-between text in `<sub>`. "Approximately" usage like
`~50% ... ~10%` pairs up and subscripts everything between the two tildes.
A backslash escape cannot fix this: micromark resolves `\~` back to a bare
`~` before supersub runs. Instead, `preprocessTilde` swaps approximation
tildes (a `~` prefixing a number, not attached to a word) for the Unicode
tilde operator `∼` (U+223C), which renders as a tilde but is not split by
supersub. Mirrors `preprocessLaTeX`: early return, single regex pass,
code-region skipping. Genuine subscripts (`H~2~O`, `a ~2~ b`), strikethrough,
escaped tildes, and home paths are preserved.
* 🔢 fix: Harden tilde preprocessing — escaped tildes, URLs, math, MarkdownLite
Addresses Codex review findings:
- Convert escaped approximation tildes too (`\~50%`): markdown decodes `\~`
to `~` before supersub, so the escape still pairs into a subscript.
- Anchor matches to a prose boundary (start / whitespace / open bracket) and
exclude `\(`/`\[`/`\{`, so URL path tildes (`/~50`) and math delimiters
(`$~10$`, `$$~10$$`, `\(~10\)`) are left untouched.
- Apply preprocessTilde in MarkdownLite (user messages + search/subagent/
code-analysis displays), which also enables remark-supersub.
* 🔧 refactor: Neutralize approximation tildes via remark plugin, not raw-text
Replaces the string-level preprocessTilde with `remarkApproxTilde`, a remark
plugin that rewrites "approximately" tildes (`~50%` → `∼`) on parsed text nodes
before remark-supersub runs.
Because it operates on the AST, code spans, fenced code blocks (backtick *and*
~~~), inline code with any backtick count, link destinations, and math spans are
structurally excluded — none are `text` nodes — resolving every raw-text edge
case Codex flagged without region-scanning heuristics. Escaped `\~` is covered
for free (markdown decodes it before the plugin runs).
- New client/src/utils/tilde.ts: `normalizeApproxTildes` (pure, per-text-node)
+ `remarkApproxTilde` plugin.
- Wired into both renderers (markdownConfig + MarkdownLite), before supersub.
- latex.ts / Markdown.tsx reverted to original; preprocessTilde removed.
- tilde.spec.ts: pure-function cases + a hand-built-tree test proving code,
math, and link URLs are untouched while text (incl. link text) is converted.
* 🔧 fix: Cover quoted approximations and the markdown error-boundary fallback
- Broaden the boundary to any non-word, non-tilde char (`(?<![\w~])`), which
now includes quotes — `"~50%" ... "~10%"` was still subscripting because `"`
was not a recognized prose boundary. Safe to widen because the plugin runs on
text nodes, so code / links / math / URLs are already excluded structurally
(the earlier allowlist only existed to dodge URL paths in raw text).
- Add remarkApproxTilde to MarkdownErrorBoundary's fallback remark pipeline so
the fix holds when a render error falls back to the minimal renderer.
* 🔧 fix: Preserve autolink URL labels when normalizing tildes
A GFM autolink renders the URL as its own label (a text node equal to the href),
so the broadened boundary was rewriting `~50` inside a displayed URL to `∼50`
even though the href stayed correct. Skip text nodes that are an autolink's label
(value matches the destination, allowing for the implied scheme on www/email
links), so the visible URL is preserved verbatim. Regular link labels (prose)
are still normalized.
Note: a single URL containing two `~<digit>` segments is still subscripted by
remark-supersub itself — that's pre-existing behavior (reproduces with no plugin)
and out of scope here.
* 🔧 refactor: Drop unist-util-visit runtime dep from tilde plugin
Replace the `unist-util-visit` import with a small self-contained recursive walk
over text nodes (tracking the parent for the autolink-label check). This removes
reliance on a transitively-hoisted runtime package — addressing the dependency-
hygiene concern without adding a dependency or churning the lockfile. The
type-only `unist` import remains (erased at build, no runtime resolution).
Behavior is unchanged; verified against nested emphasis and list/paragraph trees.
The streaming favicon stack was gated on `source.processed === true`, but the
agents scrape pipeline marks sources processed only after a `Promise.all`
barrier (the slowest page fetch). Raw SERP results — with everything needed to
render favicons — arrive in the first attachment well before that, so the UI sat
on "Searching the web" with no favicons for the entire scrape window.
Render favicons from the raw sources as soon as they land instead of waiting for
`processed`, filling the dead window and moving the label to "Processing
results" immediately. Completed-state, turn scoping, and finalizing behavior are
unchanged.
* 🪙 feat: Default Context Cost On + Configurable Display Currency
Flip interface.contextCost to default-on (schema default true, resolved per-field
in loadDefaultInterface so it applies unless an admin explicitly sets false).
Add interface.currency { code, rate }: an ISO-4217 code and a static USD→local
multiplier so non-USD communities (EUR, JPY, CNY, BRL, ZAR, …) can show costs in
their currency. Inner fields are required (no nested defaults) to keep zod
input/output identical; loadDefaultInterface passes it through. Display-only —
model prices stay USD server-side.
* 🪙 feat: Currency-Aware Context Cost Formatting
formatCost(usd, currency?) applies the static rate (usd × rate) and formats via
a cached Intl.NumberFormat keyed by currency code — locale-correct symbol and
per-currency decimals, falling back to USD on a malformed code. The USD default
(code USD, rate 1) is byte-identical to the prior output.
* 💄 feat: Gauge Hover Snapshot, Click-to-Open Breakdown, Hide Until Data
Replace the hover-only HoverCard with: a compact hover snapshot tooltip
("Context 341.7k / 1.0M (34%)" + cost when enabled) via the existing Tooltip
primitive, and a click-opened Ariakit popover for the full breakdown that
dismisses on outside-click/Escape/blur. Gate visibility on usedTokens > 0 so a
fresh, message-less chat shows nothing, with an animate-in fade as the first
tokens land. Thread the display currency into the breakdown + snapshot.
* 🧪 test: Gauge Interaction + Visibility E2E
Switch the breakdown specs from hover to click, and add a test that the gauge is
absent on a new chat, surfaces the snapshot tooltip on hover, opens the breakdown
on click, and dismisses on Escape and outside-click.
* 🪙 fix: Harden Currency Resolution + Layer Breakdown Above Tooltip
Address Codex review on the currency display:
- Unsupported currency code now falls back to USD AND rate 1, so a typo like
{ code: 'EURO', rate: 0.92 } no longer shows a converted amount under a $
symbol (was $9.20 for a $10 cost; now $10.00).
- A non-finite/negative rate (e.g. a partial admin override that set code before
rate) falls back to rate 1, so a cost never renders as NaN.
- Fraction digits derive from the currency's own defaults, so zero-decimal
currencies (JPY) render ¥5, not ¥5.00, and extra sub-unit precision applies
only to currencies that have minor units. USD output is unchanged.
- Raise the click breakdown popover to z-[200] so it always sits above the
z-150 hover tooltip when both briefly coexist.
* 🪙 fix: Validate ISO-4217 Codes + Derive Tiny Threshold from Minor Unit
Address Codex review on currency formatting:
- Intl.NumberFormat accepts any well-formed 3-letter code (EUU, RMB) without
throwing, so the previous construct-based check missed typos/non-ISO codes and
applied the rate under a bogus label. Validate against Intl.supportedValuesOf
('currency') (the ISO-4217 set); unsupported codes fall back to USD + rate 1.
Codes are normalized to upper-case; graceful fallback if the runtime lacks
supportedValuesOf.
- The tiny-amount threshold now derives from the currency's minor unit
(10^-fractionDigits): 0.01 for 2-decimal, 0.001 for 3-decimal (KWD/BHD/JOD),
1 for zero-decimal — instead of a hard-coded 0.01. Sub-unit precision trims to
each currency's own scale. USD output unchanged.
* 💾 feat: Persist Context Breakdown & Branch/Total Usage Cost
Persist the granular context breakdown and per-response usage/cost on the
response message metadata, and re-derive branch + total usage/cost from a
per-message index so the popover survives reloads and is branch-aware live.
- Add aggregateEmittedUsage + buildPersistedContextUsage helpers in
packages/api; capture the latest visible snapshot and every emitted
on_token_usage payload via contextUsageSink/usageEmitSink.
- Attach metadata.contextUsage (Part A) and metadata.usage (Part B) on the
agents response message in sendCompletion.
- Carry per-message usage on the token index; add sumTotalUsage/setEntryUsage
and branch-scoped usage on sumBranch.
- Repurpose the session accumulator into a single in-flight pending holder;
flush it into the index at finalize; hydrate breakdowns on load.
- Render branch cost with a conditional all-branches total in the breakdown.
* 🧹 chore: Remove orphaned com_ui_session_cost i18n key
* 🩹 fix: Address Codex review — normalize usage server-side, fix reload deltas
- Persist per-event-normalized display units in metadata.usage (TResponseUsage)
so reloaded mixed-provider turns match the live session; client reads them
directly instead of re-normalizing with a single stamped provider (P2).
- Persist completedOutputTokens (final call output) on metadata.contextUsage so
a reloaded multi-call turn adds the post-snapshot delta, not the full
tokenCount the snapshot already counts (P2).
- buildIndex preserves a prior entry's immutable usage when a rebuilt cache
message lacks metadata.usage, so a mid-session rebuild (regenerate) keeps a
sibling branch's flushed cost (fixes the e2e regenerate failure).
- Track costKnown so turns saved with contextCost off don't render $0.00 when
cost display is later enabled (P3).
- Use an epsilon for the all-branches cost comparison to avoid a spurious total
row from float summation order (P3).
- Update unit/integration/e2e tests for the new shapes; regenerate e2e asserts
the all-branches total after reload (deterministic via persisted metadata).
* 🩹 fix: Address Codex round 2 — pending leak, cost coverage, reload delta
- Clear the in-flight pending usage on terminal abort/error (resetLive), so a
stopped generation's tokens no longer merge into the next response (P2).
- costKnown now means COMPLETE coverage (ANDed): a branch mixing cost-bearing
and cost-less turns is flagged incomplete and the cost row is hidden rather
than rendering an under-reported total (P2).
- Drop the tokenCount fallback for completedOutputTokens on reload: only the
persisted post-snapshot delta is used, so a multi-call turn whose provider
emitted no usage_metadata no longer double-counts earlier output (P2).
- Update tokens.spec for AND coverage semantics + incomplete-cost case.
* 🩹 fix: Address Codex round 3 — no-usage snapshots, total coverage, provider-less cache
- Skip persisting metadata.contextUsage when the response emitted no primary
usage event: without a known post-snapshot output the granular gauge would
undercount the reply on reload, so fall back to the coarse per-message
estimate instead (P2).
- Gate the all-branches cost row on totalUsage.costKnown so an incomplete total
(a sibling saved without cost) never renders an under-reported figure (P2).
- aggregateEmittedUsage/finalCallOutputTokens now normalize per-event with the
client's magnitude fallback (normalizeEventUnits) instead of billing
splitUsage, so provider-less cached events match live on reload (P2).
- Add backend test for the provider-less cached case.
* 🩹 fix: Address Codex round 4 — abort attribution, complete cost coverage
- aggregateEmittedUsage persists cost only when EVERY call was priced; a partial
pricing failure now omits cost so the client treats coverage as unknown rather
than reading an under-reported sum as authoritative (P2).
- finalizeUsage flushes pending into the response entry only when events were
folded this session (eventCount > 0), so a late/second resumable subscriber
carrying persisted metadata.usage keeps it instead of being overwritten with
an empty pending record (P2).
- On user stop, attribute the in-flight pending usage to the partial response
(new attributePending handler) instead of discarding it in resetLive — the
stopped reply's billed tokens are kept and still can't leak into the next
response; resetLive's discard remains for the error path (P2).
* 🐛 fix: Persist branch cost across branch switches via sticky usage history
Branch cost vanished on switching to a sibling branch (until a new turn) — the
cost analog of the granularity bug. buildIndex rebuilds the token index from the
messages cache; a sibling generated this session whose cache message lacks
metadata.usage (and is transiently dropped from the cache during regenerate)
lost its live-flushed usage, so sumBranch found none and the cost row hid.
Fix: a sticky per-response usage map (conversationId → messageId → usage),
written by setEntryUsage and never rebuilt from the cache — the usage counterpart
of snapshotsByAnchorFamily for the breakdown. buildIndex/upsertEntries restore an
entry's usage from it when the message carries none; cleared on convo switch and
migrated with the index. Add unit coverage for the drop-then-readd regression and
an e2e assertion that branch cost survives a branch switch.
* 🐛 fix: Re-index on branch switch so branch cost survives the switch
The sticky usage history alone didn't fix the reported branch-switch cost drop:
on a branch switch no cache `updated` event fires, so the index subscriber never
re-ran, and the post-regenerate rebuild was skipped while `isSubmitting` was
still true — leaving the index stale and missing the now-viewed branch's
response entirely (sticky can only restore entries present in a rebuild).
Re-index from the messages cache on every tail change (created/finalize AND
branch switch), not just while submitting. The cache holds the full message set
at switch time, so the viewed branch's response is re-added and its usage
restored from metadata.usage or the sticky history → sumBranch finds it and the
branch cost renders. Verified locally: the branch-switch e2e now passes (the
cost section shows both the branch row and the all-branches total). Also fixed
that e2e assertion to target a single cost value (strict-mode safe).
* 🩹 fix: Handle stopped-stream usage — reset pending + persist abort metadata
Codex round (stop/abort edges):
- Resumable explicit-stop (intentional SSE close) reset UI state but never
cleared pendingUsageFamily, so usage folded before the stop leaked into the
next response in the conversation. Discard pending on intentional close
(resetLive); a resume re-folds via backfillUsage, so nothing is lost.
- The abort save path (abortMiddleware) persisted the stopped response without
metadata.usage/contextUsage, so its cost + breakdown vanished on reload.
Rebuild both from the job's persisted tokenUsage (emitted payloads incl. cost)
and contextUsage snapshot — parity with the normal sendCompletion path;
breakdown gated on a primary usage event like buildResponseMetadata.
Deferred (per scope decision): mid-stream branch-switch transiently shows the
streaming branch's pending on the viewed sibling (cosmetic, until finalize).
* 🩹 fix: Persist abort metadata on the real agents route + tighten snapshot gate
Codex round (corrects last round's wrong-path fixes):
- Stopped AGENTS responses are saved by routes/agents/index.js (/chat/abort),
not abortMiddleware — so last round's metadata fix never ran for them. Moved
the rollup/snapshot builder into packages/api as buildAbortedResponseMetadata
(shared, unit-tested) and applied it in BOTH abort save paths, so a stopped
agent reply keeps its cost + breakdown on reload.
- Persist the breakdown only when the FINAL visible call emitted usage: track a
per-response snapshot count and require primaryUsageCount >= snapshotCount.
Previously any earlier primary usage event passed the gate, so a multi-call
turn whose final call emitted no usage_metadata used an earlier call's output
as completedOutputTokens (already counted by the latest snapshot) → reload
over-reported. Now it falls back to the coarse estimate.
Resumable stop pending-reset (prior round, 3cde6fe035) already flows through
clearAllSubmissions → SSE close → the intentional-close handler's resetLive.
Deferred per scope: mid-stream branch-switch pending attribution (tracked).
* 🩹 fix: Abort breakdown over-count + resume re-fold after pending discard
Codex round (on the re-applied abort/snapshot work):
- buildAbortedResponseMetadata now persists ONLY the usage/cost rollup, not the
context breakdown. The abort path can't tell whether the final call emitted
usage (the job stores only the latest snapshot, not a count), so persisting
the breakdown risked reusing an earlier call's output as completedOutputTokens
(already in the snapshot) → reload over-count. Stopped/incomplete responses
now fall back to the coarse gauge estimate, which is safe and apt.
- resetLive now also forgets the conversation's folded usage-event identities
(clearUsageFolded). Discarding pending on a terminal/intentional close left
the folded keys set, so a later resume's backfillUsage saw the persisted
events as duplicates and never rebuilt pending — leaving the response's usage
missing until a full reload. Clearing them lets the resume re-fold.
Apply the MessageTimestamp transform to all remaining hover-reveal
controls in the message UI: replace the md: breakpoint proxy with a
(hover: hover) media query so action buttons stay visible on touch and
other non-hover devices (e.g. tablets wider than md), while still
hiding until row hover/focus where a pointer supports it.
The group-hover:visible reveal trio keeps no hidden base state, so it
only drops the md: prefixes; the actual hide-until-hover mechanism is
the opacity variant, which stays focusable and in the accessibility
tree while hidden.
* ✨ feat: Show Message Timestamps on Hover
Reveal a message's time inline next to the author name on hover. Recent messages (under 24h) show a relative time ("10 minutes ago") with the absolute date on hover; older messages show the absolute date directly.
A shared MessageTimestamp component is used by both MessageRender and ContentRender, with createdAt added to their memo comparators so the timestamp appears once it's available.
Resolves#5199
* 🎨 fix: Gate message timestamp reveal on hover capability, not width
Use a (hover: hover) media query instead of the md: breakpoint so the timestamp stays visible on touch and other non-hover devices (e.g. tablets wider than md), while still revealing on hover/focus where a pointer supports it.
* 🎨 fix: Show message timestamps across all renderers and keep them live
Extend the hover timestamp to the Assistants (MessageParts), shared-link, search, and parallel/multi-response renderers so every prompt and response shows when it was sent. The parent message's createdAt is threaded down to parallel column headers (SiblingHeader).
Add a shared, ref-counted minute ticker (useTimeTick) so relative labels like "2 minutes ago" stay current while a conversation is left open instead of freezing at first render.
* 🌿 fix: Preserve Viewed Branch on Sibling-Tree Churn
Regenerating a message could snap the view to an unrelated newest branch.
MultiMessage reset siblingIdx to 0 (newest) on any messagesTree.length
change, but getRegenerateSubmissionMessages slices the flat message array
during a regenerate — the streaming handlers render a tree missing unrelated
sibling branches, then finalHandler restores the full set. That 2→1→2
child-count swing snapped unrelated forks to their newest sibling, so
regenerating the latest response on an older branch jumped to a previously
regenerated branch.
Replace the indiscriminate reset with per-fork branch memory: a 'seen' set
distinguishes a genuinely new sibling (submission/regeneration/edit here —
focus it) from one transiently dropped and restored (preserve the user's
branch). Decision extracted as the pure, unit-tested resolveSiblingSelection.
- client/src/utils/messages.ts: resolveSiblingSelection + tests
- MultiMessage: seen/selectedId refs, structural id-signature effect
- e2e: regenerate-latest-on-older-branch keeps the viewed branch (fails on
the old reset, passes now)
* 🧪 test: Long-Thread Branch Preservation E2E
Add the user-reported scenario: in a multi-turn thread, regenerate an
earlier response (forking a root branch), switch back to the original, then
regenerate a later response on it — the original branch must stay intact.
Uses labeled prompts so each turn's unique reply is a reliable settle signal.
Verified it fails on the original MultiMessage and passes with the fix.
* 🎨 style: Fix import order in MultiMessage (react before recoil)
* 🌿 fix: Keep Unrelated Branches in Regenerate Optimistic Render
Regenerating a message used a flat `messages.slice(0, targetIndex)` for the
optimistic render, which also drops unrelated sibling branches that merely sit
later in the flat array. Mid-regenerate the thread briefly collapsed to a short
branch (visible flash) and the scroll jumped to the shrunken content and didn't
recover — the same flat-array root cause as the branch-reset bug.
Remove only the regenerated response and its descendants, keeping unrelated
branches. The thread (and scroll) stay put through the regenerate. This array
is render-only — the server regenerates from parentMessageId and createPayload
doesn't include it — so summing by subtree never affects the request.
Verified via a small-viewport scroll trace: old collapses 903->295px / 8->2
renders mid-stream; fixed stays 903px / 8 renders, scroll held at bottom.
Unit test covers the keep-unrelated-branches behavior (fails on the old slice).
* 🌿 fix: Let an Explicit Branch Selection Survive Streaming ID Churn
resolveSiblingSelection focused any unseen sibling id before checking the
committed selection. When an in-flight response's id is replaced mid-stream
(placeholder → server/run id, e.g. useStepHandler re-keys to runId) after the
user switched to a different sibling, that swap looked like a brand-new sibling
and stole focus back to the streaming branch.
Reorder: the committed selection wins while still present; only focus a fresh
sibling when the selection is gone (regenerated away, or its own placeholder id
was just replaced — that's how a regen/edit still takes focus, since the slice
removes the old response). Added unit tests for both churn directions.
* 🌿 fix: Only Focus a New Sibling When the Fork Actually Grew
The previous churn fix (selection-wins-first) was too aggressive: a genuinely
new sibling ADDED while the prior selection is still present — e.g. a follow-up
re-parented as a sibling after a generation-start failure — was no longer
focused, so its reply never rendered (broke message-tree generation-start
recovery e2e).
Gate new-sibling focus on actual growth: resolveSiblingSelection now takes
prevCount and only focuses a never-seen id when ids.length > prevCount. A
same-count placeholder→server id swap (churn) or a restored already-seen
sibling is not growth, so the committed selection still wins there. Covers
follow-up/new-branch focus, churn steal-prevention, and self-churn follow.
message-tree + chat e2e: 17 passed (incl. the recovered generation-start test).
* 🌿 refactor: Drop MultiMessage Branch-Memory in Favor of the Slice Fix
The regenerate-slice fix (keep unrelated branches in the optimistic render) is
the true root cause: with no spurious tree collapse, the original
setSiblingIdx(0)-on-length-change never misfires, so the branch-reset is fixed
without per-fork memory. The earlier MultiMessage rewrite (seen/selectedId/
prevCount + resolveSiblingSelection) was a symptom patch added before the root
cause was found, and its per-instance memory generated two edge-case findings
(placeholder→server id churn; divergence from external siblingIdx writes like
resume restore).
Revert MultiMessage to the simple upstream version and remove
resolveSiblingSelection (+ its tests). The slice fix + the existing branch e2e
(chat.spec: switch-back, regenerate-latest, long-thread) cover the behavior;
all 17 chat + message-tree branch specs pass with this version.
* 🌿 fix: Focus the Regenerated Response When Its Fork Count Is Unchanged
When a parent already has multiple sibling responses and the user switches to a
non-latest one and regenerates it, the optimistic slice drops the target but
keeps the other siblings, so the child count is unchanged. MultiMessage only
resets the (reversed) sibling index on a length change, so the stale index kept
pointing at the kept sibling and the regenerating response stayed hidden until
the server restored the dropped sibling at finalize (count bump → reset).
Explicitly focus the newest sibling (reversed index 0 = the appended response)
of the regenerated fork in createdHandler. Position-based, fires only on the
regenerate action, so it doesn't reintroduce the placeholder→server id churn or
external-write fragility that a per-render selection memory had.
E2E: new during-stream test (slow+counted reply marker) asserting the
regenerating response is visible before finalize; negatively verified
(fails without the focus call, passes with it).
* 🌿 fix: Eliminate Pre-Created Flash by Focusing at the Optimistic Render
The createdHandler focus removed the until-finalize bug, but a brief flash
remained between clicking regenerate and the `created` event: useChatFunctions
renders the optimistic placeholder first, and that render has the same
unchanged-count problem, so the kept sibling showed until createdHandler fired.
Extract the focus into a shared useFocusRegeneratedResponse hook and apply it at
the optimistic render too (useChatFunctions) and on `created`
(useEventHandlers). The placeholder is now focused from the first frame.
E2E: gated pre-created test — holds the SSE stream GET (the chat POST returns a
stream id; the stream is a separate GET) so `created` cannot arrive, leaving
only the optimistic render, then asserts the kept sibling is already gone. This
isolates the optimistic focus (createdHandler cannot mask it); negatively
verified (fails without the optimistic focus call).
* 🧪 test: Extend Store Mock for the Regenerate Focus Hook
useChatFunctions.regenerate.spec.tsx mocks ~/store and recoil partially; the new
useFocusRegeneratedResponse calls store.messagesSiblingIdxFamily via a recoil
`set`, neither of which the mock provided (TypeError on regenerate). Add
messagesSiblingIdxFamily to the store mock and `set` to the useRecoilCallback
mock. Test-only; production code unchanged.
* 📊 feat: Real-Time Context Window & Token Usage Tracking
* 🧪 fix: Align Pricing Spec Dep Signatures with TxDeps
* 🩹 fix: Resolve Codex Findings for Context Usage Tracking
* 📊 feat: Granular Tool Token Breakdown with Deferred Splits
* 🧪 test: Cover Session Cost in Mock E2E and Scope Usage Selectors
* 🧪 test: Live Host-Pipeline Usage Verification (Env-Gated)
* 🧪 test: Local Real-Provider Multi-Turn E2E Harness
* 🪙 fix: Keep Tagged Usage Buckets Out of the Live Context Estimate
* 🩹 fix: Scoped Token-Config Fallback and Sequential Visibility for Usage Events
* 🩹 fix: Address Usage Review Findings — Cost Timing, Scoped Caches, Finalized Output
- carry the post-snapshot output estimate into the context snapshot at
finalize so the gauge keeps the last response after live resets
- accumulate per-rate billable units and price the session cost at
render, so usage events arriving before the token-config load still
count once it resolves
- pass user-scoped token-config cache keys through loadConfigModels
fetches and drop the controller's unscoped fallback to prevent serving
another user's resolved config
- tag emitted usage events with a per-run seq so resume dedupe never
drops a distinct call with an identical payload
- admit the static tokenConfig override in the custom endpoint schema so
it survives zod parsing into req.config
* 🩹 fix: Align Client Usage Accounting with Backend Cost Semantics
- classify cache tokens by provider (shared inputTokensIncludesCache from
data-provider, consumed by both the backend billing path and the client)
instead of a magnitude heuristic, so Anthropic/Bedrock turns where cache
is smaller than uncached input no longer under-bill input
- mirror resolveCompletionTokens on the client so Vertex-style hidden
thinking tokens are reflected in the Output row and session cost
- prefer endpoint pricing over adapter-provider pricing so a custom
endpoint can price a known model name without built-in rates shadowing it
- carry static cacheRead/cacheWrite overrides through the tokenConfig
schema and buildTokenConfigMap
* 🩹 fix: Honor Static Token Config in Billing; Tighten Usage Freshness
- initializeCustom now uses a static endpoint tokenConfig as the agent's
endpointTokenConfig (billing + balance checks), not just the advertised
UI config — previously the gauge showed admin rates while the agent
billed against built-in tables
- invalidate the token-config query alongside models on user-key add/
revoke so context windows and pricing refresh without a reload
- include maxContextTokens in ChatForm's stabilized conversation memo so
the gauge reflects a changed context-window setting immediately
- feed the live output estimate from the legacy content path (direct and
assistants streams), setting from cumulative part text rather than
accumulating deltas
* 🩹 fix: Resume Usage Dedup, Agent Pricing, and Partial Override Billing
- fold usage events idempotently by (runId, seq) so resume backfill no
longer resets the conversation totals — a mid-stream reconnect keeps the
usage of prompts already completed earlier in the session
- tap replayed pending message/reasoning/content events so output streamed
past the resume snapshot reaches the live estimate, not just the message
- resolve cost against the agent's backing endpoint (Agents conversations
report endpoint `agents` / provider `openAI`, neither of which keys a
custom endpoint's tokenConfig)
- getMultiplier/getCacheMultiplier fall back to the standard tables for
models absent from a partial endpointTokenConfig, so a partial static
override no longer bills non-listed models at defaultRate while the UI
shows the correct pattern rate
* 🩹 fix: Repaired Output in Gauge, Cache-Rate Keys, Config Gate, Usage Cleanup
- live/completed gauge counts the repaired completion (normalized output),
so under-reporting providers don't drop the response from used context
- translate static tokenConfig cacheWrite/cacheRead onto the write/read
keys getCacheMultiplier reads, so cache tokens bill at the configured
rate instead of the prompt-rate fallback
- clear the token index and usage atoms when leaving a conversation, so
visited histories don't accumulate in memory for the tab's lifetime
- wait for startupConfig before mounting the gauge, so a deployment with
contextUsage disabled never briefly mounts it or fires the token-config
query on first load
* 🩹 fix: Move Token-Config Resolution to TS; Key Live Usage by Created Convo
- extract the token-config resolution (override gathering + cache lookup +
buildTokenConfigMap) into resolveTokenConfigMap in packages/api, leaving
the /api controller a thin request-scoped wrapper (CLAUDE.md TS rule)
- getConvoKey prefers the user message's real conversationId once the
`created` event stamps it, so a new chat's first-response live gauge and
totals land under the id TokenUsage subscribes to instead of NEW_CONVO
* 🩹 fix: Clear Stale Redis Job Usage; Live-Tap Legacy Streams; Share Fetched Config
- DEL the Redis job hash before re-creating it so a reused streamId can't
inherit a prior run's contextUsage/tokenUsage and backfill stale usage
- tap the legacy {message,text} stream branch (non-agent OpenAI/Anthropic
streams) into the live estimate, not just the content path
- copy a deduped fetch's token config to every sibling endpoint sharing the
baseURL/key/headers, so /token-config resolves each by its own name
* ⏪ revert: Don't DEL Redis job hash in createJob (breaks cross-replica resume)
createJob is an idempotent join — a second replica calls it for the same
streamId to share an in-flight stream's state. DELeting the hash wiped the
prior replica's persisted created/usage state, so a joining replica missed
the created event (GenerationJobManager cross-replica integration test).
Reverts the F1 change from 2bfce0c34b; the stale-usage concern doesn't
arise in practice (streamId is unique per generation).
* 🩹 fix: Best-Effort Usage Emit; Tag Hidden Sequential-Agent Usage
- wrap the ModelEndHandler usage emit in try/catch so a failed telemetry
delivery (closed SSE / Redis publish error) can't abort the handler
before thought-signature capture, which would break resumed tool calls
- tag hidden sequential-agent usage as 'sequential' (non-primary) so the
client folds it into session cost/totals but not the live context gauge,
instead of letting an undefined usage_type inflate the visible gauge
* 🩹 fix: Refetch Stale Token Config on Mount; Normalize Vertex for Lookup
- useTokenConfigQuery refetches on mount when stale, so a user-key change
that invalidates tokenConfig while the gauge is unmounted takes effect on
return instead of serving the prior key's resolved config
- normalize a Vertex-backed agent's provider (vertexai) to the google
token-config key, so Gemini context windows and rates resolve instead of
showing unknown context / $0 cost
* ✨ feat: Server-Side Per-Event Cost (Authoritative Pricing for the Gauge)
Move usage-cost pricing to the single source of truth. The backend prices
each model call with the same billing functions (premium tiers via
getMultiplier(inputTokenCount), cache rates) and emits the USD cost on
on_token_usage when interface.contextCost is enabled; the client sums
emitted costs instead of re-deriving from base token-config rates.
- computeUsageCostUSD reuses prepareTokenSpend/prepareStructuredTokenSpend
so the emitted cost matches what is billed (incl. premium thresholds)
- getDefaultHandlers gains a usageCost pricing context; initialize.js wires
db.getMultiplier/getCacheMultiplier gated on contextCost (agents path)
- client UsageTotals carries a summed costUSD; retire the client-side rate
lookups (costFromUnits/calcUsageCost) that drifted from backend pricing
and produced the provider-keying / cache-key / Vertex / premium findings
- keep normalizeUsageUnits for the displayed token counts; token-config is
still used for the context-window meter
Fixes the premium-tier session-cost under-report (gpt-5.x / gemini-3.1
above their input thresholds).
* 🩹 fix: Branch-Accurate Usage Snapshot + Clearer Gauge Track Contrast
- re-anchor the context snapshot from the user message to the response
message at finalize. Regenerating a response branches off a shared user
message, so anchoring on it made the snapshot read as "active" on both
branches — switching to the sibling branch showed the wrong (other
branch's) context. The response message is branch-unique, so sibling
branches now correctly fall back to their own per-branch totals.
- raise the gauge ring's track/fill contrast (muted track, prominent fill)
so the used portion reads clearly as a fill-level indicator
* 🩹 fix: Tag Sequential Usage in Billing; Emit Subagent Cost; Reset Live on Resume Errors
- tag hidden sequential-agent usage `usage_type: 'sequential'` on the
COLLECTED usage (not just the emit), and treat it as non-primary in
recordCollectedUsage (billed, excluded from the reported output total) so
hidden intermediate output stops inflating the parent's tokenCount/pruning
- emit on_token_usage from the subagent usage sink (tagged `subagent`, with
authoritative cost when contextCost is on) so the gauge's session
cost/totals include billed subagent usage; it stays out of the live meter
- call resetLive on the resumable 404 and max-retry terminal branches so the
gauge doesn't keep counting stale in-flight tokens after the stream ends
* 🎨 fix: Contrast the Popup Context Bar; Revert Ring Restyle
- raise the popup breakdown's context progressbar contrast (muted
surface-tertiary track, prominent text-primary fill) — that's the bar the
contrast feedback was about
- revert the gauge ring restyle (kept its original border-heavy track /
text-secondary fill); the ring wasn't the element in question
* 🩹 fix: Stop Snapshot Granularity Leaking Across Branches; Revert Tree Memo
- a null-anchor context snapshot was treated as active on every branch,
leaking one generation's granular breakdown onto sibling branches. Require
a non-null (response-message) anchor on the viewed branch instead, so
siblings without a matching snapshot fall back to their own totals.
- revert the buildTree WeakMap memo in messages.ts. buildTree is pure (builds
from shallow copies) so the memo was behaviorally identical, but it was the
feature's only change to core branch-navigation selectors — removing it
matches upstream and rules it out of branch-navigation debugging.
* 🪙 fix: Thread Endpoint Token Config to Agent Billing, Cost, and Context Limits
Custom-endpoint agents resolve an endpointTokenConfig during agent init but
it never reached the AgentClient, so spending, emitted cost, and runtime
max-token resolution all fell back to default rates for those agents.
- Surface options.endpointTokenConfig on the returned InitializedAgent.
- Pass it to the AgentClient (this.options.endpointTokenConfig) so the
spending path bills at configured rates.
- Thread it through usageCost to computeUsageCostUSD so emitted per-event
cost matches billing.
- getModelMaxTokens/getModelMaxOutputTokens fall back to the built-in map
for models absent from a partial override (matches buildTokenConfigMap);
consolidates the duplicated fallback in pricing.ts.
* 🪙 fix: Preserve Granular Breakdown Across Branch Switches
The granular context breakdown lives only in the live on_context_usage
snapshot — a single per-conversation slot, anchored to the latest response
and overwritten by each generation. Switching to a branch generated earlier
this session lost its tool/skill/system rows and fell back to coarse totals.
Retain each generation's finalized snapshot in a per-conversation map keyed
by its branch-unique response id (snapshotsByAnchorFamily). When the live
snapshot is off the viewed branch, walk the branch tail for its deepest
stored anchor and render that breakdown. Bounded by generation count and
cleared on conversation switch; the live/just-generated path is unchanged.
* 🪙 fix: Harden Resume Seeding and Subagent Usage Emission
- useResumableSSE: skip the trailing-output live seed when the resume
carries a context snapshot; the snapshot's messageTokens already counts
produced output, so seeding it again inflated usage until the next reset.
- AgentClient subagent emitter: await GenerationJobManager.emitChunk like
every other caller (it persists before publishing), so a floating promise
can't race job cleanup and a Redis/publish failure is caught by the
emitter's try/catch instead of surfacing as an unhandled rejection.
* 🧪 test: Playwright Coverage for Context Breakdown Granularity
Add a test-only data-testid distinguishing the granular snapshot breakdown
(context-breakdown) from the coarse message-history estimate
(context-estimate), then assert granularity in the mock e2e harness:
- renders the granular breakdown from the live on_context_usage snapshot
(guards that the snapshot event actually reaches the popover, not just the
usage totals).
- preserves the granular breakdown after switching branches — regenerate to
overwrite the single live snapshot, switch back, and confirm the rows
survive via the per-anchor snapshot history map.
Branch regenerate/sibling selectors mirror the existing chat.spec branch test.
All three usage specs pass against the mock pipeline.
* 🪙 fix: Correct Resume Live-Seed, Fallback Re-index, and Subagent Emit Flush
Codex round on the prior commit:
- countTrailingOutputChars now counts only output at the very END of the
aggregated content (0 when the model paused at a tool call), and the resume
path always seeds it. The earlier skip-trailing-tool-parts behavior plus the
skip-seed-when-snapshot gate together over- or under-counted in-flight
output on resume; one rule fixes both — pre-invoke snapshot budget is never
double-counted, and genuine in-flight output is no longer dropped.
- useTokenUsage re-indexes from the messages cache on tail change while
submitting. The cache subscriber is muted during streaming, so without a
context snapshot (non-agent streams) sumBranch missed the created tail and
dropped history + prompt until finalize. Bounded — tailId only shifts on
created/finalize/branch-switch.
- AgentClient tracks subagent usage emit promises and flushes them in
chatCompletion's finally. The sink fires the emitter without awaiting, and
resume reads the usage emitChunk persists (HSET), so cleanup must not race
it or resumed clients miss billed subagent usage.
* 💬 feat: Conversation Starters for Model Specs
Adds an optional conversation_starters field to model specs in
librechat.yaml. When the active conversation uses a spec that defines
starters (and no agent/assistant starters apply), the chat landing
renders clickable starter prompts between the landing content and the
chat input; clicking one submits it as the first message.
- data-provider: add conversation_starters to TModelSpec and
tModelSpecSchema so the field survives strict config parsing
- client: ConversationStarters falls back to the active spec's
starters via getModelSpec; entity (agent/assistant) starters
take precedence; starter cards are centered, size to content,
wrap at word boundaries, stagger their fade-in, and gain a
focus-visible ring
- sanitizeModelSpecs passes the field through (denylist); covered
by a new unit test
- e2e: mock spec + tests for rendering, absence, click-to-submit,
and the MAX_CONVO_STARTERS cap
Closes#3619
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: Sort ChatView imports
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
* 🧹 fix: Prune Dangling Skill IDs from Agent Allowlists
Deleted skills left their ids behind in every agent's `skills` allowlist:
nothing removed them on skill deletion, the builder rendered no chip for
unresolvable ids (so users could neither see nor remove them), and at
runtime the non-empty allowlist intersected with accessible skills to an
empty set — silently disabling the entire skills catalog for the agent
even though the panel looked like "no skills selected."
- deleteSkill / deleteUserSkills now $pull deleted ids from all agent
allowlists (no versioning, timestamps untouched)
- createAgent / updateAgent prune allowlist ids whose skill doc no longer
exists (existence-only check, never ACL), so poisoned agents self-heal
on the next save — including duplicates and sync paths
- the builder renders unresolvable allowlist entries as removable
"Unavailable skill" chips once the catalog query resolves
* 🪞 fix: Keep Skill Queries and Authoring Labels Truthful After Chat Edits
Skills authored mid-chat via create_file/edit_file never reached the
Skills panel or builder without a manual refresh, and a create_file that
overwrote an existing file still announced "Created" in the tool card.
- invalidate all skill query caches (refetchType: 'all', since the skill
hooks opt out of refetchOnMount) when a completed create_file/edit_file
call targets a skills/ path
- label create_file completions from the host-authored output summary:
overwrites now read "Updated <file>" with the edit icon
* ♻️ refactor: Inject Skill Authoring Callback Instead of Query Client
useStepHandler took useQueryClient directly, forcing a QueryClientProvider
wrapper onto all 54 renderHook calls in its spec. Its only consumer,
useEventHandlers, already holds the query client and does this exact
invalidation pattern for project/MCP keys — so pass an optional
onSkillAuthoringComplete callback instead. Detection stays in the
completion handler; the side effect lives with the client. Spec diff
collapses to pure additions.
* 🩹 fix: Resolve Codex Review Findings on Allowlist Pruning
- normalize allowlist candidates to lowercase in filterExistingSkillIds:
isValidObjectIdString accepts uppercase hex, but _id.toString() is
lowercase, so a casing mismatch silently emptied a valid allowlist
(widening scope to the full catalog)
- prune agent allowlists immediately after the Skill row deletion in
deleteSkill: a SkillFile cleanup failure previously skipped the prune
forever, since retries exit early on deletedCount === 0
- filter version-snapshot skills through filterExistingSkillIds in
revertAgentVersion so reverting to a pre-delete version cannot
resurrect dangling ids
- resolve allowlist ids missing from the builder's first catalog page
individually via getSkill before labeling them unavailable — a cache
miss on a >100-skill catalog no longer invites removing a valid skill
* 🚪 fix: Fail Closed When Pruning Empties a Skill Allowlist
Codex round 2: an automated prune that empties an enabled allowlist
would silently widen the agent to the full accessible catalog (empty +
enabled = full per the #13526 semantics). Hygiene must only ever narrow.
- deleteSkill/deleteUserSkills: agents whose entire allowlist is being
deleted get skills disabled instead of an emptied-but-enabled list;
ids are lowercased before the $pull so an uppercase-but-valid id
cannot leave the dangling entry behind
- createAgent/updateAgent/revertAgentVersion: pruning a non-empty
allowlist to zero survivors disables skills; an explicit user-sent
skills: [] keeps the full-catalog semantics
- builder: a per-id skill lookup only renders the removable
"Unavailable skill" chip on a confirmed 404/403 — transient and
server errors keep the chip hidden rather than inviting removal
The no-spec branch of `useApplyModelSpecEffects` (added in #11796) reset
`ephemeralAgentByConvoId` to null on every `newConversation` call when
model specs are configured. On in-place model/endpoint switches (modular
chat, same conversation or new-chat draft), BadgeRowContext never refills
from localStorage — its init effect only re-runs when the storage suffix
or spec changes — so the MCP selection (and tool toggles) were silently
dropped from subsequent request payloads while the MCP badge kept
displaying them.
Reset now only happens on context transitions (leaving a spec, or moving
to a different conversation key), where a BadgeRowContext refill is
guaranteed; in-place non-spec switches preserve the ephemeral agent.
- Gate the no-spec reset on `prevSpecName` / `prevConvoId`, passed from
`newConversation` via a snapshot read of the pre-switch conversation
- Add jest coverage for all five branches of the no-spec path
- Add e2e spec asserting `ephemeralAgent.mcp` stays in the chat payload
after a new-chat model switch and after regenerate on a switched
conversation (verified failing before the fix, passing after)
- Add non-spec "Mock Provider D" endpoint to the e2e config so tests can
switch between two real ephemeral endpoints; widen `MockEndpoint` type
* 🛟 fix: Auto-Recover from Stale Service Worker Assets After Deploys
- 404 missing static assets in the SPA fallback instead of serving index.html
- inline recovery script unregisters stale SWs and reloads once on chunk failure
- route vite:preloadError into the same recovery path for stale lazy chunks
* 🛟 fix: Address Review — SW-Side Recovery, Scoped Unregister, Shared Fallback
- importScripts'd sw-heal.js pings window clients on activation and reloads
ones that can't pong: stale pages carry no recovery code of their own
- scope SW unregistration to the deployment base for subpath installs
- preventDefault vite:preloadError only when a recovery reload was initiated
- extract createSpaFallback and apply the asset 404 guard to experimental.js
Adds an opt-in showOnLanding flag to model specs. When set, the chat
landing shows the spec's label and description in place of the
time-of-day greeting; specs without the flag are unaffected, so existing
deployments see no behavior change. HTML-valued descriptions (inline
icons + markup) render sanitized via the shared config-HTML sanitizer
with a new media tag/attribute allowlist, both on the landing and in
model selector items. Excludes e2e specs from the typed client lint
block so staged e2e files no longer fail pre-commit with 'file not
found in project'.
* ⏳ fix: Extend and decouple MCP OAuth flow timeouts
The OAuth auth button disappeared after 2 minutes (the internal OAuth
handling timeout) while the flow state lived for 3 minutes, leaving users
who didn't click immediately stuck in an unrecoverable re-auth loop. The
handling timeouts also reused the connection/init timeout, so a short
initTimeout would shrink the OAuth window further.
- Add MCP_OAUTH_HANDLING_TIMEOUT (10m) and MCP_OAUTH_FLOW_TTL (15m) to mcpConfig
- Decouple the reactive/proactive OAuth waits from initTimeout/connectionTimeout
- Use OAUTH_FLOW_TTL for the FlowStateManager TTL and the UI status window
- Ensure the flow TTL outlives the handling timeout, fixing the
"Flow state not found" race
- Remove dead FLOW_TTL constant and document new env vars
Fixes#13615
* ⏳ fix: Coordinate OAuth pending window with handling timeout
Address Codex review: the extended OAuth wait was still capped by other
timeouts that were not updated.
- Align PENDING_STALE_MS (button validity + pending-flow reuse window)
with MCP_OAUTH_HANDLING_TIMEOUT so a flow stays reusable for the full
wait instead of 2 minutes (Finding 3)
- Clamp MCP_OAUTH_FLOW_TTL to never fall below the handling timeout so a
callback near the deadline still finds its flow state (Finding 2)
- Floor attemptToConnect's timeout to the handling window for OAuth
servers so the reactive in-connect OAuth wait is not killed by the
30s connection timeout (Finding 1)
- Update flow staleness tests to reference the threshold symbolically
* ⏳ fix: Align OAuth window across status, action flows, and client polling
Address Codex round 2: extending the server wait exposed three more
windows that were still capped or now over-extended.
- checkOAuthFlowStatus reports a PENDING flow as active only within the
usable PENDING_STALE_MS window, not the longer Keyv retention TTL, so
the connect button reappears instead of a stuck 'connecting' state
- Give Action (custom tool) OAuth its own FlowStateManager on the prior
3-minute TTL so the longer MCP OAuth TTL can't leave an action tool
call waiting up to 15 minutes
- Extend the MCP server-card client polling to the 10-minute handling
window so a user who completes OAuth after 3 minutes is still picked up
* 🧪 test: Make stale-flow CSRF test track PENDING_STALE_MS
The CSRF-fallback stale-flow test hardcoded a 3-minute age, which is now
within the 10-minute PENDING_STALE_MS window and was wrongly treated as
active. Derive the age from PENDING_STALE_MS so it tracks the constant.
* ⏳ fix: Add grace buffers and surface OAuth timeout to the client
Address Codex round 3 (near-deadline edges):
- Clamp MCP_OAUTH_FLOW_TTL to handling timeout + 60s grace (not equality),
so flow state outlives the wait instead of expiring at the same instant
- Extend attemptToConnect's OAuth floor by a 60s grace so a user who
authorizes near the deadline still gets the post-OAuth reconnect
- Surface OAUTH_HANDLING_TIMEOUT on the connection-status response and
have the client poll for the configured window instead of a hardcoded
10 minutes, so a tuned server deadline isn't capped on the client
* ⏳ fix: Refresh client OAuth timeout from the first status refetch
If the connection-status cache is empty when polling starts, the client
captured the 10-minute fallback and never picked up a tuned oauthTimeout.
Re-read it after each refetch so a longer configured deadline is honored
even on a cold cache.
* 📝 refactor: Type oauthTimeout on MCPConnectionStatusResponse
Declare the oauthTimeout field on the shared response type in
data-provider instead of an ad-hoc inline cast in the client hook, and
replace the pre-existing 'as any' on the status query read with the
typed getQueryData. Type-level only; no runtime change.
* ⚡ refactor: Migrate @librechat/client build from Rollup to tsdown
Mirrors the data-schemas migration. Replaces Rollup (rpt2 + postcss) with
tsdown (rolldown + oxc); the package build drops from tens of seconds to ~0.3s.
- Emit isolated-declaration .d.ts via oxc (dts.oxc) and enforce
isolatedDeclarations in tsconfig for editor DX (source made clean: explicit
export type annotations added across src, no `any`).
- Extract component CSS to dist/style.css so the CJS output stays valid
CommonJS (the prior postcss runtime-injection produced an ESM import in the
CJS bundle that breaks jest/require). Imported once in the client app entry;
Vite bundles it for the app.
- Repoint package.json to dual .mjs/.cjs + .d.mts/.d.cts and add ./style.css
and ./package.json exports.
- Update CI build-cache keys to hash tsdown.config.mjs; remove rollup.config.js.
* 🔧 chore: address Codex review on client tsdown migration
- Add tsdown.config.mjs to turbo.json build `inputs` so changes to the new
bundler config invalidate the Turbo cache (the shared inputs only listed the
rollup configs). Also covers the already-migrated data-schemas.
- Name the memoized default export (ControlComboboxMemo) instead of the
codefix-generated `_default_1`, for clearer stack traces / grepping.
Bumps typescript 5.3.3 -> 5.9.3 across all workspaces. typescript-eslint must move 8.24.0 -> 8.60.1 too: 8.24's typescript peer was capped at <5.8.0; 8.60.1 widens it to <6.1.0.
Two errors surfaced by the newer compiler are fixed:
- api/src/rum/proxy.ts: TS 5.9 made `Buffer` generic (`Buffer<ArrayBufferLike>`), which no longer structurally matches `BodyInit`; cast the fetch body (Node's fetch accepts a Buffer at runtime).
- client usePresetIndexOptions.ts: drop a dead `|| {}` on an object spread (always truthy — flagged by the new TS2872 check).
All four package typecheck jobs + the client app typecheck pass under 5.9.3; builds (tsdown + rollup) and the rum proxy tests are unaffected.
Render assistant markdown as independently memoized top-level blocks instead of a
single ReactMarkdown that re-parses and re-highlights the entire message on every
streamed token. Once a block's source slice is stable it skips re-parse/re-render;
only the final, still-growing block re-parses.
- splitMarkdown: split a message into top-level blocks via mdast-util-from-markdown
(+ gfm/directive/math extensions) using node source offsets; also report per-block
executable-code and artifact index counts.
- MarkdownBlocks: render each block memoized on its raw slice, each wrapped in its
own CodeBlock/Artifact providers seeded with prefix-summed base indices, so the
document-order indices used to match code-execution results stay stable under
memoization (verified by OLD-vs-NEW parity tests across direct + streamed renders).
- CodeBlockContext/ArtifactContext: add optional baseIndex (default 0, fully
backward compatible) so per-block providers continue the running index.
- markdownConfig: extract the shared remark/rehype plugins + components map.
- deps: declare mdast-util-from-markdown, mdast-util-gfm/math/directive and the
micromark gfm/math/directive extensions as direct client dependencies (previously
resolved transitively via react-markdown).
- Tests: splitter unit tests; index parity + DOM equivalence vs the whole-message
renderer; rendering smoke tests.
- Bench (MarkdownBlocks.bench.tsx, outside __tests__ so the default jest run skips
it): ~88% fewer code-block renders and ~2.3x faster cumulative render across a
simulated stream.