Commit graph

2269 commits

Author SHA1 Message Date
Marco Beretta
151dc9e03e
📌 fix: Keep Pinned Chats Pinned Through a New Turn (#15230)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: keep pinned chats in the Pinned section after a new turn

A chat's conversation state snapshots the sidebar-owned flags when the chat
is opened, and the pin mutation never reaches it. Pinning a chat that is
already open therefore leaves a stale `pinned: false` on that state, and the
SSE handlers write it back over both list caches on the next turn. The chat
drops out of Pinned and reappears under Today, since groupConversationsByDate
only skips the rows the chats cache still marks pinned.

Strip `pinned` and `isShared` from the conversation state before it reaches
the caches, and carry the cached row's value forward whenever an updater omits
one. The pin mutation still sends `pinned` explicitly, so unpinning a chat
removes it from the section exactly as before.

The server half of this bug was fixed in #14860, which put `pinned` in
excludedKeys so saveMessageToDatabase's unset sweep stops clearing it, but it
went in without a test. Cover it here: without that entry the sweep unpins the
row on every message, which is what the released builds still do.

* fix: preserve pinned flags during uncached upserts

* fix: narrow nullable SAML profiles

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-27 07:47:30 -04:00
Danny Avila
8b1fcc0fc2
🌐 fix: Expose Gemini Models to Vertex AI Agents (#15234)
* 🌐 fix: Expose Gemini Models to Vertex AI Agents

* ♻️ refactor: Resolve Shared Vertex Model Catalogs

* fix: Preserve Exact Vertex Model Catalogs

* style: Format Agent Model Selection

* test: Preserve Native FS in Stable Diffusion Spec
2026-08-27 06:50:23 -04:00
Danny Avila
3d808dc906
🛗 fix: Anchor Message Nav Gestures to One Measured Rib Layout (#15272)
* 🧭 fix: Anchor the Message Rail to One Measured Layout

The rail's magnifier wrote size into normal flow while its rows, flex items
in a scrolling column without `shrink-0`, compressed to their content the
moment the list overflowed. Hovering grew the column's scrollHeight 363→378
and shifted every rib below the pointer, staling the cached centres: the
centre of `m10`'s rib previewed `m12`, and a click in the gaps — half the
rail's area — followed that same wrong id.

Three more measurements came from different origins. The ribs' offsetParent
was the absolutely positioned `nav`, not the static column, so the rail's
auto-centring inherited the chevron's height (34px ≈ 3.5 ribs). `scrubTo`
mapped the pointer's viewport fraction across the whole rib list, ignoring
the column's own scroll, so no drag position landed on the rib under the
pointer. And `currentId` came from the IntersectionObserver, whose topmost
intersecting row is a rib short of the end whenever a `.steer-render`
follows its response in document order while sitting inside it — the rail
pointing above the end while the reader sits at the bottom.

Everything that answers "which rib is the pointer on" now reads one measured
layout in the column's own content space, and "you are here" is a scroll-spy
over entry spans, so at the bottom current lands on the terminus.

Also: a response's row mounts a frame before its first token, which labelled
the rib with nothing and opened an empty preview card; ribs now name the
pending state. Lighting separates the single current mark from the in-view
band instead of hover replacing both. An origin rib mirrors the terminus. And
the column takes one tab stop with arrow-key walking, rather than one per
message — 226 stops in a 200-message thread.

* 🧭 fix: Address Codex Round 1 on the Message Rail

All three findings were real.

An empty preview is not evidence of generation. `buildEntry` returns nothing
for image-, tool-call- and reasoning-only content too, so a reopened thread
labelled those settled rows "Generating" forever. Only the tail of a live
submission earns that wording now; every other text-free row first reads the
text its rendered card already puts on screen, and falls back to an explicit
"No preview" rather than a blank label and an empty preview panel.

A roving tab stop has to travel with focus. Deriving `tabIndex=0` from the
scroll-spy's current rib alone left a second stop behind the moment an arrow
key moved focus, so Tab re-entered the rail it had just left and Shift+Tab
walked backwards into it instead of out.

And a snap point only exists inside the range the container can reach. The
rows carry `scroll-margin-top: 4rem` against `pt-14` of content padding, so
the first entry's raw snap point is -8px; compared unclamped it read as
"there is still something above you" at the top of every conversation. That
kept the up chevron live with nowhere to go — a defect that predates this
branch — and the new origin rib inherited it, rendering as out of view while
the reader sat at the absolute top. `snapPointFor` now clamps, and the jump
helpers share it so they answer that question the same way the chevrons do.

Re-verified against the real layout: the harness now reproduces the 56px
padding and 64px scroll margin exactly, where the previous run had masked
the negative snap.

* 🧭 fix: Address Codex Round 2 on the Message Rail

Two new findings, both real, both introduced by this branch.

The row fallback was reading chrome. An assistant row renders a VISIBLE `h2`
naming the sender, so the fallback added for settled tool-call and image-only
rows handed back "Claude" for a response that had not produced a token yet —
inventing a preview and, worse, masking the pending state the previous commit
had just added. It now reads the row's message body, which is empty for a
freshly mounted response and carries the card text for a settled one.

And the hit test went stale whenever the rail scrolled. Making the rail
wheel-browsable meant the ribs can move without the pointer moving, but the
pointer's position was cached already converted into the column's content
space. The preview — and the id a click in the gaps follows — stayed on the
rib that used to be there. The pointer is stored as a viewport coordinate now
and converted at the moment it is used, with a scroll handler on the column to
redo the test when the ribs move underneath it.

Pending also narrowed to responses. Between sending and the reply's row
mounting, the reader's own turn is the last entry, and a submission in flight
is not evidence that the user's message is the thing being generated.

The three findings repeated from round one were already fixed in 51180fae7b;
codex re-reviews the whole diff rather than the increment. Re-verified each in
a browser against the real layout, including the row chrome this round adds to
the harness.
2026-08-27 06:45:57 -04:00
Danny Avila
f0eda61638
🧵 feat: Unify Subagent Child Threads (#15261)
* feat: unify subagent child thread rendering

* chore: sort subagent UI imports

* fix: preserve bounded subagent thread context

* fix: render persisted child activity in unified timeline

* fix: bound durable subagent activity projections

* perf: cap subagent activity source scans

* chore: sort subagent thread imports

* fix: align completed child messages

* fix: bound selected subagent activity reads

* fix: bound child activity storage reads

* fix: preserve child receipt truncation state

* test: preserve projected receipt truncation

* test: align child completion e2e

* test: isolate Stable Diffusion logger mock
2026-08-27 06:04:13 -04:00
Marco Beretta
227a99ede8
🧮 fix: Render Google Settings From the Shared Schema and Bound Them Per Model (#14989)
* fix: render Google settings from the shared schema and bound them correctly

Google was the last endpoint hand-rolling its own sliders. The schema was
already there and already wired, only the frontend never used it, so
rendering from it replaces 315 lines with the body OpenAI, Anthropic and
Bedrock share.

That closed a functional gap rather than only moving code: the old form
exposed six fields where the schema declares fifteen, so Resend Files,
Thinking, Thinking Budget, Thinking Level, Grounding with Google Search,
URL Context and File Token Limit were unreachable from a Google preset.
resendFiles is added to the Google schema so its switch round-trips, and
the endpoint type is resolved from the endpoints config, since a preset
for a Google-compatible endpoint need not carry endpointType and would
otherwise blank the panel.

Sharing the controls also meant inheriting their gaps, which this fixes:

- Number settings declared a range that nothing enforced, so a value past
  the provider's ceiling was persisted and rejected later. clampSettingRange
  applies it, and generateDynamicSchema validates the same rule, so the
  definition is the single source of truth for both.
- Thinking budget bounds are per model. The generic range capped 2.5 Pro
  below its documented 32,768 and admitted Flash values above 24,576.
  positiveMin carries the documented floors while -1 stays typeable as the
  decide-automatically sentinel.
- Ranges the model narrowed are marked modelSpecific, so a switch to a
  model that ignores the parameter cannot rewrite a value set for another.
- useDebouncedInput rebuilt its debouncer every render, because neither
  setOption nor the inline setter is memoized, so pending edits were never
  really superseded and a flush reached an instance holding nothing. The
  callbacks move to refs, and the text and slider controls flush on blur or
  value commit so Save and Export cannot read a stale preset.
- Controls reset to their definition default on a conversation or preset
  change and only recovered ~560ms later, which showed saved values as
  defaults and could write the default back.

The debounce regression test fails against the previous memo dependencies
and passes with the refs, so the flush is verified rather than assumed.

* fix: keep the context token bounds on the Google setting

The bounds came from the hand-rolled Google editor, but they were added to
the shared definition every endpoint renders, so blurring the field clamped
OpenAI, Anthropic, Bedrock and custom endpoints to a window that is only
Gemini's. Custom endpoints may declare context windows outside it.

* fix: agree with the generated schema across the sentinel gap

A stored value between range.min and zero passed through clampSettingRange
unchanged, though the schema admits only the sentinel or the positive floor,
so normalization could preserve a value the provider then rejects. Validate
a configured default against the same rule.

* fix: keep positiveMin on configured parameter definitions

The runtime schema for customParams.paramDefinitions retained only min, max
and step, so a configured positive floor was stripped before the UI saw it
while the shared SettingRange type advertised it.

* fix: commit a double-click slider reset immediately

The browser dispatches dblclick after the second pointer release, so the
value commit has already flushed and the reset sat in the debouncer. Saving
or exporting inside that window read the value the slider no longer showed.

* fix: normalize an out-of-range stored value on mount

The applied-range ref started at the first range, so the effect returned
immediately and a budget saved under the shared range stayed displayed and
savable when the selected model no longer allowed it.

* fix: normalize on navigation and keep sliders out of the sentinel gap

The parameters panel stays mounted across conversations, so a legacy budget
could arrive under a range that never changed; keying the normalization on
the conversation or preset identity as well catches it. After a navigation
the local value still belongs to the conversation being left, so the
incoming stored value is what gets normalized.

A slider steps straight through the gap between a sentinel minimum and its
positive floor, which the generated schema rejects, so the committed value
is clamped. It is also set before the flush: the keyboard path commits
before it reports the change, so the flush alone had nothing to write.

* fix: close the remaining paths into the sentinel gap

Applying a preset over the open conversation replaces the stored value
without changing the conversation id or the model, so normalization now also
triggers on a stored value that arrives differing from the local one. A
value the user typed reaches the conversation through this same field and
matches by the time it lands, so it stays on the blur-clamped path.

The slider's adjacent number input only flushed on blur, so a typed value
could sit in the gap the track is now kept out of.

A configured positiveMin above the maximum admits nothing but the sentinel
while the clamp maps every non-negative input onto a maximum the generated
schema rejects, so both the config schema and the definition validator
refuse it.

* fix: keep a non-negative sentinel and a loadable slider default

The minimum is the sentinel whatever its sign, and the generated schema
admits it outright, so a range like { min: 0, positiveMin: 10 } no longer
has its 0 lifted to the floor by the clamp.

The synthesized slider default took the midpoint of the whole range, which
for a sentinel range lands in the gap the validation added alongside it, so
an otherwise coherent custom definition failed to load. It now takes the
midpoint of the admissible interval.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-25 21:21:28 -04:00
Marco Beretta
0383030817
perf: Avoid Parallel Full and Paged Prompt Loading on Startup (#15031)
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-25 21:21:17 -04:00
Marco Beretta
124e357cbf
✏️ feat: Edit Pasted Text and Clear It on New Chat (#15017)
* fix: stop an unsent paste from following every new chat

An explicit new chat now drops the unsaved-chat draft key before the
composer resets. `newConversation` empties the composer, but the key
outlives it and `useAutoSave` restores from that key on the way in, so a
long paste that was never sent came back as an attachment on every later
new chat. Per-conversation drafts are untouched.

Clicking a pasted-text chip opens the text in an editor so it can be
corrected before sending, and the chip's subtitle offers returning the
paste to the composer. The text comes from the in-memory blob, falling
back to the `text` field the file record already carries, so neither
needs a new endpoint.

`FileContainer` grows a `subtitleAction` prop for the second control.
Supplying it swaps the chip's own button wrapper for a full-bleed one
behind the content, since a button inside a button is invalid markup and
browsers drop the inner one's events.

* chore: sort imports to fix static checks

* fix: address paste edit review findings

- Keep the original paste attached until the replacement upload succeeds,
  so a rejected or failed save cannot destroy the only copy
- Guard edits and queued replacements against conversation switches and
  new-chat resets, mirroring the long-paste lifecycle guards
- Recover text for restored pastes by downloading the stored bytes, which
  Assistants and agent uploads persist without a text field
- Delete uploaded attachments when an explicit new chat discards the
  draft that referenced them, instead of orphaning the records
- Mark paste provenance explicitly (session registry plus files draft)
  instead of inferring it from a filename a deliberate upload can share
- Keep the subtitle action revealed on devices without hover

* fix: scope draft cleanup to its tab and delete restored pastes

- Stamp unsaved-chat files drafts with the writing tab's session id and
  skip deletion when another tab owns the record, so a new chat in one
  tab cannot discard the uploads attached in another
- Delete a restored paste's upload when an edit replaces it or returns
  it to the composer, which the attached flag otherwise preserved

* fix: harden paste edit lifecycle guards

- Re-check the originating composer and the file map before detaching an
  edited original on upload success, so navigation or a send during the
  request cannot remove or delete a file the old draft or sent message
  still references
- Record the replacement upload's paste provenance in the session
  registry and the files draft, keeping Edit and Move back on the new chip
- Bind move-inline to the unsaved-chat token as well, and abort the move
  when the chip is no longer attached to an unsent composer
- Restrict new-chat draft cleanup to ids the composer still owns: library
  re-attaches and ids with unknowable ownership are spared, and uploads
  still in flight are deleted once their records reach the files cache,
  unless the file came back attached in the meantime

* fix: match restored paste identities and recheck before opening the editor

- Treat a chip as attached when any of its ids (map key, file id, temp
  id) matches the composer map, since draft restoration keys entries by
  their temporary upload id while the value carries the server-assigned
  one; the previous key-only check made Move back silently do nothing
  and left both chips attached after an edit
- Recheck the originating composer and the attachment map after the
  text resolve before opening the editor, so a send during the download
  cannot stage a replacement upload into the emptied composer
- Extend the provenance predicate to temp ids for the same restored shape

* fix: spare re-attached sent pastes and discard stale editor resolves

- Force-delete a restored paste only when the composer's own draft
  claims its id, so a paste that was already sent and re-attached from
  the library keeps its shared record through Edit and Move back
- Sequence editor-open requests so a slow text resolve cannot overwrite
  the chip a later click selected

* fix: carry deferred discards across resets and clear the pending draft

- Merge newly deferred upload ids with the pending set instead of
  replacing it, so a second reset cannot orphan an earlier in-flight
  upload's eventual record
- Match deferred ids against temp_file_id as well, since the files
  cache keys records by the server id while the discard tracked the
  request uuid
- Clear the pane's pending draft key on an explicit new chat, or a
  running response's queued text and attachments come back with the
  next run

* fix: mint a fresh tab id when sessionStorage was inherited

Duplicated and opener-created tabs start with a copy of the original's
sessionStorage, so a stored tab id only proves continuity when the
document is a reload of the same tab. Every other entry into a document
now mints a fresh id, keeping an inherited one from attributing another
tab's live drafts to this composer.

* fix: gate restored-paste deletion on draft tab ownership and unclip the chip focus ring

- Stamp every files draft with the writing tab, not just unsaved-chat
  ones, and require the stamp to match before a restored paste's record
  is deleted, so another tab restoring the same draft is not destroyed
- Draw the full-chip Edit control's focus indicator as an inset ring
  with the surface's radius, since the offset ring was clipped away by
  the surface's overflow-hidden

* fix: resolve paste ownership before restoration and across draft migration

- Treat a draft's own pastedTextIds as composer-owned at discard time,
  so a reload-then-new-chat click deletes or defers those uploads
  before the composer map has been rebuilt, instead of skipping them
- Read both the pending and idle draft keys when claiming a restored
  paste for deletion, since a response finishing mid-edit migrates the
  record between them

* fix: keep failed edits recoverable and retry failed draft deletions

- Reopen the paste editor with the user's corrections when the
  replacement upload is rejected or fails, instead of leaving only the
  original's text to reopen
- Retain deferred and immediate discard ids when the delete request
  fails, and retry them on the next files-cache update, so an offline
  or transient failure cannot orphan the uploads

* fix: retry restored-paste deletions that fail

A failed delete of a detached restored paste retains its payload in a
session store, and the discard retry effect drains retained payloads
alongside its own batch on every files-cache update, so an offline or
transient failure cannot orphan the upload once its chip is gone

* fix: queue failed edits and lock chips with actions in flight

- Queue a failed edit behind whatever dialog is open instead of dropping
  its corrections, and reopen it when that dialog closes
- Track an in-flight action per source paste and hide its Edit and Move
  back affordances until the replacement uploads or the move settles,
  so the same original cannot be acted on twice

* fix: address PR review bot findings

Codex:
- Keep the tab id on back-forward restoration
- Preserve the original tab owner when rewriting drafts
- Skip clearing idle and pending drafts another tab still owns
- Delete pending-draft uploads before clearing them
- Return failedFileIds from DELETE /files and retry those records
- Spare reattached files from retained deletion retries
- Trigger retained-deletion retries when a delete is retained
- Persist deferred discards across reload
- Delete embedded owned uploads with a discarded draft
- Ignore stale paste-editor failures before toasting
- Abort a queued edit after the original is sent
- Serialize Move back with a synchronous in-flight lock
- Prune paste provenance ids that left the draft

* fix: retain paste deletions the server reports as failed

The delete route answers 200 with `failedFileIds` when a record's storage
delete fails, so the detach path's `.catch()` never fired and the orphaned
upload lost its only cleanup reference once the draft provenance was pruned.
Inspect the resolved response and retain the deletion when it names the file.

Extract the `failedFileIds` reader `useNewChat` already had into the file
utils so both deletion paths read the response the same way, and give the
paste editor coverage for the failed and accepted responses.

Also add the missing `size` on a composer file literal that was failing the
client type-check.

* fix: release draft claims when their tab is gone and keep cleanup durable

A tab stamped its id on a files draft and nothing ever took it off, so a draft
saved in a tab the user then closed became unreachable for good: no other tab
would restore it, write to it, or clean it up, and the closed tab's id can never
be presented again. Tabs now report themselves in a small liveness registry and
release the claim on pagehide, and a claim whose tab is no longer around is
treated as free. Writers restamp a dead claim rather than preserving it.

Ownership also only existed once something was attached, so a typed-but-unattached
draft on a shared composer key read as nobody's and another tab's New Chat cleared
it. Saving text to one of those keys now claims it the same way.

Two more from the same review:

- The delete route answers a partial failure as 200, so treating an id as still
  present unless the response reports it deleted kept a ghost row for a file
  another tab had already removed. Read it the other way around: only a reported
  failure keeps a record cached.
- A retained deletion whose retry failed again moved no effect dependency, so it
  was never attempted a second time, and the payload only lived in memory. It is
  now persisted for the session and asks for a backed-off retry, plus one on
  regaining connectivity.

* fix: keep bfcache claims, retain failed deletes, and move the delete contract to TS

Four findings from the latest review round:

- pagehide fires with persisted: true when a document enters the back-forward
  cache rather than closing. Releasing the tab's claim there let another tab
  take the draft and delete files the restorable document still had attached,
  so the claim is now only handed back on a real unload; a bfcached tab that is
  never restored still ages out through the liveness window.
- useFileDeletion issued its batch and never looked at the outcome, so a fresh
  paste whose delete failed was orphaned with no retry. It now retains whatever
  the server did not delete, reading failedFileIds as well as the rejection.
- A partial failure answers 200, so the unconditional success toast told the
  user a file was deleted while it was still on disk and back in their list.
- The delete response contract lived in the legacy JS route. It moves to
  packages/api as buildDeleteFilesResponse, leaving the route a thin caller.

The useFileDeletion spec's mutateAsync mock returned undefined; react-query
always hands back a promise, so it now resolves like the real one.

* fix: park bfcached tab claims and drop ownership left by an emptied draft

A document in the back-forward cache has a frozen heartbeat, so the ordinary
liveness window expired its claim after 150s even though it could still be
restored with those attachments on screen, letting another tab take the draft
and delete the files underneath it. Entering the cache now parks the tab as
suspended, which holds the claim for 30 minutes: comfortably past the point a
browser keeps a bfcache entry, and still bounded, since a claim that never
expires is what stranded drafts under owners that no longer existed. Restoring
the document beats normally again and clears the flag. The registry entry grew
a shape for this and still reads records written as a bare timestamp.

Clearing the text of a shared composer key also left the ownership-only record
behind, locking the key to a tab with nothing in it: the next tab to type there
could neither restore its own draft nor take the key back. That claim is now
released when the text goes and nothing is attached.

* fix: keep unlinks out of the delete retry and claim shared text before writing

Four findings from the latest round:

- Retaining a failed agent or assistant unlink sent it through the generic
  retry, which replays files alone. That drops the tool_resource context, so
  the route would take its ordinary delete branch and destroy a record the
  agent and other references still point at. A failed unlink orphans nothing,
  so those deletions are simply not queued.
- A retry that resolved naming files in failedFileIds left both stores
  untouched, so nothing moved the effect that would try again. It now asks for
  another attempt on a reported failure, the same as on a rejection.
- The reattachment guard read only the idle new-chat key. After a reload the
  composer map is empty until the autosave restore renders, so a file the user
  had reattached to the conversation they were viewing, or to the pending key,
  could be deleted underneath them. All three keys are checked now, including
  their paste provenance.
- Text was written to a shared composer key before ownership was resolved, so
  a tab could overwrite another's saved text and still be refused the claim,
  leaving it unable to restore what it had just typed. The claim is taken
  first, and a claim with no attachment behind it follows whoever's text is
  actually stored; one backed by an attachment stays with its open owner.

* fix: merge shared discard state and keep restored file-search pastes retrievable

Four findings from the latest round:

- Every mount of useNewChat (header, sidebar, mobile bar, shortcuts) kept its
  own snapshot of the pending-discard list and wrote it back over one shared
  session store, so an id recorded by one instance was dropped by the next
  write from another, orphaning the upload it pointed at. An update now only
  resolves the ids that instance knows about and carries the rest through.
- Refusing an attachment-backed claim still let the text write land, destroying
  the owning tab's text for a tab that could not have restored it anyway. The
  claim now reports whether it succeeded and the write is dropped with it.
- A restored paste has no tool_resource on its record, so an edit to one that
  had been uploaded for file search was re-uploaded as a plain context file and
  the vector-backed original detached, dropping it out of retrieval. embedded
  does survive on the record and is only set for a vectorized file, so it is
  what the destination falls back to.
- The reattachment guard collected map keys and server ids but not
  temp_file_id, while the retry lookup resolves that alias: reattaching a file
  whose discard was pending under its temporary id would not have protected it.

* fix: stop the draft owner refusing its own writes and guard shared pending keys

Three findings, the first a regression from the previous commit:

- The attachment-backed refusal was evaluated before the owner check, so the
  tab that owned the draft was refused its own key: once anything was attached,
  nothing typed after it was saved. Ownership is settled first now, and the
  refusal applies only to another live tab.
- A long paste wrote its provenance and pending-paste record into the shared
  composer key without checking who owned it, and setFilesDraft preserves the
  existing owner rather than rejecting the write, so the paste was recorded
  into another tab's draft, which could then restore and delete the upload
  while this tab still showed the chip. Both write sites now check first.
- Two concurrent runs share the default pending key, and the migration to the
  new conversation ran before the ownership check: the finishing run moved the
  other tab's text and attachments under its own conversation and left that tab
  nothing to carry over. Ownership of the source is verified before migrating,
  and this composer's own text is still saved either way.

* fix: protect cross-tab reattachments and orphaned pastes on every discard path

Five findings from the latest round:

- The retry guard only read this pane's own draft keys, so a file a second tab
  had reattached to a conversation this pane never opened was deleted anyway.
  Drafts live in localStorage and are readable from every tab, so the guard now
  sweeps every persisted files draft rather than three known keys.
- Clearing the composer removed the shared text record without checking who
  owned it, so an empty composer in one tab erased text another tab was still
  holding behind its attachments. The clear path takes the same guard as the
  write path, and both now share one ownership predicate.
- When another tab owned the pending key, this tab's own queued attachments
  were cleared from the map and never written anywhere, because the autosave
  that would have persisted them had been refused that key for the whole run.
  They are now written under the conversation the run just became.
- A draft write that storage refuses (private mode, quota) left a generated
  paste with no record to discard it by. New Chat now also collects the live
  marked pastes the composer is still showing, skipping re-attached ones.
- With draft saving off, the reset path deleted files without awaiting or
  reading the response, so a failure orphaned the upload. It retains what the
  server did not delete, like every other deletion path.

* fix: keep reloading tabs live and spare pastes an active run is using

Three findings:

- pagehide cannot tell a reload from a close, and the tab id survives a reload
  on purpose, so releasing the claim there handed this tab's own draft to
  another one while the document was still bootstrapping. A closing tab is left
  to the ordinary liveness window instead, which is what the window is for.
  Entering the back-forward cache is still marked, since that heartbeat freezes.
- The text-ownership guard only covered the shared composer keys, but a
  conversation key is reachable from every tab viewing that chat and is stamped
  the same way, so one tab could overwrite text another was holding behind its
  attachments. The guard now applies to any key; the ownership stub is still
  only created for the shared keys, which tabs otherwise share freely.
- Submitting empties the file map but leaves the draft's paste provenance until
  the final SSE event, so New Chat during a streaming response treated the empty
  composer as still owning what the message had just sent and deleted files the
  message, and the run reading them, still referenced. The provenance promotion
  is skipped while a run is in flight.

* fix: give each tab its own presence record and publish live attachments

Four findings:

- Tab presence lived in one shared localStorage map, so two tabs beating at the
  same time read the same snapshot and wrote back rival copies; the loser
  disappeared until its next beat, long enough for another tab to treat its live
  draft as abandoned. Each tab now writes only its own key, and expired records
  are swept while reading.
- With draft saving off nothing is written to a draft at all, so a file
  reattached in another tab was invisible to a retry running here and could be
  deleted underneath it. A tab now publishes what its composers are holding into
  its own presence record, and cleanup unions that with the drafted ids.
- The record written when another tab owns the pending key kept only attachment
  ids, so a restored chip stopped being recognised as a paste and lost editing
  and cleanup. Provenance is rebuilt from the session registry. The unsent paste
  text cannot come along: this tab was refused that key all run, so it was never
  stored anywhere to carry.
- New Chat with draft saving off skipped every embedded record, leaving an
  unsent file-search paste with its metadata, storage and vectors intact. A
  paste this composer owns is now included with its real embedded value, while
  other embedded files are still left alone.

* fix: elect one cleanup worker, scope the queue to its account, guard edit writes

Three findings:

- Every mounted useNewChat (header, sidebar, mobile bar, shortcuts) entered the
  cleanup effect against one shared store, so a single retry issued the same
  DELETE several times and toasted about each. A pass is now claimed before it
  runs; an instance that is turned away asks for a later one rather than
  dropping the work.
- The retained queue outlived a sign-out, so the next account retried the first
  one's payloads, was refused by the ownership check, and rescheduled forever.
  Logging out clears the queue and cancels the pending retry.
- The paste path checks draft ownership before recording provenance, but the
  edit path did not, so a replacement could be written into a record another
  open tab owns, which that tab could then delete while this one still showed
  the chip. It takes the same check.

* fix: match paste identities everywhere and stop migrations clobbering a foreign draft

Five findings:

- The presence record published only composer map keys, but a restored upload
  is keyed by its temporary id while the value carries the server one, and a
  retained deletion in another tab names whichever it recorded. All three
  identities are published now, matching the local guard.
- Migrating a finished run checked that the pending record was ours but not the
  destination, so a conversation draft another tab owned with attachments on
  screen was overwritten and restamped. Both ends are checked, and the
  non-owner fallback no longer writes over a foreign destination either.
- The live-paste fallback matched the registry against file_id alone, so a
  completed paste, marked under its client upload id, read as somebody else's
  file and its upload survived New Chat. It matches every identity now.
- An upload still in flight has no filepath or source, so no discard path can
  build a payload and the reset drops the chip anyway. Its id is deferred so the
  record is deleted when it arrives, with draft saving on or off.
- An edited paste that had been staged into the code sandbox was re-uploaded as
  a plain context file, since only the file-search case was reconstructed.
  metadata.codeEnvRef is durable and now routes it back to execute_code.

* fix: silence background cleanup and keep cross-tab protection past a send

Four findings:

- The reset path matched the paste registry on file_id alone, the same alias
  gap already fixed in New Chat, so a completed embedded paste read as somebody
  else's file and survived with its vectors. It matches every identity now.
- The background cleanup pass used the ordinary delete mutation, so a storage
  failure that kept failing announced itself on every retry, and success
  arrived minutes after the action behind it. The mutation takes a silent
  option and the retry pass uses it; direct user actions still report.
- Each hook instance loaded the pending-discard list once and was never told
  when another instance wrote it, so work deferred by an instance that then
  unmounted stalled. Writes now notify every mounted instance, which re-read
  and apply only a real change.
- Cross-tab protection sampled only what a composer was holding right then, and
  sending clears both the map and the draft, so a file reattached in another
  tab and then sent could be deleted between retries. A tab now remembers what
  it recently held for ten minutes, which is long enough for the other tab's
  next pass to see it and cancel that deletion for good.

* fix: honour draft ownership in every clear and track what a message consumed

Five findings:

- The ownership contract was only applied at the new call sites; the SSE final
  event, the steering handoff and the debounced text clear still erased records
  through clearAllDrafts and clearDraft. The check moved inside those helpers,
  so every path that clears a draft respects it.
- Only the explicit logout cleared the retained queue, leaving a silent refresh
  that returns nothing and a failed user query to carry it into the next
  account. It clears wherever the session is lost instead, in the one place all
  three paths pass through.
- Using isSubmitting to decide whether a paste was consumed was wrong for a
  stopped or errored turn: those clear the flag without clearing the draft, so
  New Chat afterwards deleted files the turn already referenced. Submission now
  records the ids it took, and those are excluded by name.
- The presence sweep only ran from deletion cleanup, so a profile that never had
  a failed delete accumulated a record per tab until the origin quota ran out
  and draft writes began failing silently. The heartbeat sweeps.
- When a run finished into a conversation another tab owned, this tab's own
  queued text and attachments were dropped for want of a writable destination.
  They stay on the key it does own and are restored from there.

* fix: mint a tab id when the browser has no randomUUID

crypto.randomUUID is absent on insecure origins and in older webviews, and the
throw left the tab with an empty identity: every draft was then written without
an owner and every ownership guard read another tab's record as its own,
reinstating exactly the loss this layer exists to prevent. Falls back to
getRandomValues, then to a local mint. The id only has to tell tabs apart.

* fix: address PR review bot findings

Clear the retained deletion queue on every direct authentication exit, not
just the debounced context update: an empty or rejected silent refresh, a
failed user query, and the external-IdP logout all leave the page without
passing through setUserContext, so the queue survived into whoever signed in
next and retried under credentials the ownership check rejects forever.

Settle the edit lock when a replacement upload is aborted. Removing the
replacement chip mid-upload consumes the lifecycle through onAbort, which the
paste editor never handled, so the source paste kept its Edit and Move-back
actions hidden for the rest of the session and the typed correction was lost.

Keep the temporary-file cleanup payload for whatever the server reports as
failed. The delete route answers a partial storage failure with a 200 carrying
failedFileIds, and the cleanup mutation cleared FILES_TO_DELETE wholesale on
any success, dropping the only automatic retry those orphans had.

Persist both paste registries per tab. They lived in module-level sets, so a
reload kept the files draft but forgot the paste had been consumed, and New
Chat then classified an already-sent paste as unsent and deleted a file the
persisted message still references.

Withdraw discarded ids from tab presence. A removed, moved, or discarded chip
kept its recent entry for the whole window, and the retry sweep read that as
evidence the file had been reattached: it cancelled its own cleanup and left
the failed upload orphaned on the server. Presence records whose heartbeat
cannot be read are skipped rather than rewritten, since giving one a fresh
seenAt would revive a dead tab's claims over every id it still held.

* fix: address second round of PR review bot findings

Stop a settled deletion from undoing the logout clear. A DELETE that was
already in flight when the session ended settles afterwards, and its handler
is the last reference to that payload, so it wrote the departing account's
records straight back into session storage. Clearing now latches retention
shut and only a newly established session reopens it, which also covers the
paste editor's own retention and the discard paths, not just this one writer.

Reinsert a failed paste when nothing durable holds it. A composer the user
has typed into is deliberately left alone while a recovery record exists,
because that record restores at an anchored offset later. When the shared
draft key belongs to another live tab the guard skips the record entirely, so
the upload callback held the only copy and refusing dropped the text outright.
It now goes back in at the offset its anchors resolve to, which is where a
restore from a record would have put it.

* fix: address third round of PR review bot findings

Withdraw attachment presence from this tab only. The sweep cleared the
withdrawn ids out of every tab's recent map, which is the one record a second
tab has left once it has reattached a file and sent it: its composer and its
draft are both empty by then, so erasing that entry handed the next retry a
file it read as abandoned and let it delete the upload out of the message now
referencing it. The withdrawing tab always published what it withdraws, so its
own record is all it needs to touch.

Guard the direct New Chat deletion against other tabs. The retry effect
consults every other tab's drafts and published presence before deleting, but
the discard that runs on New Chat went straight to the request, so it raced
past that guard and could delete a file another tab still had attached or had
already sent. It now consults the same two sources, excluding its own draft
keys and its own presence record, which hold exactly what the discard is
throwing away.

Fix the import order in Presentation.tsx, which CI static checks flagged.

* fix: address fourth round of PR review bot findings

Read this tab's presence before sweeping stale keys. Timers pause while the
machine sleeps, so a live tab can beat again with its own record already past
the liveness window; the sweep reaped it and the write that followed published
an empty presence, and nothing republished it because the file map had not
changed. Another tab's retry then saw no claim on chips this one still had on
screen.

Keep submitted-use evidence when a later chip is withdrawn. The same file can
be sent on one message and reattached afterwards, and once the composer and
draft have cleared, its recent entry is the only cross-tab record that a
message still references it. Withdrawing a chip no longer erases an entry for
an id a submission already consumed; it ages out on the ordinary window.

Clear composer drafts when the account changes. A files draft carries the whole
text of a paste held as a file, and the browser tab keeps its identity across
an in-app account switch, so the ordinary draft restore could hand the next
account the previous one's writing. Both draft families are now dropped on the
sign-in and sign-out paths, ahead of the skipFirst exception.

Spare a submitted paste from the edit path's explicit deletion. Editing or
moving a reattached library file that an earlier message sent deleted the
server record underneath that message, because the draft-ownership check
succeeded and nothing consulted the submitted marker.

Validate an edited paste as a replacement rather than an extra file. The
original is deliberately still attached while the replacement uploads, so the
shared validation counted both and rejected the edit at the file-count or
total-size limit; with a limit of one, a lone paste could never be edited.

Preserve failed rows after a table deletion. The table's own cache update
removed every requested file without consulting failedFileIds, undoing the
partial-aware update and hiding a file whose storage delete had failed.

Document the two deliberate dependency omissions in AuthContext, which CI now
lints at zero warnings because the file is part of this change.

* fix: address fifth round of PR review bot findings

Clear composer drafts on the way out of a session, not only on the way in.
Clearing them from the login mutation missed social sign-in entirely: OAuth,
OpenID and SAML leave through direct links and come back through the silent
refresh, so a different account could arrive in the same tab with the previous
account's drafts and tab identity intact and have its paste text restored. The
draft clearing is now paired with the retained-deletion clearing in one helper
used by every authentication exit, so neither can be wired into a path the
other was missed from.

Rebuild paste provenance when restoring a queued upload. A paste queued during
a run has its pending draft taken by takeComposerDraft, so choosing Edit
message restored the upload into an empty composer with nothing recording that
it was a generated paste. Filtering existing provenance could not recover that,
and an unmarked restored chip is treated as a shared attachment: removing it
would not delete it and New Chat skipped it, orphaning the unsent upload. The
session registry still knows, so it is consulted.

Drop paste provenance when a rejected upload is removed. Validation can reject
a paste before it reaches composer state, and the failure path removes it with
removeFile, but the id stayed in pastedTextIds. That left a record
hasDraftAttachments reads as a real attachment claim with no chip behind it,
and with the file map unchanged nothing pruned it, so it locked every other tab
out of the shared composer key.

* fix: address sixth round of PR review bot findings

Centralise the foreign-claim guard. Every path that deletes an upload has to
ask whether another tab or pane still claims the file, and the guard was being
assembled by hand at each site, which is exactly why it was missing from three
of them. collectForeignAttachmentClaims now builds that set once, and the New
Chat discard, the no-draft reset fallback in useNewConvo, and the paste
editor's explicit deletion all consult it. A record another tab claims is
skipped rather than retained, since it was never this pane's to delete.

Scope presence withdrawal to the pane that owns it. One tab holds several
composers and the presence record is flat, so the hook that won the global
deletion pass swept every pane's entry while knowing only its own file map,
erasing the evidence of a chip a sibling pane still had on screen. Withdrawal
now takes the pane index, and an id another pane still lists keeps its recent
entry too.

Guard destructive draft clearing against text-only claims. claimComposerDraftTab
stamps a key that holds nothing but text, and the write guard ignores a claim
with no attachment behind it, so a tab finishing a run that began as an unsaved
chat cleared the shared new-chat key and took another tab's half-written
message with it. Clearing now honours any live foreign stamp, while text writes
keep their deliberate last-writer-wins behaviour.

Mark queued override files as submitted. A during-run queued message drains
through overrideFiles into the reuseFiles branch and skipped the marker loop
entirely, so reattaching that paste later left isPasteSubmitted false and New
Chat or an edit could delete a file the queued message still referenced.

* fix: address seventh round of PR review bot findings

Treat publishing an attachment as proof of liveness. The publisher carried the
old seenAt over, so a tab whose timers had been paused past the liveness window
published a chip and stayed expired until its next interval tick, long enough
for another tab's cleanup to sweep the record and delete the file under the
chip that had just appeared.

Count a sibling pane as a claim. The foreign-claim helper excluded this tab
entirely, so the pane doing the discarding could not see the other composer in
the same tab and deleted a file it still had on screen. Live claims are now
gathered per pane: only the discarding pane's own entry is left out, along with
this tab's recent map, which is flat and cannot say which pane an id came from.

Keep a tab identity when session storage is unusable. It can be blocked or full
while localStorage still works, and returning an empty id left the document
unattributed, which every ownership and liveness guard reads as no owner, so
tabs could destructively clear each other's attachment-backed drafts. An id
that lives only for this document still tells the open tabs apart.

Scope attachment withdrawal in the deletion hook to the originating pane, and
thread the composer index through ChatForm, FileFormChat and FileRow to supply
it. Removing a file from one side-by-side composer withdrew the id for every
pane, and with drafts off the sibling never republished its claim.

Check foreign claims before deleting a live edit source. The guard only covered
the restored path, because detach returns early for an in-memory upload before
reaching it, so editing a live paste another tab had reattached from the library
deleted the file underneath that tab's chip.

Keep autosaving to the pending key while the destination is not writable. The
preserved queued work was written under the pending key but the destination was
still recorded as the active conversation, so later edits autosaved against a
foreign key and a reload mounted straight onto the destination, losing the work
that had just been preserved.

* fix: address eighth round of PR review bot findings

Make submitted-use evidence durable and readable across tabs. The tab that
retries a retained deletion is rarely the tab that sent the message, and this
evidence lived in the sending tab's session storage, which left published tab
presence as the only cross-tab record. That ages out on a fixed ten-minute
window, so a retry resuming after a longer freeze classified a sent file as
abandoned and deleted it out of its message. It is timestamped in localStorage
now, with a horizon wide enough to outlast any plausible freeze and a hard cap
so a long-lived profile cannot grow it without bound. Paste provenance stays in
session storage, since which chips offer the paste affordances really is
per-tab.

Tie a blocked pending draft to its intended destination. Keeping the pending
key active while a live tab owned the destination left the pending state with
no memory of where it was heading, so any later navigation looked like the
awaited transition and carried the queued text and attachments into an
unrelated conversation.

Defer an in-flight paste on direct conversation resets. Callers that reach
newConversation without going through New Chat left an upload with no filepath
yet unrecorded, so once the request landed nothing remained to delete the
server file.

* fix: restore the composer clear after send

CI e2e caught this: after sending a message with an attachment the chip stayed
in the composer, so the sent message and the composer both showed it.

Two causes, both from keying composer storage off state that lags a render.
`currentConversationId ?? conversationId` is the previous conversation during
every transition, so the file-cache restore ran against the outgoing key and
put the just-sent attachment straight back into the map that the submit had
cleared. The active key is now the conversation unless the switch effect has
deliberately parked storage on the pending key.

Separately, treating any first mount as the awaited pending transition ran the
pending migration on every direct load of a conversation. That is narrowed to a
pending record this tab owns which actually holds something, which is what a
reload with real queued work looks like.

Verified against the two failing specs locally, then the whole mock chat spec:
8 passed.

* fix: address ninth round of PR review bot findings

Stop expiring submitted-use evidence on a timer. The work it has to outlast is
a retained deletion, and those carry no expiry of their own, so any interval
chosen could be outlived by a suspended tab still holding cleanup work, which
is the same bug with a longer fuse. The ledger is bounded by count instead,
evicting oldest first only when it would otherwise grow without limit.

Consult that ledger before retrying a deletion. The retry pass built its
protection set from drafts and published presence only, both per-tab and
time-bounded, so a file sent from a tab that has since been suspended had
nothing left to speak for it. The record is resolved before judging, because
the discard is often keyed by the temporary upload id while the pane that sent
it marked only the server id.

Refresh liveness when withdrawing presence, matching the publication side. A
retained-deletion pass resuming after paused timers withdrew its own entry and
then swept the record as stale, taking sibling panes' claims with it.

Keep queued attachments when neither draft key is writable. With another tab
owning the pending key and a second owning the destination, the effect cleared
the live map and could persist it nowhere, so unsent attachments vanished the
moment the run got its conversation id.

Remove the replacement provenance when an edited paste is not accepted. The
edit path records the replacement id before routing the upload, and a rejected
upload left a provenance-only draft that reads as a live attachment claim with
no chip behind it.

Verified with the mock chat e2e spec after rebuilding the frontend: 8 passed.
2026-08-25 20:06:59 -04:00
Marco Beretta
21ba9d3f30
🔁 fix: Rerun a Message the Editor Has Not Changed (#15212)
* fix: allow rerunning a message the editor has not changed

The submit button in both message editors was disabled until the draft
differed from the persisted message, so reissuing a request after a
cancelled response, a failed generation, or a backend restarted on
different parameters meant typing a throwaway character and deleting it
first.

The button now reads "Rerun" while the draft is untouched and "Update &
rerun" once it differs, and neither state disables it. An untouched
assistant turn regenerates instead of replaying its own content as an
edit: editedContent retains the existing content and appends the new
completion, so replaying it would return the old answer with a second one
glued onto it.

Two ask options were dead on arrival. editedText was declared on TOptions
and passed by EditMessage but never destructured by ask, and
isResubmission was never set or read anywhere. Since no submission can
carry a text-level edit, and editedMessageId regenerates the row in
place, an assistant turn in the plain-text editor now always reads
"Rerun" and its status slot says where an unsaved edit is about to go.

Fixes #15205

* fix: address PR review bot findings

chatgpt-codex-connector:

- Route a plain-text assistant rerun through regeneration. It kept the
  edit-resubmission options, so it replaced the response in place, and
  with no targetResponseMessageId the submission resolved the NEWEST
  answer for that turn: rerunning an older sibling pruned the wrong
  subtree from the optimistic thread while appending a placeholder keyed
  to the older sibling's own id. It now sends isRegenerate with
  targetResponseMessageId, matching the hover action and
  EditContentParts, and leaves the sibling index alone.

- Let an empty answer reach the rerun handler. The field is registered as
  required so Save cannot blank a message, and routing the rerun through
  handleSubmit meant a response cancelled before its first token had an
  enabled button that did nothing. The answer's draft is never submitted,
  so it no longer gates the rerun or the disabled state.

The status hint changes with the behavior: rerunning discards an unsaved
answer edit and generates a new response rather than replacing this one.
2026-08-25 20:04:02 -04:00
Danny Avila
e9936b8ad2
🎢 fix: Restore Schedule Dialog Scrolling So Save Stays Reachable (#15225) 2026-08-25 19:58:45 -04:00
Danny Avila
6d499ba3ce
fix: Anchor Resumed Elapsed Time at the Generation's Real Start (#15204)
Some checks are pending
Publish `@librechat/client` to NPM / pack (push) Waiting to run
Publish `@librechat/client` to NPM / publish-npm (push) Blocked by required conditions
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Publish `@librechat/data-schemas` to NPM / pack (push) Waiting to run
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
*  fix: Anchor Resumed Elapsed Time at the Generation's Real Start

A reload emptied the Recoil anchor, so the indicator fell back to its
mount time and visibly reset to 0s over a run that had been generating
for much longer. The stream status the resume path already reads carries
the server-recorded generation start; the fill now prefers it, so a
reattached run reports real elapsed time. The fill remains fill-only:
a same-session reattach keeps its original ask baseline, and the
indicator's existing clamp absorbs any client/server clock skew.

* 🕰️ fix: Rebuild the Resumed Baseline From the Server-Computed Age

Codex round 1: anchoring at the server's raw createdAt compares two
clocks — a client behind the server froze the resumed reading at 0s for
the skew, one ahead inflated it. The status route now also reports the
generation's age computed on its own clock, and the client rebuilds a
clock-local anchor as Date.now() minus that age, so each machine only
ever compares to itself. Raw createdAt stays as the fallback for an
older server mid-rolling-deploy.

* 📥 fix: Compute Elapsed Age in TypeScript, Anchor It at Status Receipt

Codex round 2: the elapsed computation moves into packages/api as
getGenerationElapsedMs — the route now just delegates, keeping the
response contract type-checked and the /api surface a thin wrapper —
and the client subtracts the age from the moment the status response
arrived (dataUpdatedAt) rather than from apply time, so a slow history
fetch between receipt and apply can no longer shrink the reading.
Declined with rationale: a shared clock source across replicas — the
residual is inter-replica NTP drift, milliseconds against the
minutes-scale client skew this PR eliminates, and the helper gives any
future shared-clock upgrade a single home.
2026-08-25 09:35:10 -04:00
Danny Avila
e9dec7749a
🕶️ fix: Unhide Event Subagent Names on the Dark Surface (#15206)
The event subagent group's child rows are raw buttons with no text color
of their own, and the section root set none either, so the agent name
labels inherited straight from the unthemed black body color — invisible
on the dark surface (and silently off-token in light mode: pure black
where --text-primary is 33 33 33). The slug and status lines carried
explicit text-text-secondary, which is why only the names vanished.

Root gets text-text-primary, matching SubagentActivity and
SubagentThreadPanel, so every descendant inherits the theme role and the
rows' secondary lines keep their explicit overrides. Verified against
the live cascade: the label computes rgb(0,0,0) in both modes today and
the token color (236/236/236 dark, 33/33/33 light) with the root themed.
2026-08-25 09:25:06 -04:00
Marco Beretta
bf6144c9e1
🎛️ fix: Withhold the Seeded Model Catalogue Until Models Resolve (#15035)
`useGetModelsQuery` seeds from a static fallback config, so `modelsQuery.data`
describes a hardcoded model list both before the mounted fetch resolves and
after it fails outright. The agent builder read that seed as authoritative and
offered models the active server configuration never exposed.

Blank the catalogue until the mounted fetch actually succeeds, surface the
failure in the model panel instead of silently falling back to the seed, and
refuse to create an agent against a provider/model pair the resolved catalogue
does not offer.

Also wires the builder's orphaned `htmlFor` labels to the controls they name.
2026-08-25 08:58:02 -04:00
Danny Avila
16dd677be4
🎨 style: Set Question Popover and Subagent Panel on the Sidebar Surface (#15201)
Both floated over the chat on surfaces one step too close to it — the
question popover on surface-secondary, the subagent thread panel on the
chat's own surface-primary. Both now use surface-primary-alt, the
conversation-list sidebar's role, verified in the running app: popover,
panel, and sidebar all resolve to the same computed background in dark
(rgb 23,23,23) and light (rgb 247,247,248). Inline question cards keep
surface-secondary deliberately — that is the tool-record family's
surface, and settled questions collapse into that family.
2026-08-25 08:29:42 -04:00
Ravi Kumar L
3d2da403ce
🥛 fix: Drop Stale Saved Model Defaults in Builder Forms (#15179) 2026-08-25 08:18:17 -04:00
Danny Avila
4b113697b5
🔌 feat: Background Execution Toggles for Actions & Plugin Tools (#14407)
* 🧵 feat: Background Execution Toggles for Actions & Plugin Tools

* 🩹 fix: Resolve action background opt-in across encoded-domain forms and scope it per action

* 🧹 refactor: Resolve action domain in a single pass

* 🧩 fix: Merge Normalized Action Background Options

* 🪢 fix: Reconcile Action Background Aliases

* 🧭 fix: Harden Action Background Compatibility

* 🕰️ test: Allow Settled Task TTL Expiry

* 🧬 fix: Merge Refreshed Action Tool Registrations
2026-08-25 08:13:13 -04:00
Danny Avila
cc0111b3cf
📐 fix: Set the Elapsed Reading on the Column Its Neighbors Share (#15195)
The timer sat at the footer's flush left while everything around it is
inset 6px: the streaming dot pads (24 − 12) / 2 to center on the size-6
header icon's axis, and the hover-button glyphs that replace the timer
sit behind their own p-1.5. The same ps-1.5 inline-start inset lines the
reading up with the dot above it and the glyphs that follow it — measured
in the live app: timer x 382, dot x 382, first settled glyph x 382.
2026-08-25 07:52:37 -04:00
Danny Avila
a9d99b3771
📌 fix: Keep the Settled Turn Mounted Through Final Content Compaction (#15186)
* 📌 fix: Keep the Settled Turn Mounted Through Final Content Compaction

The agent aggregator writes content parts at provider-source indexes, so
the streamed array is sparse wherever a step produced nothing; the final
SSE event carries the persisted, compacted array. Adopting it verbatim
shifted every part after a hole, re-keying every index-derived React
identity: the settled message remounted wholesale, activity-phase groups
replayed their fold-in entrance, code panes re-highlighted, and the
thread visibly snapped up and down at the end of every tool-calling run.

finalHandler now pairs the compacted parts with their streamed
counterparts in order and stamps each with the index it streamed at
(`streamedIndex`, client-only); render keys read the stamp while all
coordinate logic (edit indexes, phase bounds, cursor) stays on the live
compacted positions the server persisted. Phase-segment keys also anchor
to their first defined part instead of the segment ordinal, since
phantom hole-only segments vanish at compaction and shifted every
segment after them.

* 🔁 fix: Carry Identity Stamps Through Re-Delivered Finals and Parallel Attribution

Codex round 1, both real:
- P1: a later final event can re-deliver an already-settled message as a
  fresh compact array (Assistants runMessages resync); index-aligned
  pairing returned it unstamped, wiping the previous settle's stamps and
  re-keying the older turn all over again. The pairing now carries the
  matched current part's stamp forward, so a settled turn keeps its keys
  through every subsequent final.
- P2: ParallelContentRenderer's sequential stretches invoked
  renderResumeAttribution with only the live index, so steer attribution
  nodes in parallel content still re-keyed at the swap. The stable key
  index now threads through both call sites; getPartKeyIndex moves to
  utils/messages beside the stamp writer it reads.

* 🧿 fix: Require Content Agreement Before Pairing Streamed Identity

Codex round 2 (P2, real): hide_sequential_outputs runs omit intermediate
parts from the final array, so a type-only match could hand the retained
output an omitted intermediate's identity — transferring its key and any
UI state. Non-tool pairing now requires content agreement: mutual-prefix
text for TEXT/THINK/ACTIVITY_LABEL (one side extending the other is the
same part observed at two moments), the Open Responses phase for TEXT,
and the label kind for activity labels — a blank reservation still pairs
with its filled label. Ambiguous shapes fall back to the pre-stamp full
re-key, which is honest for a final that visibly removes parts.

* 🪢 fix: Refuse Stamping When the Server Removed Content; Strip Stamps on Edited Reruns

Codex round 3, two of three real:
- Prefix agreement alone still mis-paired when an omitted intermediate
  happened to prefix the retained output. Pairing now also requires that
  no substantial streamed part is left over: leftovers mean the server
  removed content (hide_sequential_outputs), so every in-order pairing
  is suspect and the message re-keys plainly instead.
- An edited resubmission clones the settled (stamped) prefix and appends
  the rerun's parts at the prefix length; a retained stamp at or above
  that length collides with an appended part's key. The clone now strips
  the client-only stamps, reverting the retained prefix to physical
  identity for the rerun.

The third finding (content-segment keys under late-phase recovery) is
declined with rationale on the PR: user expansion overrides survive via
the message-wide expansion map with stable group ids, recovery is a
genuine restructure at the moment a phase materializes, and first-child
anchoring is the only choice stable under the two high-frequency events
(streaming appends and final compaction).
2026-08-25 06:26:17 -04:00
Danny Avila
ac2aef00f6
🫗 fix: Drain Quoted Excerpts Into Mid-Run Steering (#15175)
* 🧭 fix: Carry Quoted Excerpts Through Mid-Run Steering

"Add to chat" quote chips were dropped by every during-run steer path: the
steer POST had no quotes concept, so a composer-origin steer left the chip
staged (gluing onto the NEXT send) and a queued item steered into the live
run lost its quotes silently.

Quotes now ride the steer protocol end to end:
- POST + admission: `quotes` on the steer body, normalized like the chat
  route's (getReferencedQuotes caps), part of the idempotency fingerprint
  only when present so pre-existing receipts still replay.
- Injection: merged into the model-bound turn as Markdown blockquotes at
  both boundaries (text-only and media paths), mirroring prependQuotes.
- Persistence + replay: the STEER content part stores `quotes` separately
  from the typed text; stampSteerPartMedia re-merges them per turn (even
  with resendFiles off) via the SDK's transient media stamp, with the quote
  block folded into the token budget.
- UI: composer steers/interrupt-steers drain the chips (skill picks stay
  staged — they configure a NEW turn's run); SteerPart and the in-flight
  bubble render the same MessageQuotes reference blocks as user bubbles;
  queued/failed rows show a quote count; reconnect reseeds fall back to the
  server item's quotes when no local chip survives.
- buildMessages keeps its zero-await path to the parallel context kickoff
  via a synchronous stamp-target probe.

* 🧭 fix: Keep Quotes in the Client-Safe Steer Projection

toPendingSteer is the projection behind resume-state pendingSteers, abort
responses, and terminal leftover claims — dropping quotes there would lose
them on exactly the recovery paths the reconnect reseed's server fallback
relies on.

* 🧪 test: In-Flight Steer Bubble Renders Carried Quotes

* 🔁 fix: Re-Stage Quotes When a Pre-Quotes Replica Accepts the Steer

Codex flagged the rolling-deploy window: an old replica 202s a quoted steer
while dropping the excerpts, so the client cleared the chips for context the
model never received.

The 202 (fresh and receipt replay) now echoes quotesAccepted from the
DURABLE item; a missing echo on a quote-bearing composer-origin steer
re-stages the excerpts as composer chips — the pre-steer behavior, so they
ride the next send instead of vanishing — and strips them from the surviving
chip so a later terminal conversion cannot duplicate them. Queued-origin
steers keep quotes on the item, whose restore paths already return it
intact. The residual cross-version lost-ACK retry stays fail-closed as a
409 idempotency conflict (failed chip with retry controls).

* 🔁 fix: Close the Remaining Cross-Version Quote-Loss Windows

Codex round 2:
- Send now of a quoted queued item against a pre-quotes replica now
  re-stages the excerpts too (the row is consumed and the words inject
  bare, so the composer is their only remaining home); the strip clears the
  chip's captured origin copy so reclaims and terminal conversions cannot
  duplicate them.
- A quoted retry whose lost first ACK was accepted by a pre-quotes replica
  now REPLAYS that legacy receipt instead of 409ing: the stored fingerprint
  matching the quote-less hash of the same words proves the cross-version
  case, and the replayed 202's missing echo drives the re-stage. Different
  quotes against a quote-bearing receipt still conflict.
- TSteerAppliedEvent.part gains the quotes field (typed SSE consumers).

* 🧪 test: Drop the Stale Narrow SteerDrainOutput Alias

The spec's local intersection re-declared injectedMessages with
content: string, predating the SDK pin that declares the field natively
(content: string | MessageContentComplex[]). Under CI's clean install the
hook's BaseHookOutput is no longer assignable to that narrower alias; the
plain PostToolBatchHookOutput is the correct type for every drain/boundary
assertion. Verified against the published 3.6.16 dist and the local one.

* 🔁 fix: Honor the Generation Owner's Quote Capability End to End

Codex round 4:
- steerQuotesCapable rides job metadata (createJob + HITL resume rewrite),
  mirroring preemptCapable's owner-recorded pattern: an upgraded admission
  replica no longer stores quotes — or claims them accepted — for a
  generation whose older owning drain would silently drop them at
  injection. The missing echo drives the client re-stage, and a later
  capable handover cannot double-deliver restored context.
- Applied events reconcile dropped quotes: when a quote-less applied part
  settles a quote-bearing chip (the lost-202 ordering the ACK-echo path
  cannot see), resolveSteerChip and both reconnect settle paths re-stage
  the chip's excerpts before removing their only copy. mergeRestagedQuotes
  dedupe keeps every trigger idempotent for the same excerpts.

* 🔁 fix: Re-Read Quote Capability at the Last Moment and Cap Restaged Chips

Codex round 5:
- A HITL resume rewrites steerQuotesCapable without changing the
  generation's createdAt, so the enqueue fence cannot see a
  capable-to-legacy handover landing during admission's awaits. Re-read
  the owner's flag immediately before item construction (paid for only by
  quote-bearing requests); the residual between re-read and enqueue commit
  matches preemptCapable's documented race.
- mergeRestagedQuotes now respects the 10-quote contract with the staged
  chips winning: a restored tail that cannot ride the next send is dropped
  explicitly instead of rendering as a chip the submission would silently
  discard. MAX_QUOTE_COUNT moves to utils/steer as the single client
  source; QuoteButton imports it.

* 🔁 fix: Steer Quote Coverage for Preflights, Memory, and Single-Scan Stamping

Codex round 6:
- Stored-message policy inspection now extracts steer-part quotes as quote
  fragments (path /content/N/quotes/M), so conversation import and shared
  link preflights inspect the newly persisted field exactly like top-level
  message.quotes.
- The memory copy gets its own quote-merge stamp (text only, resendFiles
  false): formatAgentMessages ignores part.quotes, so without it a steer
  whose substance lives in its excerpt reached the chat model but never
  memory extraction.
- collectSteerStampTargets replaces the boolean probe: buildMessages
  collects once and hands the targets to stampSteerPartMedia, keeping the
  zero-await fast path without scanning the history twice.

* 🔁 fix: Redis Quote Plumbing, Conversion-Race Guard, and Quote-Bound Recovery Proof

Codex round 7:
- RedisJobStore.deserializeJob now restores steerQuotesCapable (the explicit
  mapper otherwise dropped it on every read, leaving quote steering inert in
  Redis deployments), with the round-trip spec extended.
- Both Lua parked-steer projections (terminal close + generation
  replacement) forward item.quotes, matching toPendingSteer — a lost final
  no longer strips excerpts from durable recovery in Redis mode.
- The no-echo restage reads the SURVIVING chip (reclaimRejectedChipQuotes):
  a terminal conversion that beat the delayed 202 already moved the quotes
  onto the queued follow-up, and re-staging them again double-delivered.
  Regression-tested with the conversion-before-ACK ordering.
- RecoveredSteerPayload binds normalized, order-significant quotes (builder,
  validator, TS matcher, and the Lua decode+matcher): a stale client
  presenting the same recoverySteerId with altered or missing quotes cannot
  consume the parked source. Quote-less sources keep matching quote-less
  recoveries.

* 🔁 fix: Execution-Bound Quote Capability with an Atomic Enqueue Predicate

Codex round 8:
- steerQuotesCapable becomes a transient assertion translated (at createJob
  and in ApprovalLifecycle.resolve) into steerQuotesExecutionId, valid only
  while it equals the LIVE providerExecutionId. A legacy replica winning a
  HITL resume rewrites the execution id without knowing the marker, so its
  stale assertion self-invalidates — a bare boolean could not be cleared by
  code that predates it.
- The fenced enqueue evaluates that equality atomically (all three Redis
  scripts decode-and-strip like the existing preemptCapable normalization;
  both InMemory sites mirror it) and returns the persisted item, so the
  quotesAccepted echo reflects exactly what was stored even when a handover
  lands between admission's read and the commit. The last-moment re-read is
  gone — the transaction is the authority.
- Tests: capable-resume re-binding, legacy-resume omit-not-clear
  invalidation, the admission-vs-handover race (capability read true, then
  execution rewritten before enqueue), and the Redis round-trip of the
  marker.

* 🔁 fix: Full Redis Parking Coverage and Loss-Moment Quote Restaging

Codex round 9:
- The two remaining Redis parking projections (terminal status CAS and
  stale-running cleanup) forward item.quotes — every field-picked steer
  projection now carries them (audited: 2 Lua 'projected' + 2 Lua
  'clientItem' + toPendingSteer).
- The ordinary no-echo ACK no longer re-stages: the steer has not injected
  yet, so the quotes stay carried on the pending chip. A quote-less applied
  event re-stages them at the actual loss; a terminal leftover conversion
  carries them onto the recovered row, whose normal send delivers quotes on
  any server — re-staging at the ACK let that leftover auto-send bare text
  while the excerpts glued onto an unrelated draft. Only the settled
  receipt replay (already injected, no future event) reclaims immediately.

* 🔁 fix: Legacy-Replayable Receipts with Separate Quote Identity

Codex round 10: an upgraded-first receipt stored a quote-inclusive
fingerprint no pre-quotes replica could recompute, so a lost-ACK retry
routed through one 409'd already-accepted words with duplicate-send
controls.

The durable fingerprint reverts to the quote-independent 3-field hash —
the one shape EVERY deployed version computes, replayable across a rolling
deploy in both directions — and quote identity moves beside it as
requestedQuotesFingerprint (of the REQUESTED quotes, pre any capability
strip, so an incapable-owner acceptance still replays its own retries).
Absent records (legacy-written or quote-less) accept any same-words retry,
preserving the round-5 rule; present records must match exactly, keeping
different-quotes clientSteerId reuse a 409 on quote-aware readers. Under
the keep-on-chip client contract a legacy replay's missing echo is
harmless — the excerpts stay carried on the pending chip.

* 🧪 chore: Re-Trigger CI After Dropped Workflow Events
2026-08-24 22:29:13 -04:00
Danny Avila
c0a55aa0f5
🧮 fix: Currency-Safe Single-Dollar LaTeX via Micromark Tokenizer (#15181)
Replaces the preprocessLaTeX string pass, whose currency allowlist missed
suffixes like "$2bn", letting SINGLE_DOLLAR_REGEX rewrite "$2bn to at
least $4bn" into $$-math. Single-dollar math is now a micromark text
construct (client/src/utils/latex.ts) registered by remarkSingleDollarMath,
so each span is decided during parsing with Pandoc-style boundary rules:
non-space after the opener, non-space before and no digit after the
closer, single-line, no backticks, opaque backslash escapes, balanced
braces, and fail-fast on an invalid close so a later price dollar can
never extend a span. Rejected spans stay byte-identical text, and code
spans, fences, and autolinks are structurally protected by the parser.

The LaTeX parsing setting now gates only this plugin; $$, \(...\), and
\[...\] continue to parse unconditionally via remark-math (aliased to
micromark-extension-llm-math, now mirrored in jest moduleNameMapper so
tests exercise the production tokenizer). katex/contrib/mhchem is loaded
with the markdown config, so \ce/\pu render properly instead of being
regex-mangled. splitMarkdown aligns its math options with the renderer.
2026-08-24 22:29:02 -04:00
Danny Avila
6988ff5d7b
✂️ fix: Unclip the Share Dialog's Public Role Menu (#15177)
PR #14734 replaced PublicSharingToggle's hand-rolled reveal (which set
overflow: visible while open) with the shared Collapse, whose permanent
overflow-hidden shears the non-portaled access-roles menu to a sliver.
Adds an opt-in overflowVisibleWhenOpen prop to Collapse — clipped while
closed and during the closing tween, unclipped once open — so in-tree
popovers can escape; the menu stays non-portaled because portaled menus
inside modal OGDialogs land aria-hidden and get focus-yanked shut.
2026-08-24 20:59:07 -04:00
Danny Avila
69e7c73614
🎛️ feat: Expose Authoritative Subagent Controls (#15169)
* feat: expose authoritative subagent controls

* fix: reconcile subagent control races

* fix: reconcile durable control conflicts

* fix: preserve authoritative subagent control outcomes

* fix: fence subagent controls to child thread

* fix: validate subagent control targets before routing

* fix: close subagent control boundary gaps

* fix: keep control reservations private

* fix: close subagent control admission gaps

* fix: preserve authoritative control history

* style: sort subagent control imports

* fix: preserve authoritative subagent control retries

* style: sort control state imports
2026-08-24 20:37:49 -04:00
Marco Beretta
649e68170e
🖼️ refactor: Consolidate Provider Icons Into a Single Registry (#15148)
* test: make useIsActiveItem observer assertions deterministic

The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.

Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.

This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.

* feat: add canonical ProviderId vocabulary and resolver

* feat: resolve custom endpoint provider identity at config load

* feat: add provider icon registry data

* feat: add ProviderIcon and ProviderAvatar components

* feat: add provider icon resolution hook

* refactor: migrate direct icon lookups to the provider registry

* refactor: migrate composite endpoint icons to the provider registry

* refactor: render message provider icons from the registry

* refactor: remove the duplicated endpoint icon maps

The model selector was the last consumer of the icons map, so it now
resolves art through the provider registry like every other icon call
site. That leaves getIconKey with no callers, and the five icon map
types it depended on with no references, so all of them go too.

* fix: address Codex review findings on provider icons

Move brand tile colors onto theme tokens, accept relative image paths,
pass endpoint config into message icon resolution, keep Cohere padding
on landing only, render configured image URLs in provider-only
consumers, preserve the Gemma label, and publish provider assets with
the shared client package.

* fix: address remaining Codex findings on provider icons

Keep monochrome art white on branded avatar tiles, inline provider
assets as module data URLs so ProviderIcon works outside the SPA, and
recognize api.cohere.ai when resolving custom endpoint brands.

* fix: address the latest Codex review notes

Stop inlining provider logos into the shared bundle, keep agents and
assistants marks on group icons, reject CSS appended to brand
gradients, give brand tokens hex fallbacks for package consumers, and
treat data image URLs as configured artwork.

* fix: honor native provider and theme-controlled avatar contrast

Use an explicit custom-endpoint provider when host branding misses,
keep agents and assistants marks on model specs, and drive branded
avatar foreground from a theme token instead of a raw white class.

* fix: tighten brand validation and inherit SVG fill color

Forward the computed color class into provider SVGs, accept only a
single balanced gradient for brand backgrounds, keep provider
foreground hex-only, recognize relative image fragments, and preserve
percentage sizing in URLIcon fallbacks.

* fix: keep EndpointIcon hook-free and accept protocol-relative icon URLs

useMentions.ts invokes EndpointIcon({...}) as a plain function in seven
places, inside useMemo mappings and a React Query select callback, so the
useProviderIcon call added to it ran a hook outside a render and threw
"Invalid hook call" as soon as the mention list was built. It now uses the
hook-free resolveProviderIcon, and a spec pins the imperative-call contract
those call sites depend on.

isImageURL explicitly rejected protocol-relative URLs, so an endpoint or
model group configured with //cdn.example.com/provider.png fell through to
provider resolution and rendered the generic mark, where the removed
UnknownIcon rendered any nonempty custom iconURL. A leading // followed by
a host is now an image; a bare // or /// still is not.

The ConvoIcon spec's two cohere conversations move to one shared fixture,
since ProviderId.cohere is not an EModelEndpoint and a single-step
assertion to TConversation failed the client type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv

* fix: annotate themeBrandTokens for isolatedDeclarations

packages/client compiles with isolatedDeclarations, under which
`as const satisfies` is not an explicit type annotation, so the emitted
declaration could not be produced from the initializer alone.

This never surfaced before because the "Type check @librechat/client"
step only runs after "Type check @librechat/api", which was failing on
dev's Agents SDK issue and skipping it.

Annotated as readonly (keyof IThemeBrands)[] and frozen, matching
themeColorTokens directly above it. Both consumers only call .includes()
and .map(), so no literal tuple type is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv

* fix: keep nested provider SVGs at their span's size

ProviderIcon sizes component art with an outer span carrying an inline
width/height, then rendered the SVG with cn('h-full w-full', classes).
Because cn is twMerge, a caller's own sizing class won that merge, so the
fraction applied twice: Landing passes size={41} with h-2/3 w-2/3, ConvoIcon
scales to a 27px span, and the SVG then took two thirds of that again, ~18px
where it used to be ~27px.

Only component-backed providers regressed. The asset branch has no wrapping
span, so its fraction still resolves against the 40px container.

Reordering the merge makes the span's size authoritative while leaving every
other caller class in place, including the [color:inherit] that branded
avatars forward. The img branch keeps resolving against its parent, so its
size is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv

* fix: close the image-format and provider-host tables

Two allowlists that the refactor narrowed, fixed as sets rather than one
entry at a time.

isImageURL's extension list had grown by patch four times, each round
restoring one form the old renderer accepted. It now carries every format
browsers actually render, so avif joins apng, bmp, cur, jfif and the jpeg
spellings in a single pass.

The host table had no Azure entry, so an OpenAI-compatible endpoint on
team.openai.azure.com fell through to the generic mark; the custom schema
cannot express provider: azure, so host was its only signal. Both supported
Azure suffixes are added, and enumerating ProviderId against the table
surfaced Google as the same gap, which is added too.

Bedrock, mlx and ollama are the remainder and cannot be host-resolved:
bedrock's hostname is region-scoped under a shared AWS suffix, and the other
two are served from the operator's own machine. That is now recorded next to
the table and pinned by a test, so a provider added later without a host
fails rather than silently rendering the generic mark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 13:00:38 -04:00
Danny Avila
e0d5e11cdf
⏱️ feat: Show Elapsed Time Under the Streaming Response (#15167)
* ⏱️ feat: Show Elapsed Time Under the Streaming Response

A minimalist elapsed-time indicator (5s, then 1m 5s) occupies the footer
slot the hover actions vacate while a response generates, anchored to a
per-index submission-start timestamp so remounts (new-conversation id
hydration, navigation) never reset it. The once-per-second tick is
component-local state, so streaming rows never re-render on its account.

* 🧭 fix: Keep the Original Elapsed Baseline When Reattaching a Stream

Codex round 1: resume-on-load restamped the anchor at reattach time, so
navigating away from a still-streaming conversation and back restarted
the reading at 0s — the exact reset the atom exists to prevent. Resume
paths now leave the anchor alone: a same-session return keeps its ask
baseline, and a reload (atom empty) falls back to the indicator's mount
time, which is what the stamp produced anyway.

* 🪗 fix: Scope the Elapsed Timer to Its Own Generation, Localized and Spoken

Codex round 2, all four findings:

- The anchor is cleared on every terminal path (final, error, abort
  fallback), and resume-on-load only fills an empty one — so a run another
  client started never inherits a stale baseline, while a same-session
  reattach still keeps its original start.
- The indicator additionally requires the newest sibling position:
  latestMessageId follows the selected branch, so a settled older sibling
  paged to mid-regeneration satisfied the latest+submitting gate and got a
  counting timer under settled content.
- Visible digits now come from the shared run-step duration formatter
  (Intl.NumberFormat per locale), replacing the raw-number interpolations.
- The compact reading is aria-hidden with a spoken 'N seconds elapsed'
  equivalent beside it, per the house duration-label pattern; still no
  aria-live, so the tick never announces.
2026-08-24 11:36:36 -04:00
Danny Avila
6a7da61234
🥸 chore: Resolve Agents SDK Path Aliases That Masked Backend Types (#15160)
* 🐛 fix: Restore Agents SDK Type Resolution in Backend Type Checks

* 🐛 fix: Preserve Typed Prompt Callback Assignability

* 🐛 fix: Accept Agents Function Tool Calls in isImageVisionTool

* 🐛 fix: Prove the Run Step Wire Contract at Compile Time
2026-08-24 08:38:30 -04:00
Danny Avila
8773b36eec
🎽 fix: Commit Subagent Roster Selections to Form State (#15154)
* fix: persist subagent selections synchronously

* test: verify subagent roster form state

* style: sort subagent roster test imports
2026-08-24 03:21:48 -04:00
JOJO
092bc583a8
📭 fix: Detect Agent List Pages in useHasData (#15156)
* 🐛 fix: Detect `AgentListResponse` data in `useHasData`

The marketplace agent queries return `AgentListResponse` pages whose
agents live under the `data` field, but `useHasData` only checked for
a non-existent `agents` field, so it always returned `false` for real
agent list pages. Check the `data` field first so cached list pages are
recognized as meaningful data.

* fix: preserve SmartLoader type narrowing

* fix: retain cached agents during refetch

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-24 02:45:59 -04:00
Danny Avila
c7e8b45419
🪶 refactor: Polish Event Subagent Activity (#15152)
* fix: polish event subagent activity

* chore: satisfy static checks

* fix: close subagent activity review gaps

* test: satisfy activity selection types

* fix: preserve subagent group layout scope

* fix: close subagent activity polish gaps

* fix: narrow edited activity anchor id

* feat: present subagent turns as one thread

* fix: keep subagent timeline pinned

* fix: render sparse assistant content

* fix: retain sparse initial activity cursor

* fix: bound continuous subagent history

* fix: type timeline prefix

* chore: sort timeline imports
2026-08-24 02:24:30 -04:00
Marco Beretta
4d8f145526
⏱️ test: Make Active Item Observer Assertions Deterministic (#15147)
The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.

Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.

This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.
2026-08-23 23:16:14 -04:00
Marco Beretta
dd85c6d6d0
🪹 feat: Shared Empty State for Side Panels (#15123)
* feat: shared empty state for side panels

Bookmarks and Memories each hand-rolled the same empty state: the same bordered
card, the same circular icon surface, the same title and caption sizes, written
out twice. Schedules had none at all, so an account with no schedules got a bare
list with nothing to explain what the panel is for.

One EmptyState primitive in packages/client, taking an icon, an optional title
and description, and an optional action. Bookmarks and Memories move onto it with
no visual change and no copy change. Schedules gets a real empty state, and an
error state with a Retry action, so a panel that failed to load offers a way out
instead of looking empty.

A description with no title takes the title's size rather than the caption's:
where it is the only line, it IS the message.

* fix: drop the create hint for roles without schedule create access

The panel already hides its create button behind hasCreateAccess, but
the empty state still told a USE-only viewer to create a schedule it
offers no way to create. The invitation now renders only when the
capability does.

* fix: suppress the create hint when the quota already blocks creation

A maxPerUser of 0 disables the create button on an empty list, so the
empty state must not say to create one either; the hint now follows the
same effective gate as the button.
2026-08-23 18:56:34 -04:00
Danny Avila
fc2b8584c4
📇 feat: Surface Event Child Activity Through a Bounded Parent Index (#15142)
* feat: surface event-driven child activity

* fix: keep child task aggregation documentdb-compatible

* fix: address event activity review findings

* test: provide markdown message context defaults

* fix: report bounded child history truncation

* fix: preserve current child activity state

* fix: preserve durable event child activity

* fix: handle missing task timestamps

* fix: keep active event snapshots live

* fix: preserve event activity across valid anchors

* fix: close event child activity gaps

* fix: preserve event activity across resume
2026-08-23 18:50:00 -04:00
Marco Beretta
8a118c7cb3
⏱️ feat: Shared Time Picker for Schedule Times (#15122)
A schedule's time was three dropdowns side by side: hour, minute, meridiem. That
is three controls for one value, it cannot be read at a glance, and the minute
list was a fixed set of four with the stored value bolted on, so a schedule
already running at :07 could be kept but never chosen.

They become one TimePicker: hour, minute and, where the clock format calls for
one, meridiem, as scrollable columns behind a single trigger showing the selected
time. An hourly cadence gets MinutePicker, the same control with its other
columns dropped, so it reads as the same widget rather than a different one. Both
live in packages/client with their wording passed in as props, so the primitive
carries no translation keys of its own.

Not `<input type="time">`: the browser owns its rendering, and it cannot be
brought in line with the rest of the form.

`hour12` is a required prop rather than a locale-derived guess. The app has
already resolved its Clock format setting, and re-deriving the answer inside the
picker would let it disagree with the summary printed beside it.

The trigger names its selected value as well as its field: `aria-labelledby`
replaces a button's child text, so pointing it at the label alone announced
"Time" and left a screen reader user unable to tell what was selected without
opening the columns and reading them. The columns are a roving-tabindex
radiogroup, arrow keys wrap, and the selected row is scrolled to the middle of
its column on open.

The popover is deliberately not portaled. A Radix dialog sets `pointer-events:
none` on the body while open, so a popover portaled out of it renders correctly
but receives no clicks or wheel events, and its focus trap puts the content out
of tab order too.

Hour and minute are set in one change. Behind separate fields a half-applied edit
could submit a time the user never picked, and the form now carries the hour as
the 0-23 value the cadence stores rather than a 12-hour value plus a meridiem it
has to recombine.
2026-08-23 22:47:33 +00:00
Marco Beretta
7834ebab33
🕰️ feat: Clock Format and Week Start Preferences (#15121)
* feat: clock format and week start preferences

Times were written in whatever convention the browser locale implied, and the
week always started on Sunday. Neither is right for a large part of the user
base: most of Europe reads a 24-hour clock and starts the week on Monday, and a
user running an English interface in a region that does either is currently
given the American convention with no way to change it.

Two General settings, Clock Format (System / 12-hour / 24-hour) and Week Starts
On (System / Sunday / Monday). Their System branch reads the runtime locale
rather than `i18n.language`, which is normalized down to a translation bundle:
`en-GB` and `en-AU` both become `en`, which is exactly the regional part these
two settings depend on, and reading it would report a 12-hour clock and a Sunday
week to a British user.

Week start is typed on the same 0-6 Sunday-first scale the schedule cadence uses
rather than being narrowed to Sunday/Monday, because the System branch reports
whatever the locale says and several (ar-EG, fa-IR) start the week on Saturday.
Engines without `Intl.Locale.prototype.getWeekInfo` fall back to a short list of
Sunday-first regions with Monday, the ISO 8601 default, otherwise: this is a
display default the toggle can always override, so an imperfect fallback degrades
rather than breaking.

Both settings are stored per browser. They describe how this device reads a
clock, which is a property of where someone is sitting rather than of their
account, and a user who moves between a European desktop and a US phone wants
each to read its own way.

Applied to message timestamps, the schedule dialog and card, key expiry and
refill dates, prompt and agent version dates, memory dates, and project chat
lists. The weekday order also drives the schedule dialog's day pills and the way
a weekly cadence reads back, so a wrap-around selection of Sat+Sun+Mon reads
"Monday, Saturday, Sunday" in a Monday-first week instead of "Sunday, Monday,
Saturday".

Dropdown now names its selected value as well as its field label. `aria-labelledby`
REPLACES the trigger's own text, so pointing it only at the caller's label left
the selected value unannounced, which these two settings are the first consumers
to hit.

* fix: teach the week-start fallback the Saturday-first regions

The no-week-data heuristic could only answer Sunday or Monday, folding
ar-EG to Sunday and fa-IR to Monday when CLDR says both start on
Saturday, and the selector offers no explicit Saturday override to
recover with. It now carries CLDR's Saturday-first territories, and the
UAE moves off the Sunday list to the Monday default, where CLDR put it
when its weekend moved to Sat-Sun. The fallback tests delete the
engine's week data for their duration, so they exercise the heuristic
on every engine instead of skipping wherever getWeekInfo exists.

* fix: infer likely regions for bare language tags and stop rebuilding clock formatters

A runtime that reports a language-only locale (bare ar or fa) carried no
region for the week-start heuristic, so those users fell to the Monday
default even though maximize() knows their likely region starts the week
on Saturday. The heuristic now maximizes before defaulting.

The runtime locale and each locale's meridiem answer are also cached at
module scope: every message timestamp mounts useClockFormat, so the
uncached path built a fresh Intl.DateTimeFormat per rendered message,
hundreds in a long conversation, even when the preference ignores the
locale entirely.

* fix: keep the Maldives on Friday in the week-start fallback

CLDR's lone Friday-first territory was in neither fallback set, so
dv-MV (and bare dv, which maximizes to MV) fell to Monday on engines
without week data, with no Friday override in the selector to recover
with. The three per-day sets consolidate into one region-to-day map.

* fix: complete the Sunday-first fallback from CLDR week data

The hand-picked ten Sunday-first regions left the System preference on
Monday for en-IN, id-ID, bn-BD, ur-PK, th-TH and the rest of the long
tail on engines without week data. The list is now every territory whose
und-XX week does not start Monday per CLDR, deprecated codes included,
with a note on how to regenerate it when CLDR moves a territory.

* fix: mock message context across markdown test suites and prevent global plugin cache leak
2026-08-23 18:41:14 -04:00
Marco Beretta
b55222b6f4
📆 feat: Weekly Schedules on Several Days (#15120)
* feat: weekly schedules on several days

A weekly cadence has always stored `daysOfWeek` as a list, and the API has always
accepted several, but the dialog offered a single day-of-week dropdown. That
picker could only hold `daysOfWeek[0]`, so a multi-day schedule created through
the API read back as running on one day, and the form needed a preservation rule
to avoid collapsing the rest of the set on an unrelated edit.

The dropdown becomes a row of toggles, one per day, so the control can represent
what the cadence already stores. The preservation rule goes with it: there is
nothing left to preserve once an untouched picker shows the real set.

Each pill is a toggle button rather than a checkbox because it renders as one,
and carries the long weekday name as its accessible label since "Mon" reads fine
at a glance but poorly aloud. The submitted set is sorted, so two schedules
picked in a different order are the same cadence.

Weekly with nothing selected is expressible in the form but not on the wire, so
it blocks submit with a message rather than silently saving as Monday.

* fix: keep the weekday pills one line tall and honest about empty sets

Seven 3rem pills wrapped to a second line inside their md cell, spending
height the dialog's no-scroll budget does not have; they now share the
row equally with the locale's narrow weekday labels, composed on the
shared Button so the pills carry its focus ring rather than a bare
feature-styled element. Both labels are built once per locale instead of
fourteen Intl constructions per keystroke, and the cadence summary no
longer describes the Monday fallback while the form says to pick a day.

* fix: name each weekday pill outright on hover

The narrow labels repeat within a week and read by position; until the
week-order preference lands, the position is fixed Sunday-first, so the
title gives a sighted user the full day name without relying on it.
2026-08-23 15:21:05 -04:00
Danny Avila
2ac7986947
🫂 fix: Route Subagent Activity Through the Chat Renderer (#15137)
* fix: align subagent activity with chat UI

* fix: preserve subagent activity boundaries

* fix: preserve subagent panel state semantics

* fix: preserve subagent activity metadata

* fix: preserve live subagent event metadata

* fix: scope subagent phases by message step

* fix: retire closed subagent message phases
2026-08-23 15:20:28 -04:00
Marco Beretta
591f05d2e1
🧹 chore: Sort Client Imports Left Behind by the Import Order Gate (#15139) 2026-08-23 15:18:05 -04:00
Marco Beretta
1724489024
🧭 feat: Choose a Schedule's Time Zone (#15119)
* feat: choose a schedule's timezone

A schedule's timezone was whatever zone the browser reported when it was created,
and nothing in the dialog could change it afterwards. That is wrong for anyone
who travels, for a shared account, and for a team schedule that should follow an
office rather than whoever happened to open the dialog.

The zone becomes a picker over every IANA zone the runtime knows, with the user's
own zone and UTC pinned first. `Intl.supportedValuesOf` is unavailable on older
engines, so that pinned pair doubles as the fallback list: a user who cannot
browse zones can still keep the one their schedule already uses. Each option
carries its current offset, since a name alone does not tell two similar zones
apart.

A zone change on its own is a timing edit, so it is submitted like one. The
server recomputes the next run whenever the timezone changes and measures the
interval floor against the effective pair, which is what makes `0 0,12 * * *` a
12-hour gap in UTC and an 11-hour one in America/New_York on the day it springs
forward. The dialog now mirrors that: the cron field validates against the
selected zone and the floor is measured in it, so a cadence cannot be accepted
here and refused by the API.

* fix: keep zone-only edits out of the cadence and the zone list findable

A timezone-only edit rode the cadence dirty flag, so the PATCH carried a
cadence rebuilt from the form and could overwrite stored fields the
pickers cannot represent, an API-created hourly's nonzero hour for one.
The floor still validates a zone change as the timing edit it is, but
only touched cadence controls put a cadence on the wire, and the spec
now pins that down instead of only checking the zone.

The picker list also gains the modern IANA names supportedValuesOf
omits (it reports CLDR's legacy canonical forms, Asia/Calcutta for
Asia/Kolkata), each probed against the engine before inclusion, and the
per-zone offset labels are cached per locale so reopening the dialog
stops rebuilding ~400 Intl.DateTimeFormat instances.

* fix: carry the full tzdb rename set into the zone picker

Five names covered the famous renames but Node still accepts and omits
fourteen more modern canonical identifiers (the Argentina provinces,
Indiana and Kentucky city moves, Kathmandu, Asmara, Faroe, Chuuk,
Pohnpei, Kanton, Atikokan). The set is now the tzdb rename list, still
probed per engine and deduped; deprecated links like US/Eastern and the
sign-inverted Etc/GMT forms stay out deliberately, since they duplicate
zones already listed under their canonical names.
2026-08-23 15:02:41 -04:00
Marco Beretta
44d97f859d
feat: Custom Cron Cadence for Scheduled Chats (#15084)
* feat: custom cron cadence for scheduled chats

Scheduled chats could only be built from four fixed presets, each pinned to a
single hour and minute, so anything outside that shape (twice a day, every 15
minutes, the 1st of the month) was not expressible. This adds a Custom cadence
that takes a raw five-field cron expression.

The cadence schema becomes a discriminated union on `frequency`. A cron row
carries `expression` instead of the hour and minute it cannot represent, since
there is no single hour for `0 9,17 * * 1-5`, and the Mongo schema requires each
field only for the shape that has it: a blanket `required` would reject every
cron write, and dropping it entirely would let a structured cadence silently
fire at 00:00 with a missing hour.

Five fields only. croner also reads a six-field form carrying seconds and a
seven-field form that pins a year, and both are refused. Seconds would promise a
precision the engine does not keep, since it polls on a thirty-second tick and
offsets each schedule by up to two minutes of jitter. A pinned year makes a
cadence that runs out, and every place that computes a next run reads "no next
occurrence" as a cadence it cannot read.

Compilation, validation, next-run previews and interval measurement live in
packages/data-provider so the dialog and the engine share one parser and cannot
drift. The dialog previews the next occurrences, enforces the admin interval
floor and disables its own submit from the same functions the server validates
with, so it cannot offer a Create the API answers 400 to.

The interval floor now covers cron, and measures it twice, taking the smaller.
The nominal gap is probed in UTC and discounted by the same worst-case DST
allowance the structured branches carry, which keeps `0 9 * * *` reporting
exactly what the Daily preset reports. Real elapsed time is then measured in the
schedule's own zone across each of that zone's transitions, because
spring-forward compresses a gap that straddles one: `0 0,12 * * *` in
America/New_York is 11 hours that day, not 12, and a floor between the two would
otherwise be bypassed. The floor ships with the schedules list so the dialog can
mirror it rather than surfacing it as a 400 after submit.

Radio gains a wrap variant, since five frequency segments no longer fit one row
in a phone-width dialog and a translated label can push even a desktop one over.
Its indicator follows the selection across rows; the single-row default is
unchanged.

* fix: mark the cron input invalid when the interval floor rejects it

A floor-violating expression disabled Create and rendered the cadence
message, but the input itself still said aria-invalid=false and its
aria-describedby never reached that message, leaving a screen reader
user with a disabled Create and no stated reason.
2026-08-23 14:35:18 -04:00
Marco Beretta
0f376884bd
📱 feat: Offer a Dismissable Strip of Chat Beside the Mobile Drawer (#14990)
* feat: leave a dismissable strip of chat beside the mobile drawer

The drawer took the whole viewport, so opening it read as a screen change
rather than a layer over the conversation, and the only ways back were the
header button and a swipe.

It now stops at 80% and the chat stays visible behind a scrim, which is
itself the dismiss target: tapping it closes the drawer and returns to the
conversation, which never navigated away. The scrim renders as a sibling
of the pane rather than inside it, because the pane is inert while the
drawer is open and would swallow the click. Drawer width and pane travel
derive from one constant so they cannot drift.

Closing had to change with it. A programmatic close repositioned the pane
instantly, which was invisible only because a full-width opaque drawer
covered the jump; with a strip on screen that jump lands in plain view, so
both surfaces animate together, the motion the drag path already produced.
The spec that pinned the old reveal is rewritten to pin this.

The easing also changed: the previous curve spent its last third of the
duration on a few percent of the distance, which read as the panel
sticking just before it landed, most obvious on close.

Three things the scrim has to respect, each found in review:

- It routes through useSidebarToggle rather than writing the atom, so the
  slide still starts imperatively and a large conversation cannot stall it.
- It drops its fade under prefers-reduced-motion, matching the snap
  kickDrawerAnimation already performs.
- It stays the pointer target until the close animation settles, derived
  from the committed state so every close path is covered, and cleared on a
  timer so a scrim unmounting at the breakpoint cannot strand it. Focus
  returns to the drawer's opener once the closed state commits, since the
  scrim goes aria-hidden and untabbable.

* fix: close the mobile drawer predictably from every path

Move the close handling out of Root into useDrawerDismiss, which fixes three
things the split scrim-owned version got wrong:

A breakpoint crossing derives the drawer closed with nothing to animate, so
narrowing a window or rotating a tablet armed the pointer guard and left a
transparent full-screen scrim swallowing taps for 300ms.

The scrim stays the pointer target through that guard, where the state has
already committed. A tap there closed again, a no-op that never reached the
focus handoff, stranding the restore flag to fire on a later close.

Focus was only restored when the scrim itself closed the drawer, and only to
the header opener. Closing from the drawer button or Escape left focus in a
subtree that goes inert, and routes that render no opener left it on the
scrim once it went aria-hidden. Every close path now restores, to the opener
or the pane, and only when the close is what dropped focus.

* style: sort imports in the new drawer hook

* fix: reclaim focus from the scrim when Escape closes the drawer

The drawer's Escape handler closes it without going through the scrim, so a
keyboard user who tabbed there kept focus on a button that becomes
aria-hidden and untabbable. Inert drops focus to the body by itself;
aria-hidden does not, so it has to count as lost too.

* feat: make the mobile chat strip a setting, off by default

The drawer covering the full width and closing by swipe stays the default.
Turning the setting on stops it short of the edge, leaving a strip of the
conversation visible that also closes the drawer when tapped.

Both surfaces read one custom property for how far the drawer opens, so the
value can change at runtime without threading a number through the swipe
gesture, and their travel still cannot drift apart. The fallback is the
default, so anything rendered outside the property's scope agrees too.

The scrim moves into its own component, which is what makes its tab order,
aria-hidden and pointer-events states testable.

* fix: keep the reveal close on the default full-width drawer

Making the strip opt-in put the paired close animation on the default path,
where the drawer covers the pane: selecting a conversation then visibly
shifted the chat leftward while the new one committed into the moving layer,
which is the regression the reveal existed to avoid.

The reveal is now chosen from geometry rather than the setting, since it is
safe exactly when the drawer hides the pane, however the width was arrived
at. The drawer also transitions its width, so toggling the setting while it
is open moves both surfaces on one curve instead of jumping the width in a
frame while the pane eases across the transition.

* fix: honour reduced motion when the strip setting changes the width

Changing the setting updates the width custom property directly rather than
going through the snap path, so the drawer eased its width and the pane its
transform for the full transition even for a user who asked for no motion.

The preference now reaches the declarative styles on both surfaces, and the
snap no longer hands an animating transition back afterwards, which is what
left the element ready to ease the next change.

* fix: cover the gesture snap, the close frame and the breakpoint focus

The gesture settle restored the transitions directly rather than through the
reduced-motion handoff, so a swipe left both surfaces ready to animate the
next width change.

The close guard was armed from a passive effect, which runs after paint,
leaving one frame where the pane had dropped inert and the scrim had not yet
taken the pointer back. It is armed in the committing frame now.

Crossing into mobile with focus inside the expanded desktop sidebar drops it
when that subtree unmounts. The guard is still right to stay disarmed there,
since nothing animates, but the focus handoff has to run, so the two no
longer share an early return.

* fix: keep the pointer guard tied to a pane that actually moves

Disabling the strip unmounts the scrim at once while the drawer needs the
whole transition to widen, so a close begun in that window still slid the
pane with nothing holding the pointer. The scrim now stays mounted while a
close is in flight.

Arming that guard is tied to the same geometry the close path already
branches on. A close under a drawer that covers the pane is a reveal, with
the pane already in place, so holding the pointer there would only make the
default configuration feel unresponsive for the length of the transition.

* fix: guard the swipe close and hand focus back off the mobile breakpoint

The guard read the drawer's width to decide whether the pane was moving, but
a swipe animates the pane at any width, so the default configuration went
unguarded through the one close path that does move it. It now asks the pane
itself: the reveal leaves transition none behind, every animated path leaves
the shared transition on it before the state commits.

Leaving mobile unmounts the drawer and the scrim, so focus sitting on either
went to the document. The same handoff runs for that direction, and it now
confirms the opener actually took focus rather than assuming: the opener
stays mounted across breakpoints but is hidden on desktop.

The scrim is imported through the mobile directory's barrel.

* fix: address PR review bot findings

Codex:
- Start the scrim fade with the drawer slide, not the deferred Recoil commit
- Keep the scrim focus ring inside the overflow-hidden shell

* fix: address PR review bot findings

Codex:
- Capture pointer events on the scrim as soon as an open slide starts
- Expire the close guard at the animation deadline, not a fresh 300ms
- Keep pointer capture through a reveal close while the drawer still slides

* fix: hand focus back once the close guard releases

Codex:
- Defer focus restoration until the pane is no longer inert

The guard reapplies inert to the pane in the same commit the close lands,
and both the opener and the pane itself sit inside it, so the handoff was
ejected to the body with no dependency left to re-run it. The release now
flushes before focus moves.

* fix: drop the scrim pointer override when the close slide starts

Codex:
- Clear the opening pointer override on close

The opening kick writes an inline pointer-events override that only the
buffered release cleared, so a dismiss inside that window left the invisible
scrim swallowing taps past the guard. The close now hands capture back to the
classes, which already hold it for the guard's duration.

* fix: carry the focus handoff and the slide's own clocks through a close

Codex:
- Preserve focus when the motion preference changes mid-close
- Keep the scrim armed when an opening is canceled
- Stabilize the drawer width before closing mid-toggle

The handoff is now keyed off the guard releasing rather than the timer, so a
guard cancelled by a dependency change still hands focus back once the pane
sheds inert. A close that cancels an uncommitted open never reaches the
isClosing classes, so it keeps the scrim's pointer override instead of
returning capture that nothing else holds. And the close pins the drawer's
measured width, so a width transition still in flight cannot drive its edge
from a second clock and open a gap against the pane.

* fix: guard a close the committed state never reports

Codex:
- Guard canceled opens when the strip is disabled
- Stabilize the drawer width before an in-flight swipe

A second toggle inside the deferred flip, or an open drag that falls short,
moves the pane without expanded ever changing, so the guard had no transition
to arm from and the default configuration left the pane live as it uncovered.
The slide now reports itself and arms the guard directly, which also makes the
scrim's pointer override unconditional again: every close hands capture back
to the classes.

Claiming a gesture drops the transition, which lands a width still easing
toward the strip target on that target in the same frame, so the touchstart
snapshot went stale and held a gap open between the surfaces for the rest of
the drag. The claim remeasures.

* fix: cover the opening travel and compose the scrim's button

Codex:
- Guard the pane during default drawer opens
- Compose the shared button primitive for the scrim

Recoil's flip is deferred past the opening frames and the closing transition
outlives it at the other end, so the committed state brackets the travel too
late and drops it too early. The guard is now named for what it measures and
arms for any slide the committed state does not report, so the default
configuration covers the pane while the drawer travels over it. Only a close
records the focus handoff; an open hands focus to the drawer's header.

The scrim now composes the shared Button, keeping only the inset ring the
overflow-hidden shell requires.
2026-08-23 14:34:39 -04:00
Marco Beretta
69bf8ffdf1
📏 fix: Size Agent Builder Tool and Skill Rows (#15134)
The info and remove buttons on each Tools/Skills row passed `size-6 p-0`
through className without a `size` prop. tailwind-merge 1.14.0 has no
`size-*` group, so `size-6` never conflicted with the default size
variant's `h-10 px-4 py-2` and the buttons rendered at 40px. Because they
sit at opacity-0 until hover, the rows read as 52px of mostly empty
padding.

Pass size="icon-xs" so the recipe replaces the default outright. Rows go
from 52px to 40px.
2026-08-23 10:36:01 -04:00
Danny Avila
1d9c2fc591
🪴 feat: Fork Completed Subagents Into Continuable Chats (#15133)
* feat: continue completed subagents as chats

* chore: sort continuation imports
2026-08-23 10:09:14 -04:00
Danny Avila
c2aa688d73
🖥️ feat: Stream PTC Inner Tool Calls as a CLI-Style Trace (#15115)
* 🖥️ feat: Stream PTC Inner Tool Calls as a CLI-Style Trace

Programmatic tool calling runs a whole program inside the sandbox, and the
tool calls that program makes open no run step of their own. The card showed
one running spinner for the entire execution, with no sign of what the code
was doing.

Emit a new `on_ptc_tool_call` step event for each inner invocation — once on
dispatch, once on settle — and render them under the code as a terminal-style
trace: status glyph, tool identity, argument preview, duration, with a failure
message printed under the call that produced it.

The seam is the tool map the sandbox bridge resolves inner calls against.
`instrumentPtcToolMap` proxies `invoke` on each entry and leaves every other
property (name, schema, mcp) passing straight through, so nothing about
execution changes and emission failures can never fail a tool call.

Client state is a per-tool-call Recoil atom keyed like the sandbox-starting
and subagent atoms — live for the session, cleared on conversation switch so
a finished program's trace stays readable.

* 🩹 fix: Address Codex Review on the PTC Tool Trace

Five findings, all confirmed against the source before fixing.

Scope the trace atoms to a message occurrence. The hook already documents
that providers repeat a tool_call_id across turns and even within one
message, and `call_id` restarts at :0 for every outer call — so two programs
sharing `call_0` merged into one card. Key by (response message id, tool call
id) via `ptcTraceKey`, mirroring `subagentProgressKey`; the event's `runId`
already carries the message id and the card reads its own from MessageContext.

Prune unsettled rows on resume. Inner calls are not content parts, so the
resume snapshot cannot rebuild them, and `trackReplayEvent` only persists
OAuth events — a call that settled during a disconnect left a spinner that
never resolved. Settled rows are real history and stay.

Make the argument preview budget-aware. Iterate keys rather than entries so
the budget check can actually skip work, and clip against a bounded window so
a multi-megabyte value is never collapsed in full to build a 40-character
preview.

Catch the resumable emission promise. The synchronous try/catch around the
emitter cannot observe a rejected `emitChunk`, so a failing transport raised
an unhandled rejection per event instead of dropping telemetry.

Announce completion to assistive technology. The check glyph is decorative and
a fast call renders no duration, so a settled row previously announced no
outcome; each row now carries an sr-only status and the visible cell that
duplicated it is hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

* 🧹 fix: Repair CI Failures on the PTC Tool Trace

Two failures on the previous head, both mine.

`Tests: api (shard 2/3)` — 46 failures in `initialize.spec.js`, all
`TypeError: createPtcProgressEmitter is not a function`. The suite mocks the
callbacks module with an object literal, and wiring the new emitter into
`initialize.js` without adding it there left the factory undefined at call
time. Added it alongside `createAttachmentEmitter`, plus an assertion that it
receives the same generation fence as every other resumable emitter — a stale
epoch would leak one run's inner calls into the next.

`Static checks` — import-order drift in `PtcToolTrace.tsx` and `handlers.ts`,
repaired with `scripts/sort-imports.mts`. ESLint and Prettier both passed, so
only the dedicated check caught it.

`openai.js` and `responses.js` never take the emitter, so their specs were
unaffected; verified the initialize mock now covers every name the module
destructures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

* 🔐 fix: Address Second Codex Review on the PTC Tool Trace

Three of five findings actioned; two answered on the thread.

Respect tool-argument PII filtering (P1). Inner calls never reach
`filteredToolArgumentsResult` — the sandbox bridge invokes them directly — so
the trace was the one path putting their values on the wire in a deployment
that had configured `filters.toolArguments.pii`. When any of the name /
arguments / output fields are filtered, the emitter now omits both the
argument preview and the failure message, which routinely quotes the argument
that caused it. Name, status and duration still report.

Drop the light/dark-specific background (P1). `dark:bg-transparent` stepped
outside the semantic roles and would lose the intended separation under a
custom theme. The pane now sets no background at all and inherits the card's
surface, which resolves to the same color the override produced in both
default themes and stays correct when a theme reassigns its roles.

Bound the live trace (P2). A program looping over a large collection made
every event copy an ever-growing array and rendered a row per call. The trace
now keeps a rolling tail of 100 rows and counts what it evicted, surfaced as
"+N earlier calls" so the cap is never silent. A settle whose row is gone —
evicted, or pruned across a resume gap — no longer reappears out of order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

*  test: Keep PTC Trace Tests Aligned With Caller-Capability Filtering

Left out of the merge commit by a staging slip; without them
`handlers.spec.ts` fails on the merged tree.

`#15105` restricts the PTC tool map to tools whose `allowed_callers` admit
code execution, so the existing trace test's registry entry — which declared
none, defaulting to `direct` — was filtered out before the instrumentation
could see it. Declare the fixture `code_execution`.

Add a guard for the resolution itself: a `direct`-only tool must never appear
in the instrumented map. Tracing wraps the eligible map, and this fails if a
later change reorders that and lets the trace widen what the sandbox reaches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

* 🛡️ fix: Close Name Disclosure and Follow the PTC Trace Tail

Two findings from the third Codex pass on `17a9ec9`.

Redact filtered inner-tool names (P1). The previous gate suppressed argument
and failure previews but the event still carried `name` verbatim, so a
deployment whose `filters.toolArguments.pii.fields` includes `name` could see
a blocked identifier disclosed through the trace — the one path inner calls
take, since they never reach `filteredToolArgumentsResult`. Inner tool names
are now inspected once per PTC call with the same `extractToolArgumentContent`
+ `inspectContent` pair the executor uses; any that trip the policy are left
unwrapped, so they still execute and emit nothing. An un-inspectable name
fails closed.

Follow the trace tail (P2). The row list is a 200px scroller that never moved,
so once a program exceeded the viewport the card sat on the oldest calls while
live activity accumulated below the fold. Reuse `useFollowScroll` — the hook
the code and command panes already use — which pins to the tail while calls
are running and yields the moment the reader scrolls up. The host card threads
its disclosure state so a collapsed pane is never scrolled invisibly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

* 📌 fix: Pin the PTC Trace Through Its Final Settle

The fourth Codex pass on `4bf68e1`, one P2 finding.

`useFollowScroll` returned early whenever `active` was false, so the one
change it most needed to follow was the one it skipped. A failing inner call
settles by appending its error line in the same commit that clears the last
running row: the content grows and the stream ends together, and the pin that
would have revealed that line never fired. On an expanded, bottom-pinned pane
the failure — the row a reader most wants — stayed below the fold.

The falling edge of `active` now pins too, but only when the content changed
with it. Ending a stream on its own still leaves the pane where the reader
left it, which is what the existing contract promises and what the sibling
code and command panes rely on; a reader who has scrolled up is untouched
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

* 🔌 fix: Keep PTC Calls That Outlive a Reconnect

Fifth Codex pass on `085a83f`; one of its two findings.

Pruning rows across a resume gap deleted every `running` row, but a stream gap
is not proof the call ended. A call still executing across the reconnect
settles normally on the restored live stream — and `applyPtcToolCall` drops a
settle whose row is gone, by design, so an evicted row cannot reappear out of
order. The call therefore vanished from the trace despite having run, which is
worse than the spinner the pruning existed to prevent.

Rows are now marked `interrupted` instead of removed. A call whose settle was
genuinely lost in the gap reports that honestly rather than spinning forever,
and one that survives the gap settles onto the row it opened, reporting its
real outcome and duration. `interrupted` is a client-side conclusion, so it
widens the row status locally and leaves the wire contract alone.

Two cases added: the gap marks rather than drops, and a post-reconnect settle
lands on its marked row; plus a render case for the new outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 01:18:14 -04:00
Danny Avila
d3e70159ca
📡 feat: Stream Detached Subagent Activity (#15111)
* feat: stream detached subagent activity

* fix: annotate activity stream limits

* fix: isolate subagent activity imports

* fix: harden detached subagent activity lifecycle

* test: cover synchronous activity transport failure

* test: include required subagent activity identity

* fix: identify and reconnect subagent activity events

* fix: bound subagent activity lifecycles

* fix: close subagent activity handoff races

* fix: bind and synchronize activity subscriptions

* fix: detect fresh activity attachment

* fix: complete activity synchronization handoff

* fix: bind activity sync and failure circuits

* fix: expose subscription-bound synchronization

* fix: fence activity reconnect publications

* test: make detached timeout settlement deterministic

* fix: fence Redis activity attachments

* fix: close failed activity streams

* perf: reuse fenced activity frontier

* style: sort subagent thread imports

* fix: preserve queued subagent activity

* test: type activity publication counter

* fix: disconnect subagent activity subscriber

* fix: close background activity lifecycle gaps

* fix: preserve streamed activity spacing

* fix: preserve bounded live subagent activity

* fix: merge durable subagent activity safely

* fix: model detached activity coverage

* fix: type detached activity inputs

* fix: order overlapping subagent activity

* chore: sort activity test imports

* fix: buffer subagent activity handoff gaps

* fix: flush activity after parent close

* fix: advance closed activity suffixes

* fix: preserve detached activity ordering

* fix: close detached activity delivery races

* fix: bound shared Redis subscriber readiness

* fix: expire shared Redis subscription readiness

* fix: clean up late Redis subscriptions

* fix: preserve late Redis subscription fallback
2026-08-22 09:45:27 -04:00
Danny Avila
f384e71f77
🧯 fix: Prevent Quote Popup Update Loop (#15113) 2026-08-22 01:08:44 -04:00
Danny Avila
67b7b441b2
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
2026-08-21 22:43:32 -04:00
Danny Avila
08c9cc3d3d
🖼️ fix: Restore Shared Subagent Activity as a Read-Only View (#15108) 2026-08-21 20:58:14 -04:00
Marco Beretta
199de92c51
perf: Warm Feature Catalogs in the Background After First Paint (#15047)
* perf: warm feature catalogs in the background after first paint

Prompt groups and MCP server/tool queries no longer fire on the app
startup path. A catalog warmup store releases them after first paint
on browser idle, staggered with jitter so a fleet of clients does not
burst the API all at once. Panels opened before warmup activates
their catalog immediately and fall back to their existing loading
states. The prompts list endpoints now also run their independent
access lookups in parallel instead of in three serial rounds.

* perf: gate MCP icon observers and re-arm warmup across sessions

MCP icon/name observers mounted from rendered messages now wait for the
warmup gate like every other server-catalog consumer, so conversations
with MCP tool calls no longer pull the server list onto the first-render
path. The warmup schedule resets on logout so a second login in the same
tab warms on its own stagger instead of releasing every catalog at once.
Panel mount activations now require a visible sidebar, since a persisted
active panel stays mounted while hidden.

* perf: void stale warmup callbacks and gate the agent panel tools query

Reset now bumps a generation captured by every idle callback and its
stagger timer, so callbacks pending across a logout can no longer release
catalogs into the next session. The agent form's MCP tools query keeps
its own readiness gate so a hidden persisted panel cannot pull the tools
request ahead of its stagger once the server list resolves.

* perf: reset warmup on Root unmount and honor the insights route collapse

Root can unmount in the same render that flips authentication on logout,
so the warmup effect now resets from its cleanup as well as the
unauthenticated branch. Panel activations mirror UnifiedSidebar's
panelExpanded condition, treating the insights route as collapsed instead
of reading the raw sidebar atom.

* test: re-expand the approval tool card the saved message remounts

The helper opened the card once and then waited on its body. Saving the
response swaps the placeholder message id for the persisted one, which
rekeys every part in the turn: the card remounts collapsed, its body
unmounts, and the output assertion waits out its timeout against a
disclosure nothing is going to reopen. The redis transport lane pays a
round trip per stream event, so its finalization lands late enough to
catch the helper mid-assertion.

Wait for the closing model turn before expanding anything, then re-open
the group and the card on each attempt until the scoped output is on
screen.

* Update AgentPanelContext.tsx import order

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 20:33:29 -04:00
Danny Avila
749eed0d60
🪟 feat: Unify Subagent Activity Panel (#15106)
* feat: unify subagent activity panel

* fix: fence durable activity to selected task

* fix: preserve exact panel activity semantics

* fix: scope panel identity to parent turn

* fix: keep detached readiness status neutral

* fix: harden subagent activity invariants

* test: support backend TypeScript target

* fix: preserve subagent invocation identity

* fix: bound subagent activity correlation

* fix: drain exact-parent subagent updates
2026-08-21 19:51:11 -04:00
Danny Avila
21b7f78d56
🙋 feat: Collapse Settled Question Records by Default (#15107)
* 🙋 feat: Collapse Settled Question Records by Default

The durable `ask_user_question` record rendered as a permanently open
card. Answers are frequently long, multi-paragraph text, so a settled
Q&A buried the reply that followed it.

It now reads as one collapsed tool-call line — the same `ProgressText`
primitive `ToolCall`/`SkillCall` use — naming the question (or the
batch count, reusing the keys `ToolCallGroup` already had) and opening
on demand under the existing `autoExpandTools` preference. Only the
settled record collapses; the live pause and the interim progress card
are untouched.

The expanded panel was also hard to read. Authored text rendered
without `pre-wrap`, so a numbered or paragraphed answer collapsed into
one wall; the answer ran on from its inline label; and batch items sat
flush against their divider. Line breaks are now content, the answer
sits under its own label behind a rule, and dividers have air on both
sides.

`ProgressText`'s subtitle now truncates and absorbs the flex shrink, so
arbitrary authored text ellipsizes instead of pushing the line past the
message column — this also fixes long MCP server names on tool cards.

* 🩹 fix: Address Codex Round 1 on the Collapsed Question Record

- Settle the summary tense. A live, unanswered pause returns before the
  header, so every state reaching it is settled — an abandoned pause read
  "Asking" forever, and the collapse hid the "no answer" line that used to
  qualify it. Past tense unconditionally, matching `ToolCallGroup`.

- Move the rejection announcement out of the disclosure. `useExpandCollapse`
  marks the closed panel `inert`, so the failure explanation's `role="status"`
  could never reach the accessibility tree; it is now an sr-only status
  outside the panel, carrying both the label and the explanation.

- Count records, not repeated text, in the Bombadil observation. With
  Auto-expand tool details on, one settled record shows the question in both
  its summary line and its panel, so the old selector double-counted it and
  broke the `<= 1` singularity invariant.
2026-08-21 19:50:40 -04:00
Danny Avila
8ae94afa91
🪡 fix: Thread Parent Message ID Through MCP Request-Scoped Bodies (#15095)
* fix: Unify MCP request-scoped headers

* fix: address request-scoped MCP review findings

* test: preserve request scope on status errors

* fix: treat authorized on-demand MCP servers as ready

* refactor: separate MCP readiness from connection state

* fix: preserve on-demand MCP readiness labels

* test: satisfy OpenAI conversation ownership guard

* fix: keep MCP action predicates boolean

* fix: close deferred MCP request context gaps

* fix: preserve on-demand MCP configuration actions

* fix: fail closed on unavailable MCP parent context

* test: complete MCP connecting-state mocks

* fix: preserve missing MCP parent on continuations

* fix: align native MCP request identities

* fix: preserve edited MCP parent identity

* test: use scoped Agent initializer fixture

* test: expose MCP request body helper

* fix: preserve MCP turn identity across resume

* style: sort stream metadata imports

* fix: carry normalized MCP identity to execution
2026-08-21 16:33:01 -04:00
Dustin Healy
f02ce63d57
✂️ fix: Strip Redundant Server-Name Prefixes from MCP Tool Keys (#14732)
* ✂️ fix: Strip Redundant Server-Name Prefixes from MCP Tool Keys

MCP servers that prefix every tool with their own name produce model-facing keys that embed the server twice once the _mcp_<server> suffix is appended, pushing long tool names past provider 64-character function-name limits. Tool keys now drop a leading <normalizedServerName>_ prefix (case-insensitive, skipped when a sibling tool already owns the stripped name). The original upstream name is recorded as serverToolName on the cached definition and is always what tool calls send to the server, and runtime lookups also try the stripped spelling of persisted pre-strip keys so existing agents keep resolving.

* 🩹 fix: Keep Stripped MCP Tool Keys Provider-Safe and Collision-Free

Assistant writers submit catalog entries verbatim, so the internal serverToolName mapping is now removed from provider-facing definitions before they reach create/update payloads. Prefix stripping is collision-guarded over the resulting name set rather than raw siblings only, which also covers case-variant prefixed pairs under the case-insensitive match.

Assistant payload healing now rewrites a pre-strip persisted key to the stripped catalog key when that key actually exists in the loaded definitions, and legacy agent references keep their persisted spelling as the runtime instance name so per-tool options stay applied while the upstream call still uses the matched entry's raw name.

* 🧷 fix: Harden Stripped MCP Tool Keys Against Heal, Collision, and Cache Edges

The pre-strip heal now resolves the key boundary against both raw and normalized server spellings, mapping back to the raw name for the shadow and membership guards, so keys persisted after server-name normalization heal too. Collision detection iterates to a fixpoint so a fallback to a raw name cannot silently collide with another sibling's stripped result, and a stripped remainder equal to a synthetic marker (wildcard or server pin) is never produced.

MCP catalog cache slices are versioned so replicas that predate serverToolName never read stripped entries during a rolling deploy; stale slices expire on their own.

* 🔎 fix: Resolve Pre-Strip Keys in Event-Driven Definitions and Reinspect Persisted Catalogs

The event-driven definitions loader now tries the stripped spelling of a persisted key when the exact lookup misses, keeping the persisted name so it matches the runtime instance, which stops legacy agents from failing initialization with expected tools unavailable. The registry storage schema version is bumped so followers rebuild persisted toolFunctions instead of republishing pre-strip definitions into the versioned catalog namespace.

The assistants heal also fails closed when a normalized-suffix reference lands on a contested server-name slot, since rewriting persisted data must not bind an ambiguous reference to the tie-break winner.

* 🛰️ fix: Reserve the Synthetic OAuth Name and Heal User-Owned Server Keys

A stripped remainder equal to oauth would make the client stream handlers treat a real tool call as a synthetic authentication prompt, so it joins the reserved remainders alongside the wildcard and pin markers.

The assistants heal now audits the FULL accessible server set on every run instead of operator config names only, since assistants reference user-owned servers whose catalogs the definitions loader already resolves; an unavailable audit still skips healing entirely.

* 🧬 fix: Verify Upstream Identity for Legacy Keys and Reserve Sibling Raw Names

Stripped results now reserve every sibling's raw name even when that sibling itself strips, so a stripped key can never shadow another tool's pre-rollout persisted references within the same snapshot. Every legacy fallback (runtime lookup, event-driven definitions, assistants heal) accepts a stripped-spelling match only when the entry's recorded serverToolName proves the same upstream tool, so a stale key for a removed tool degrades to unavailable instead of calling a different sibling.

To keep that identity visible to the heal, assistant tool definitions retain serverToolName and the controllers sanitize entries through toProviderToolDefinition at the provider submission boundary instead. The agent editor migrates pre-strip persisted ids the same identity-verified way, with the upstream name exposed on the MCP tools payload.

* 🧭 fix: Heal Wildcard Tool Options and Reserve the OAuth Namespace

Wildcard-expanded catalogs rename stripped tools without any agent.tools entry to preserve the spelling, so buildToolClassification now aliases persisted pre-strip tool_options keys onto the current instance names in place, identity-gated on the definition's recorded upstream name and never overriding an explicit entry. Both loading modes flow through it: instances carry mcpServerToolName from createToolInstance and event-driven definitions thread serverToolName from the catalog.

stripServerNamePrefix also reserves the entire oauth namespace rather than the exact name, since the client stream handlers classify every oauth-prefixed key as a synthetic authentication call.

* 🛡️ fix: Derive the Full Reserved Namespace and Heal Approval Policies

The reservation guard now covers every namespace consumers classify by prefix: the wildcard and server-pin markers alongside oauth, plus the server-scoped mcp_ pluginKey namespace that pre-strip keys could never enter. Stripping also never produces a key whose isActionTool classification differs from the raw key's, since a server whose normalized name contains _action_ would otherwise see a real MCP tool routed down the OpenAPI action path past MCP authorization.

Admin toolApproval globs written against upstream tool naming keep applying: pattern lists are healed at run wiring with the current names of tools whose pre-strip spelling matches, list-level so deny, ask, and allow precedence is unchanged and a non-matching deny can no longer fail open. The MCP tools wire type also declares serverToolName end to end.

* 🪪 fix: Alias Both Key Spellings for Approval Policies and Hook Matchers

Identity aliases are now collected once at tool classification, in both directions: a stripped instance aliases its pre-strip spelling and a legacy-named instance aliases its current catalog spelling, with the current name recorded on legacy matches by the runtime lookup and the event-driven definitions loader alike. The aliases ride the agent config through both loading modes, so approval pattern healing applies to deny rules written against either spelling, closing the bypass where a rule targeting the current name missed an unedited agent's legacy instance.

Programmatic approval hook matchers get the same treatment: each hook is additionally registered under an anchored exact-name pattern for tools whose other spelling its regex matches, keeping the admin's matcher semantics intact while argument, user, and tenant specific deny or ask decisions keep executing for renamed tools.

* 🔁 fix: Alias Tool Options in Both Spelling Directions

Options aliasing now consumes the same bidirectional alias pairs as policy healing and hook matchers, so options the editor migrated to the current catalog spelling still reach a legacy-named instance retained by an unedited agent.tools entry. The previous serverToolName-only derivation skipped exactly that case since the legacy key equals the instance name there.

* 🤝 fix: Reserve the Agent Handoff Namespace Before Stripping

The client renders any lc_transfer_to_ prefixed call as an agent handoff and the background and intent passes exclude such names, so a stripped remainder inside that namespace would misclassify a real upstream tool. It joins the mcp_ pluginKey namespace as a bare-prefix reservation, which pre-strip keys could never enter.

*  fix: Reuse the Loader's Server Snapshot and Index the Editor Catalog

getAssistantToolDefinitions now returns the accessible-server snapshot from the same merged registry read that resolved the catalogs, and the heal consumes it instead of repeating the app-config and registry round trips on the assistant write path; without a snapshot the heal still fetches and fails closed as before.

The agent editor's id migration uses a memoized tool_id map, so the per-key form heal does constant-time lookups instead of scanning the catalog per option.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 14:30:29 -04:00