Commit graph

5210 commits

Author SHA1 Message Date
Danny Avila
2b1644406a
💄 style: Align the Thinking Dot with the Header Icon (#14895)
* 💄 style: Align the Thinking Dot with the Header Icon

* 💄 style: Keep the Dot Nudge Logical and Gated to the Header Axis

* 💄 style: Route the Seeded Empty-Text Placeholder Through the Nudged Cursor

* ♻️ refactor: Guard MemoryArtifacts on Its Memoized List
2026-08-16 17:56:22 -04:00
Marco Beretta
832bac39ad
🗄️ feat: Record When a Conversation Was Archived (#14863)
* feat: record when a conversation was archived

The archived chats dialog has a "Date Archived" column that was bound to
createdAt, so it showed when the chat was created rather than when it was
filed away. Nothing recorded the latter.

Conversations now carry archivedAt, set on archive and cleared on
unarchive, and the column reads it. Chats archived before the field
existed have no stamp and fall back to createdAt, which is exactly what
that column already showed for them.

The archive view sorts on the new field. archivedAt is absent on every
previously archived chat, so the missing-value group is the common case
here rather than an edge case: the cursor's null handling, written for
titles, now covers both, and an absent stamp survives the cursor as null
instead of collapsing to the epoch and replaying the whole archive.

* fix: address review findings on the archived-at stamp

- Protect `archivedAt` from saveMessageToDatabase's unset sweep. Any
  persisted field missing from endpointOptions is unset, so sending a
  message in an archived chat cleared the stamp while leaving isArchived
  true, silently dropping it into the legacy fallback group.
- Order the legacy group by the createdAt the dialog displays rather than
  by last activity. The cursor's secondary key is now chosen per sort
  field, so the fallback the cell renders and the order the server
  returns cannot disagree.
- Put that secondary key in the archive index too, so paging the legacy
  group does not fall back to a blocking sort.

* fix: keep archivedAt on a redundant archive request

Opening an archived chat and hitting the archive shortcut, or retrying
the POST, sent isArchived: true again and replaced Date Archived with
now. saveConvo now stamps only on the unarchived-to-archived transition
and still clears the field on unarchive.

* fix: make archive timestamp updates atomic

* test: type the archive race spy against the driver signature

* fix: archive without an aggregation-pipeline update

DocumentDB documents no support for pipeline-form updates on any engine version,
and the repository's compatibility assessment records that a prior P0 rewrote the
three that existed. Stamping archivedAt through a $cond pipeline reintroduced one,
which would have sent every archive and unarchive to the route's 500 handler on a
supported 5.0 deployment.

The conditional stamp is now a compare-and-set on isArchived, which keeps the
transition atomic without a pipeline: only the write that finds the chat unarchived
stamps it, so a duplicate or retried archive leaves the original date alone and an
unarchive that lands first is re-stamped. Schema defaults and createdAt-on-insert
go back to mongoose's own setDefaultsOnInsert and $setOnInsert, and tenantId is
once again stripped by the tenant-isolation plugin rather than by hand.

* fix: do not report a racing archive as a missing chat

Both conditional writes of the compare-and-set miss when the archive flag flips
between them: the chat was already archived when the transition write ran and
unarchived again before the already-archived write. saveConvo returned null for
a conversation that plainly exists, so POST /api/convos/archive answered 404.

Confirm the conversation is really gone before accepting that result, and retry
the pair when it is not. An unknown id still costs one existence read and falls
straight through to the 404.

* fix: resolve a fully contended archive to the chat's real state

Alternating archive and unarchive requests can split every attempt of the
compare-and-set: each transition write sees the chat archived and each
already-archived write sees it unarchived. Exhausting the retries therefore
proved nothing about whether the conversation exists, and the no-upsert archive
route turned a lost race back into a 404.

Read the conversation once more when the retries run out and answer with its
actual current state instead.
2026-08-16 17:07:44 -04:00
Marco Beretta
7857a99d63
perf: Flip the Pinned Flag Without a Full Conversation Save (#14862)
Pinning routed through saveConvo, which reads every message id for the
conversation and writes the whole array back just to set one boolean, and
can trigger a project-stats recompute on top.

None of that applies to a pin: it moves no chat between projects, changes
nothing the project workspace hides, and opens no retention window. A
dedicated setConvoPinned does the single findOneAndUpdate instead.

Measured against an in-memory MongoDB with the real message methods
wired in, on a 120-message chat: two driver commands and 3706 bytes
before, one command and 245 bytes after. The write scales with the
message count, so the gap widens on longer chats.

Archiving keeps using saveConvo, which it needs for exactly the project
stats and retention work a pin does not.
2026-08-16 17:07:27 -04:00
Marco Beretta
5d88b8453e
🪟 fix: Resolve Context Windows and Pricing for Newer Model Families (#14877)
* fix: resolve context windows for Qwen3.5+ and newer model families

Qwen3.5 model ids matched the `qwen3` key and inherited its 40,960 token
window instead of their native 262,144, so pruneMessages dropped whole
conversations down to the system message once tool output grew large.

Add the 3.5 through 3.8 generations, Meta Muse Spark, Muse Glimmer and
Llama 4, plus newer DeepSeek, GLM, Kimi, Grok, Mistral, Nova, Cohere and
MiniMax entries. Llama 4 Scout is capped at 1M rather than its 10M native
ceiling, since no host serves near that and a 10M value would stop
pruning from ever firing.

findMatchingPattern now matches a vendor-prefixed id on its model segment
first. No map key contains a slash, so a prefix could only ever
contribute a spurious longer match: moonshotai/kimi-k2 matched moonshot
(8 chars) over kimi-k2 (7) and reported half the real window.

* fix: price newer models and make the pricing test helper faithful

Models added to the context map billed either at defaultRate or at an
older generation's rate. Llama 4 and Muse Spark fell to $6/1M, Kimi K3
undercharged 5x through the kimi key, and Grok 4.5/4.6 overcharged
through grok-4. Add rates for 21 models.

tx.ts needs no matcher change of its own, since it receives
findMatchingPattern by injection from @librechat/api. The vendor-prefix
fix therefore already corrects moonshotai/kimi-k2, which was matching
moonshot at $2.00/$5.00 rather than kimi-k2 at $0.60/$2.50.

The data-schemas test helper implemented a different algorithm from the
function production injects, returning the first reverse-order match
instead of the longest, so the pricing suite was not describing real
billing behavior. Mirror the real implementation. Every existing test
still passes, so nothing was relying on the lossy version.

Rates for the Qwen 3.x plus/flash tiers, DeepSeek V3.1/V3.2, Kimi
K2.6/K2.7 and Nova 2 Pro are left as they are, since published figures
disagreed by more than the current fuzzy match is wrong.

* test: assert parity between the context and pricing maps

Neither map imports the other, so a model added to one and forgotten in
the other is silently wrong at runtime: it bills at defaultRate, or it
has no window to prune against. A comment in tx.spec.ts claimed such a
test lived in packages/api, but none existed.

Exact key parity is the wrong invariant. Both maps resolve by longest
substring match, so gpt-4-32k legitimately prices through the 32k bucket
without a row of its own. Assert instead that every key resolves in the
other map, with exemption lists covering the genuine gaps and a further
test that fails once an exemption goes stale, so the lists shrink rather
than rot.

Verified the assertions actually fail: injecting a context key with no
price, a price with no context window, and a rate for an exempted model
each fails the expected test and names the offending model.

* fix: correct GLM 5.3, Kimi K3 aliases, Grok tiering and prefixed overrides

Four gaps found in review.

glm-5.3 matched the glm-5 key and reported 204,800 tokens rather than the
1,048,576 it shares with 5.2. Add both the context and pricing rows.

The dot-prefixed Kimi K3 ids were added to the context map without
pricing counterparts, so moonshot.kimi-k3 resolved to moonshot.kimi and
moonshotai.kimi-k3 to moonshot, billing completion at 2.5 and 5.0 rather
than 15.0.

Grok 4.5/4.6 carry a 500K window, which makes xAI's doubled rate above a
200K prompt reachable, but neither key existed in premiumTokenValues so
long prompts stayed on the base rate. Add the premium entries.

Matching the model segment of a vendor-prefixed id was too eager.
EndpointTokenConfig is an arbitrary record and one built from OpenRouter
is keyed by org/model, so an alias like org/model-latest could resolve to
a bare model entry and pick the wrong configured context and rates. Keep
a matched key that carries the vendor, and retry on the segment only when
the whole id resolved to a prefix-only match.

* refactor: type the model matcher as keys-only and drop the caller casts

findMatchingPattern reads keys and never inspects a value, but its
parameter was typed as a union of the two maps it happens to be called
with, so every injection site cast into it. pricing.spec.ts already
carried that cast and parity.spec.ts copied it.

Name the intent instead. Both adapters now pass their map through
unchanged, and a future change that starts reading values will fail to
compile rather than slip past a cast.
2026-08-16 16:41:02 -04:00
Marco Beretta
5fc05ac037
🕰️ fix: Stop Pinning and Archiving From Counting as Chat Activity (#14861)
* fix: stop pinning and archiving from counting as chat activity

Both routes went through saveConvo, which lets mongoose stamp updatedAt.
The sidebar orders chats by that field, so pinning hoisted an untouched
chat to the top of Today, and unarchiving a year-old chat dropped it
there too instead of back into its own date group.

saveConvo now takes preserveUpdatedAt, and both routes pass it. They also
pass noUpsert: with timestamps suppressed an upsert would insert a
conversation carrying neither createdAt nor updatedAt, so an unknown
conversation id is now a 404 rather than a silently created stub.

* test: pin a project's activity pointer against metadata-only saves

Review raised that preserving updatedAt could drag a project's
lastConversationAt back to the pinned chat's older timestamp, since the
incremental path $sets it outright.

That path is not reachable here: a pin carries no chatProjectId, so
previousChatProjectId stays null while the conversation has a real one,
projectMembershipChanged is therefore true, and saveConvo takes the full
recompute branch instead. This test holds that in place, with a newer
sibling conversation in the project so a regression to the incremental
path would fail it.

* fix: keep updatedAt through the retention backfill

Under RetentionMode.ALL a legacy chat with no stored isTemporary gets a
second write after the main update, and that one still had mongoose
timestamps enabled. The first archive of such a chat therefore bumped
updatedAt anyway and landed in Today, defeating preserveUpdatedAt on
exactly the old conversations it was meant to protect.
2026-08-16 16:20:20 -04:00
Danny Avila
fb8ae881cf
⏱️ feat: Show Run-Step Durations On Tool Cards (#14892)
* ⏱️ feat: Show Run-Step Durations On Tool Cards

Surfaces how long each tool call took, derived from the `closed_at` /
`created_at` pair already carried by `on_run_step_closed` — the same event
#14871 and #14873 use for the terminal status. No new event, no new SDK
surface.

The duration is stamped onto the content part at the same three sites as
`runStepStatus`, so it survives a reload and a resumable reconnect rather
than living only on the live React message:

- `callbacks.js`, on the aggregated part before the event is forwarded
- `RedisJobStore`, in the host-authored replay reconstruction branch
- `useStepHandler`, on the live message

Rendering lands in the shared `ProgressText`, which nine tool cards already
use, rather than in each card: one place decides whether a duration is shown
and how it reads, and the cards only forward the number. That keeps this from
adding a tenth independent state derivation to a component family whose
label/announcement/progress split is already the subject of AI-1810.

The value is deliberately absent rather than zero whenever it would be a
guess — no `created_at`, non-finite input, or a negative elapsed time from
two clocks that disagree, which is now reachable because a step can be opened
in one process and closed in another after a checkpoint resume. Sub-second
durations are suppressed as noise, and it renders only on a settled,
non-error card, where the slot is not already carrying the cancelled icon or
the error suffix.

For assistive technology the compact form (`3.5s`) is hidden and paired with
a spoken equivalent ("took 3.5 seconds"), both inside the button, so the
accessible name carries the duration without an `aria-live` region
re-announcing it.

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

* 🎨 style: Sort Imports In Touched Files

The import-sort gate runs against the files a PR changes, so pre-existing
drift in `ProgressText.tsx` and `RedisJobStore.ts` surfaced on this branch.
Both were already unsorted on `dev`; this is the sorter's output, with no
semantic change.

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

* 🐛 fix: Accept Partial Timestamps In Run-Step Duration Helper

`getReportableRunStepDurationMs` declared its parameter as
`Pick<RunStepClosedEvent, 'created_at' | 'closed_at'>`, where `closed_at` is
required. That contradicted the function's own purpose: every guard inside it
exists precisely to handle stamps that may be missing.

The Redis replay branch reconstructs closures from persisted JSON and holds
nothing stronger than "might be a number", so it failed to typecheck against
the narrower signature.

Widened to an exported `RunStepTimestamps` shape with both stamps optional,
rather than asserting at the call site — an assertion would move the decision
about what is trustworthy somewhere it cannot be enforced, which is the thing
the helper exists to centralize. Callers holding a fully-typed event still
pass, since a required field satisfies an optional one.

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

* 🐛 fix: Suppress Duration When Failure Arrives As errorSuffix Alone

At every call site `error` carries cancellation while failure travels
through `errorSuffix` with `error` false, so gating the duration on
`!error` alone rendered "· 3.5s" beside "· failed" — and announced it.
The gate now checks both terminal-failure channels.

The original test pinned only the `error: true` path, which is why this
survived; the failed-via-suffix path is now pinned separately, both the
visible and the announced half.

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

* 🧩 refactor: Persist Raw Run-Step Durations, Threshold At Render Only

The three stamp sites filtered through the 1-second reportability
threshold before persisting, baking a presentation rule into stored
data: a 900ms step stored nothing, making "fast" indistinguishable from
"not derivable" and unrecoverable if the display rule ever changes.

Stamp sites now persist the raw `getRunStepDurationMs` value — absent
only when genuinely not derivable — and the renderer alone decides what
is worth showing, which `ProgressText` already did. Rendering is
unchanged. `getReportableRunStepDurationMs` is removed; it existed only
to serve the write-time filter, and a test now pins that sub-threshold
durations survive to storage.

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

* 🐛 fix: Suppress Duration On Backgrounded Bash And Code Cards

A backgrounded call's run step closes when dispatch returns the handle,
so the stamped duration is the dispatch time. Rendering it beside
"Running/Finished in background" misstated a detached task's runtime as
seconds — and violated the "settled card only" rule, since the card is
still tracking the detached run. Scope is exactly the two cards that
parse background handles.

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

* 🌍 fix: Format The Sub-10s Decimal For The Active Locale

The fractional seconds value was interpolated as a raw JS number, which
hardcodes the en-US decimal point into every language — "1.4s" where
the locale writes "1,4 s" — and translators cannot fix a number
formatted in code. The value is now formatted via Intl.NumberFormat
with i18n.language, following MessageTimestamp's pattern of threading
the language into the util; plural-key selection stays on the numeric
value. A malformed language tag falls back to the plain number.

Also documents the two accepted limits of the derivation, so they read
as decisions rather than oversights: positive clock skew is
undetectable from a single stamp pair, and the value is wall-clock
elapsed, so a step held open across a suspension (checkpoint resume,
HITL approval wait) includes that time.

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

* 🐛 fix: Persist A Durable `backgrounded` Marker Through Harvest; Localize Minute Digits

Codex round 3, both findings confirmed.

**Background origin survived only as transient state.** The dispatch
handle in `tool_call.output` and the live status-marker attachment are
both gone once the harvester patches the settled task's stdout over the
handle — so the round-2 suppression (`backgroundHandle == null`) came
back on after harvest or reload, showing dispatch time as the task's
runtime. Following the same rule as e4bd15d (persist facts, decide at
render): the harvest patch now stamps `backgrounded: true` onto the
tool call in the same atomic write that erases the handle — on the heal
path too, which re-applies over full-row saves that reverted the part.
The cards gate on handle-or-marker; the dispatch duration itself stays
stored.

**Minute-branch digits bypassed locale formatting.** The seconds branch
went through Intl.NumberFormat while minutes interpolated raw numbers,
so Arabic/Persian locales flipped to ASCII digits above one minute. All
interpolated values now flow through the (renamed) formatDurationValue;
an ar-EG test pins the localized digits.

data-schemas cannot be installed in this environment (same npm ci 403 as
packages/api), so message.ts/harvest.ts are syntax-checked with
resolution off and otherwise verified by review; CI runs their real
typecheck and suites.

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

* 🧪 test: Assert The `markBackgrounded` Stamp In Harvest Expectations

The successful-harvest test's exact `toHaveBeenCalledWith` object did
not include the newly forwarded `markBackgrounded`, so the API suite
would fail on it. All three harvest-call expectations now assert
`markBackgrounded: true` — the exact-object one of necessity, the two
`objectContaining` ones deliberately, since the durable stamp (on the
best-effort file-failure path and the reapply heal alike) is now part
of the behavior under test.

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

* 🎨 style: Wrap Harvest Spec Expectation Per Prettier

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 16:17:02 -04:00
Marco Beretta
d79d1ff76a
🎛️ feat: Consistent Dialogs, Clearer Settings, and a Keyboard Shortcuts Switch (#14882)
* refactor(AdminSettings): consolidate every admin dialog on one implementation

The People Picker admin dialog was a standalone reimplementation of the
shared AdminSettingsDialog with a better layout, leaving two components to
keep in sync by hand. Port that layout into the shared component and rewrite
People Picker to configure it, so all eight admin dialogs render from one
place.

The shared dialog gains the icon-tile header, the role selector and
permission switches as bordered cards, and a real footer. Its header row was
also top-aligning the 40px icon tile against a single-line title, leaving the
icon hanging 6px low; it now centers.

Both behaviors the standalone version lacked are preserved: the admin access
warning and the confirm-before-disable flow used by Prompts. Adds an optional
descriptionKey for a screen-reader description, and closes the dialog when
the mutation reports success, which is how People Picker kept its auto-close.
Permission switch ids are prefixed with useId so two mounted dialogs cannot
collide.

Marketplace dropped its dialogContentClassName override because the max-w-md
and background it set conflicted with the new padding.

* feat(ui): add FieldMessage for helper text that never shifts layout

Form fields across the app render their validation error conditionally, so
the error appearing pushes every field below it down. FieldMessage always
occupies one line and only swaps its content and color between a resting
hint, an error, and nothing, so the surrounding layout is fixed by
construction rather than by whichever message happens to be showing.

* fix(Memories): validate the key and value on the client

Creating or updating a memory only learned that its key was malformed or
already taken after the request came back, and the failure arrived as a
toast. getMemoryKeyError mirrors the schema validator in data-schemas and
checks for a duplicate within the same agent partition, so both dialogs
resolve the outcome before sending anything.

Errors render inline under the field instead of as a toast, live from the
first character typed, and Create and Save stay disabled while any error
stands. A pristine empty field shows only its hint, so the form does not
report a problem before there is one.

The edit dialog also closed itself in onMutate, discarding the user's edits
whenever the server rejected the write and leaving the error toast to land on
a dialog that was already gone. It now closes on success.

This drops six toasts to two: the field-required and duplicate-key toasts are
covered inline, leaving one generic toast per dialog for unexpected server
failures alongside the existing success toast.

* fix(Bookmarks): consolidate title validation and show it inline

The duplicate-title check ran from three sources against two different
strings: an inline validator reading the bookmark context, plus two warning
toasts in onSubmit reading the tags prop and the conversationTags cache.
All three now feed one helper behind the single inline validator, so both
toasts are gone and the message is always com_ui_bookmarks_tag_exists.

The title error was rendered conditionally, so it pushed the description
field down as it appeared and disappeared; it now uses FieldMessage. The
description registered a maxLength rule but rendered its error nowhere, so
exceeding 1048 characters silently refused to submit with nothing on screen.
It now reports like the title does.

Submitting also closed the dialog immediately, throwing away what the user
typed if the request failed, even though the mutation's onSuccess already
closed it. Dropping that leaves the form with no reason to take setOpen.

Renaming is no longer blocked when the title is unchanged: the tags-prop
check had no exemption for the bookmark's own title, so editing just the
description of a bookmark attached to the current conversation reported a
duplicate and refused to save.

The two tests that asserted the removed toasts now assert the inline error
and that no toast fires.

* feat(Skills): label the availability toggle and drop the detail icon

The toggle in the skill detail header was a bare switch whose only name was
an aria-label reading "Toggle skill active state", so nothing on screen said
what it did, and "active" did not say active for what. It now carries a
visible "Available to agent" label bound to the switch, plus a tooltip
stating the effect: when on, the agent can use this skill in new messages.

The label text stays fixed while the switch carries the state, so flipping it
cannot resize the action row and nudge the buttons beside it.

Also removes the decorative ScrollText circle from the detail header. It
conveyed nothing the heading did not already say, and dropping it lets the
title block sit at the top level instead of nested inside a flex row that now
has a single child.

* feat(Settings): move file management into Data Controls and clarify its labels

Files were reachable only from the account dropdown, away from the other
data-management entries. A Manage files row now sits in Data Controls beside
Import conversations and Shared links, opening the same modal, and the
account menu item is gone so there is one place to look.

Two labels renamed for accuracy and consistency:

- "Revoke all user provided credentials" becomes "Revoke all provider API
  keys". It sits in the API keys section beside Provider API keys and Agent
  API keys, so it should name what it revokes; "credentials" was vague and
  "user provided" described the system's perspective rather than the user's.
- "Clear all chats" becomes "Delete all chats", matching its own Delete
  button and the "Delete TTS cache storage" row beside it. The action is
  irreversible, which delete states more plainly than clear.

The TTS cache row gains an InfoHoverCard, the same explanation affordance
used by the API keys dialog. Nothing previously said what the cache held or
why its button is so often greyed out, which happens whenever the cache is
empty, including for anyone who has never used text-to-speech.

* feat(Shortcuts): add a switch that disables every keyboard shortcut

There was no way to turn shortcuts off short of rebinding each one to
nothing, which loses the bindings. A switch at the top of the shortcuts
dialog now suppresses all of them at once while keeping every custom binding
intact, so turning it back on restores the previous setup.

The preference persists per browser in localStorage next to the custom
bindings. Enforcement is a single guard in the window keydown handler, which
already owns every shortcut, so nothing dispatches while it is on.

Nothing is exempt, including the chord that opens this dialog. The dialog is
still reachable from the account menu, so the switch cannot lock anyone out,
and an exception would contradict what it says.

useShortcutDisplay and useShortcutAriaKey return empty while it is on, so
tooltips and aria-keyshortcuts across the app stop naming chords that would
not fire. The binding rows stay editable, so shortcuts can be configured
before turning them back on.

* style(Skills,Prompts): align side panel spacing with the other panels

Memories and Bookmarks share one spacing contract: 8px above the header,
12px down each side, and 12px under the last row. Skills and Prompts each
drifted from it, so switching panels nudged the content.

Skills sat at 16px per side and 12px on top, with the list running flush into
the bottom edge. Its top padding now comes from the panel root like the other
panels, its header and list use the shared 12px sides, and the list gets the
same bottom inset.

Prompts was applying the top padding twice, once on the panel root from the
accordion and again on its own header, for 16px, and its list also ran flush
into the bottom. The header no longer adds its own, and the list gets the
bottom inset.

Its asymmetric pl-3 pr-1 is left alone: the list reserves an 8px scrollbar
gutter, so those values already render as an even 12px on both sides.
Squaring the padding numbers would have made the panel visibly lopsided.

Measured after the change, all four panels report 8px top, 12px bottom, and
12px on each side.

* refactor(Shortcuts): invert the switch to an enabled-by-default control

The control read "Disable keyboard shortcuts", so it was on when the feature
was off. Inverting it makes the switch agree with the thing it names: it now
reads "Keyboard Shortcuts", ships on, and turning it off is what stops the
shortcuts.

The stored value follows, from keyboardShortcutsDisabled to
keyboardShortcutsEnabled defaulting to true. Nothing migrates the old key
because the previous shape never shipped, and an absent value now means
enabled, which is the default anyway.

The row loses its filled card and sits as a plain bottom-bordered row under
the title, reading as a section header for the list rather than a block
competing with it.

Every binding row now renders as disabled while the switch is off, dimmed
with its edit and reset buttons actually disabled rather than merely looking
inert. Any row left mid-edit is closed when the switch goes off, so the
recorder cannot keep capturing keys for a shortcut that would not fire.

* style(Shortcuts): fit the dialog on desktop without a scrollbar

Open panels was a full-width block stacked under the two shortcut columns, so
opening the dialog on a desktop viewport always started with a scrollbar, at
about 100px of overflow.

It becomes the third column instead. That removes the stacked block entirely,
the three columns land at comparable heights, and the content now fits with
nothing to scroll. The dialog widens on large screens to hold the extra
column.

Narrower viewports are unchanged in spirit: the panels list spans both
columns below the shortcuts at tablet width and everything stacks into one
column on a phone, scrolling as it did before.

Reflowing the groups with CSS multi-column was the other option and looked
worse: the short groups left a tall void beside Chat, and squeezing the panel
rows into four columns truncated their labels.

* style(Skills): make Edit an icon button and drop the detail text below the actions

Edit was the only text button in a row of icon buttons, so it read as a
different kind of control than Share and Delete beside it. It becomes a
pencil icon at the same 36px size, carrying its label through a tooltip and
an aria-label so the accessible name survives.

The header row also centred its two halves against each other, which pinned
the title level with the action buttons. The actions now pin to the top of
the row and the text column starts below them, giving the title, author,
date, and description a little room without moving the controls.

* test: mock shortcut setting in expanded panel

* remove unused com_ui_skill_toggle_active i18n key

Superseded by com_ui_skill_available and com_ui_skill_available_hint in SkillToggle.tsx, but the old key was left behind in the locale files.

* fix: honor shortcut switch in composer

* fix: defer file loading until dialog opens

* fix: preserve memory API errors

* chore: restore automated locale entries

* fix: honor shortcut switch during generation

* refactor: share field message primitive

* fix: reserve helper height for wrapping field messages

* fix: wrap skill detail actions at narrow widths

* fix: reset the memory create dialog when it closes
2026-08-16 16:10:17 -04:00
Marco Beretta
8a946290f6
📌 fix: Fetch Pinned Chats Independently of the Chats List (#14860)
* feat: give the pinned chats section its own fetch

The sidebar's pinned section filtered pinned chats out of the paginated
chats list, which only holds the 25 most recently updated conversations.
Once 25 newer chats existed, a reload hid the pin until the list was
scrolled far enough to fetch the page it lived on.

Pins are now fetched directly via GET /api/convos?pinned=true behind a
dedicated query, so every pin paints with the sidebar regardless of where
it falls in the chats list. Pin and unpin invalidate that query, and the
shared conversation cache helpers keep it in step so a rename, delete or
archive is reflected without waiting for a refetch.

Pins stay out of the date groups, which groupConversationsByDate already
handled.

* fix: address review findings on the pinned chats section

- Drain the cursor rather than capping the pinned request at 100. Since
  pins are kept out of the chats date groups, anything this query dropped
  was invisible in the sidebar entirely, not merely further down a list.
- Apply the active bookmark filter to the pinned request and key its
  cache by it, matching the chats list beside it.
- Move a pin to the top of the section when the caller asks for it, so a
  pin that just received a message leads the way it does in the chats
  list instead of waiting for a refetch.
- Invalidate the pinned list when a conversation is unarchived, since
  archiving removes it from that cache and nothing put it back.
- Index the pinned lookup: it filters on user + pinned and sorts by
  updatedAt, which no existing compound index covered.
- Protect `pinned` from saveMessageToDatabase's unset sweep. Any
  persisted field missing from endpointOptions is unset, so sending a
  message in a pinned chat silently unpinned it.

* fix: keep the pinned cache reconciled across the other convo mutations

Second review pass on the independent pinned query.

- Fall back to the pins already loaded in the chats pages when the
  dedicated request fails. Pins are stripped from the date groups, so an
  error otherwise emptied the section and hid them everywhere.
- Restore default focus and reconnect refetching, matching the
  conversations query. A pin changed in another tab is only reconciled by
  a refetch, since that tab's mutation never touched this cache.
- Invalidate the pinned list from the mutations that can produce or alter
  a pinned chat without going through pin itself: duplicate, fork,
  import, project assignment, and shared-link deletion.

* fix: invalidate pins on tag and project-deletion changes

Third review pass, same class as the last: the pinned query is keyed by
the active bookmark filter, so changing a chat's tags can move it in or
out of that filtered set, and deleting a project unsets chatProjectId on
its chats, pinned ones included.

* fix: cancel in-flight pinned fetches when deleting a conversation

Deletion cancelled the regular and archived queries but not the pinned
one, so a pinned GET issued before the delete could resolve after the row
was stripped and write the deleted conversation back, leaving a row that
navigates to a missing chat. Restoring default focus and reconnect
refetching in the previous commit made those in-flight fetches more
likely, so this widened rather than appeared.

Cancelled on mutate, and invalidated on success since cancelling a race
is best effort.

* test: make the SSE query-cache mock key-aware

The conversation cache helpers now run a second, pinned-keyed findAll
pass. This mock ignored its key argument and always returned an
allConversations entry, so those pinned writes were attributed to
allConversations and the write-count assertions saw three instead of two.

* fix: keep pins in sync through upsert and pin-only pages

Root-level SSE updates and resumable settlement call upsert rather than
update, so the independently cached pinned row never moved or refreshed.
An all-pin first page also left the chats virtual list empty, so
onRowsRendered never asked for the next cursor.

* fix: keep pins current through SSE recovery and project delete

Resumable SSE reconciliation invalidated conversation and allConversations
only, so an independently cached pin kept stale title and order.
Deleting a project-backed pin that lived only in that cache also skipped
the project query, because the mutation never read chatProjectId there.

* fix: keep pins current after bookmark edits and failed pages

Renaming or deleting a bookmark rewrote tags on conversations but left
the tag-keyed pinned cache pointing at the old filter. An all-pin page
whose next fetch failed also retried forever because the empty-list
effect had no memory of the attempt. Unpinning a pin that only lived in
the dedicated cache removed it from Pinned without inserting it into
Chats, and later cursor pages cannot recover a row whose updatedAt just
jumped ahead of the current cursor.

* fix: keep pins visible after a failed refetch

A failed pinned refetch left React Query holding the previous list, so
the nullish fallback never ran and a newly pinned chat vanished from
both sections. Unpinning an older pin also inserted it into every
cached chats variant, including bookmark and search results it would
not match. Drop the checked-in agent task prompt.

* test: type the pinned conversation fixtures correctly

The delete mutation takes a plain string conversationId, but reading it
back off a TConversation fixture widens it to string | null. Hoist the id
into its own constant so the call site passes the real string.

Type the tag fixture as TConversationTag so it carries the required _id
and user fields the mocked resolved value expects.

* style: sort the sidebar imports to the repo order

The new pinned-section imports went in out of the longest-to-shortest
order the import sorter enforces.

* fix: keep drained pins and empty chat caches from breaking the sidebar

A pinned page failing partway through the drain rejected the whole query, so
every pin already fetched was discarded and the section fell back to whatever
the chats cache happened to hold. Publish the accumulated pins before
rethrowing so the retry renders against the partial set.

Unpinning a chat that only lives in the pinned cache reinserted it into the
chats list by spreading the first page, which is absent once removal has
filtered out the last loaded row. Rebuild that page instead, matching the
upsert path.

* fix: order fallback pins by their timestamp

The merge kept dedicated rows in Map insertion order and appended the pins
recovered from the chats cache after them. A chat pinned while the dedicated
refetch is failing is the newest pin, so the server would return it first, yet
it landed last and could sit below the section's visible 30vh. Sort the merged
set newest-first so a fallback row takes the place the server would give it.

* fix: keep the shared badge and the move-to-top order on pins

The pin response has no isShared: the flag is derived per list request by
attachSharedFlags, which only runs for the list queries. Reinserting an unpinned
chat into Chats therefore dropped its shared-link badge, because unlike an
in-place update there is no existing row to carry the flag over from. Read it off
the cached pin before the update removes that row.

The chats cache refreshes updatedAt when it moves a conversation to the top, but
the pinned cache only reordered, leaving the previous turn's timestamp on the row.
Sorting the section newest-first then put it straight back. Refresh the timestamp
there too, so the move survives the sort and both caches agree.
2026-08-16 11:28:33 -04:00
Marco Beretta
0b995065bc
🗂️ feat: Rework the Projects Dashboard, Sidebar and Scoped Composer (#14866)
* style: Redesign the Projects Dashboard and Sidebar

Give /projects a sticky navbar, quieter search/sort toolbar, and
folder-style cards. Drop the create-dialog close control, restyle
the sidebar Projects row, and align the workspace with the same
layout language.

* feat: Edit a Project Name and Description

Add a shared edit dialog so a project can be renamed and given a
description from the workspace or the sidebar menu. The create flow
already stored descriptions; this is the matching update path.

* feat: Delete a Project from the Workspace

Extract the project delete confirmation into a shared dialog and
expose it on the workspace header so deleting no longer requires
the sidebar menu.

* feat: Add Edit and Delete Actions to Project Cards

Give dashboard cards a more-options menu that opens the same edit
and delete dialogs as the workspace, so those actions are not
workspace-only.

* feat: Match Project Descriptions When Searching Projects

Project search only matched the name, so a project found by its
description was invisible in the sidebar and the projects dashboard.
Match the escaped search term against name or description.

* feat: Let Consumers Place and Size the ControlCombobox Popover

The popover always matched the trigger width, sat 4px from it and used
the same enter animation, which is wrong for a pill-shaped trigger in a
composer and for a full-width field in a dialog.

Add popoverClassName, matchTriggerWidth, gutter and portal so a consumer
can opt out of each. All four keep the current behaviour by default, so
existing comboboxes are unchanged; portal in particular stays true, as
turning it off inside a scrollable dialog would clip the list.

* fix: Re-render Conversation Rows When Pinned State Changes

areConversationListItemFieldsEqual left pinned out of its comparison, so
a row memoised on it kept rendering the stale pin state until some other
tracked field changed.

* fix: Keep the Project Scope When Starting a Chat From a Project

Starting a chat from the project workspace set chatProjectId on the
draft but left the URL on the unscoped route, so a reload or a refresh
of the route dropped the project. Navigate to the project-scoped new
chat URL alongside the draft.

* style: Move the Project Chip Into the Composer

The chip floated above the composer as a separate row, which read as an
unrelated control and pushed the conversation starters down. Render it
inside the composer border as the first row instead, and pass the
project through ChatForm so the memoised form still controls it.

The remove button no longer fades in on hover, since a control that only
appears on hover is unreachable by touch. Its popover opens upward with
a matching bottom-origin animation that honours reduced motion.

* feat: Rebuild the Change Project Dialog on the Searchable Combobox

The dialog used a bare select, so picking a project meant scrolling an
unsearchable native list capped at the default page of 25. Use the
searchable ControlCombobox, request the full first page, and disable
Save until the selection actually differs from the current project.

The combobox opts out of portalling so its search field sits inside the
dialog's focus trap and can be typed in, and the dialog is
overflow-visible so the list is not clipped.

Unassigning now lives on the menu's own Remove From Project action, so
the dialog no longer needs an empty option. Returning focus to the menu
button rather than the menu item fixes focus being lost on close, since
the item unmounts with the menu.

* feat: Add an Overflow Menu to Project Workspace Chats

Chats in a project workspace could only be opened. Managing one meant
finding it again in the sidebar, so add the same actions to the row:
change project, remove from project, and delete.

The row becomes a card matching the project cards, and the endpoint icon
is rendered at landing size rather than in a tinted tile. Its memo
comparison now uses areConversationListItemFieldsEqual, since the render
props comparison ignored fields the row displays.

* feat: Rework the Projects Sidebar Section

The section header duplicated the projects count and the New Project
action already on the dashboard, and spent a row on a chevron button
separate from its label. Collapse it to a single label toggle with an
All Projects action, and drop the per-project count that was hidden on
hover anyway.

The new chat action becomes a real link to the project-scoped URL, so it
can be middle-clicked and opened in a new tab, and modified clicks fall
through to the browser. On the new chat route it commits ?projectId
synchronously, because a deferred search param update lets ChatRoute see
a project-scoped draft on an unscoped URL and wipe it.

Row actions stay visible on devices without hover, where an action that
appears on hover cannot be reached.

* style: Widen the Projects Dashboard and Workspace Layout

The dashboard and the workspace sat on bg-surface-primary at different
max widths, so moving between them shifted the content and the shade did
not match the rest of the app. Put both on bg-presentation at max-w-6xl.

Project and chat cards gain a border, since colour alone separated them
from the background and that separation is thin in light mode. The
translucent blurred headers become opaque, and the scale-on-press
transforms are dropped.

In the workspace the edit and delete actions move out of the heading row
into their own group, so a long project name no longer pushes them
around.

* fix: Reopen the Change Project Dialog From the Conversation Menu

Closing the Ariakit menu in the same handler that opens the dialog made
the menu's own dismissal land on the freshly mounted Radix dialog, which
closed it again before paint, so Change project did nothing.

Leave the menu close to the dialog, which already receives setMenuOpen
and closes it once the assignment succeeds, matching the share and
delete handlers beside it.

* fix: Keep the Project Chat Menu Mounted While its Dialogs Open

Hiding the Ariakit menu in the same handler that opens Change project or
Delete restores focus to the menu trigger, which the dialog mounting
alongside it reads as an outside interaction and closes on, so the
action could do nothing.

Both dialogs already receive setIsMenuOpen and close the menu once they
finish, so leave the close to them, as the conversation menu does.

* perf: Fetch a Project's Chats Only Once its Row is Expanded

Collapse hides its children with CSS and inert rather than unmounting
them, so every project row's chat query ran on sidebar load, up to one
request per project, even with the whole section collapsed.

Gate the query on the row's expanded state. React Query keeps what it
already fetched, so reopening a row is still instant.

* refactor: Move the Bottom Popover Animation Into the Shared Primitive

ControlCombobox owns its popover animations in AnimatePopover.css, so
the upward variant it needs belongs there rather than in the
application stylesheet, where the control's appearance would diverge
from the package that ships it.

The app already loads the package stylesheet, so the animation resolves
the same way the existing variants do.

* fix: Stop Project Names and Descriptions Being Silently Truncated

The dialogs accepted any length and reported success, while the
persistence layer trimmed names to 100 and descriptions to 1000
characters, so reopening a project revealed text had been dropped with
no warning.

Share both limits from data-provider and cap the inputs at them, so the
fields stop where the server would have cut them and the rule has one
definition instead of the three it had.

* fix: Do Not Report the Loaded Page Size as the Project Total

The dashboard counted the projects fetched so far, so an account with
more than one page read as exactly one page's worth and the supposed
total grew with each Load more.

Show the loaded count as a lower bound while another page exists.

* chore: Satisfy the Static Checks for the Projects Rework

Sort the delete dialog's imports to the repository order, and drop the
three English keys this branch orphaned: the sidebar menu now says Edit
project, the dashboard labels its own sort control, and the change
project dialog no longer offers an Unassigned option now that removal
lives on the menu.

* fix: Highlight Only the Route Project in the Sidebar

A leftover conversation project was still lighting a second row after
opening another project's workspace. Prefer the workspace route, and
only fall back to the conversation project outside that view.
2026-08-16 11:06:18 -04:00
Danny Avila
1153b70898
🔙 fix: Stop Stacking History Entries on New-Chat Param Changes (#14891) 2026-08-16 11:05:07 -04:00
Danny Avila
bce93f9c55
🎯 refactor: Infer Agents Endpoint for Model Specs Naming an Agent (#14889)
* 🎯 fix: Infer Agents Endpoint for Model Specs Naming an Agent

A model spec whose preset names an `agent_id` but omits `endpoint` was
unusable. `isModelSpecEndpointMatch` compares the request's endpoint to
`preset.endpoint` by strict equality, so an undefined endpoint matched
nothing and every request selecting the spec was rejected with a bare
`Model spec mismatch` — an error naming neither the spec nor the missing
field.

The selector had the matching half of the same gap: `handleSelectSpec`
read `preset.endpoint` directly, so it sent no endpoint and skipped
assigning `agent_id` to `model`. Fixing only the server would leave the
request malformed, so the resolution is shared between both.

- Add `resolveModelSpecEndpoint` to `librechat-data-provider`, inferring
  the agents endpoint when a preset names an agent and none is set. An
  explicit `endpoint` always wins, so configured specs are unaffected.
- Use it for endpoint matching and in the selector, so the menu and the
  request pipeline resolve a spec identically.

* 🔁 refactor: Materialize Inferred Spec Endpoints at Config Load

The review showed the lazy-resolver approach was unsound end to end:
config validation rejected an endpoint-less spec before the resolver
could ever run (`tPresetSchema` requires the `endpoint` key), and the
resolver was applied at 2 of ~8 read sites, leaving selection handlers,
startup presets, access filters, and provider-key reachability reading
the raw preset.

Materialize once at the boundary instead:

- `tModelSpecPresetSchema` now makes `endpoint` optional (`nullish`).
  This is barely a widening — `endpoint: null` already validated — and
  only for model-spec presets; `tPresetSchema` is untouched.
- `materializeModelSpecEndpoints` writes each spec's resolved endpoint
  back onto its preset. `createAppConfigService` applies it at both
  effective-config assembly points — YAML base load and DB-override
  merge — so admin-panel specs stored in override documents are covered.
  Identity-preserving, so cached configs see no new references when
  nothing needs filling in.
- Every consumer now reads complete specs; the client's lazy resolve in
  `handleSelectSpec` is reverted to a raw read. `getModelSpecPreset` and
  the two hand-rolled preset constructions resolve the endpoint
  explicitly, which the narrowed preset type now enforces at compile
  time for any `TPreset`-shaped destination.
- `isModelSpecEndpointMatch` keeps the resolver as request-time defense.

* 🩹 fix: Materialize Spec Endpoints Before the YAML Missing-Endpoint Guard

`processModelSpecs` warns and skips any spec whose preset lacks an
endpoint, and it runs inside `loadBaseConfig` — so the previous commit's
materialization received a YAML list from which the inferable spec had
already been dropped. Only DB-override specs (merged after the guard)
actually benefited.

- Materialize at the entry of `processModelSpecs`, so inference happens
  before the guard and YAML agent specs survive it. The guard keeps
  skipping genuinely endpoint-less specs. The `createAppConfigService`
  calls stay: the base-path one guards alternate `loadBaseConfig`
  implementations, the merged-path one covers override documents, and
  both are identity-preserving no-ops when specs are already complete.
- Constrain the widened schema: omitting `endpoint` is only legal when
  the preset names an `agent_id`. A preset with neither validated as a
  hard error before the key became optional, and silently accepting it
  would trade that startup-time error for a dead spec. An explicit
  `endpoint: null` (valid before this PR) keeps validating.

* 🩹 fix: Infer Only From Non-Empty Agent IDs, Never Over Explicit Null

Two edge cases in the inference contract:

- `agent_id: ''` (what a form-backed writer persists for an untouched
  field) passed the nullish checks, validating and materializing a spec
  that names no agent. Both the refinement and the resolver now require
  a non-empty id, so such config fails validation loudly instead of
  producing a selectable spec that cannot work.
- `endpoint: null` alongside an `agent_id` was treated as inferable,
  silently activating a spec that validated — and was skipped — before
  this PR. An explicit null is a statement, not an omission: the
  resolver now infers only when the key is absent, preserving prior
  behavior for previously valid configs.
2026-08-16 11:04:52 -04:00
Danny Avila
ed081964d6
🏷️ fix: Model Spec Menu Label and Agent Avatar Fallbacks (#14887)
* 🏷️ fix: Model Spec Menu Label and Agent Avatar Fallbacks

The selector's header already falls back from a spec's `label` to its
`name` (`getSelectedValueText`), but the menu item and search result
render `spec.label` bare. A spec persisted without a label therefore
shows its name in the header while its row in the list is blank —
selectable, but unlabeled.

Specs targeting an agent had a related gap: the chat landing resolves
the agent and shows its avatar, while the selector resolved icons from
the spec/preset/endpoint only. An agent with an avatar still rendered a
generic endpoint icon in the menu unless `iconURL` was set by hand.

- Fall back to `spec.name` when `label` is absent or empty, matching the
  header.
- Resolve the target agent's avatar as the icon when the spec defines
  none of its own; an explicit `iconURL` still wins.

`getSpecAgentAvatarURL` returns the avatar as a primitive so the
memoized `SpecIcon` compares a string rather than the identity of the
agents map, and both call sites already consume `useModelSelectorContext`,
so reading `agentsMap` adds no new subscription.

* 🔁 refactor: Normalize Spec Labels on Ingest, Harden Agent Avatar Lookup

Addresses the review findings, which fell into two patterns rather than
four independent bugs.

The label fallback was applied per render site, but the spec list feeds
five consumers: the menu row, the search row, `filterItems`, the
spec/endpoint discriminator in `SearchResults`, and `getSelectedIcon`.
Guarding two of them left `filterItems` throwing on `label.toLowerCase()`
and the discriminator misclassifying a label-less spec as an endpoint —
reproducing, inside the fix, the exact inconsistency the fix targeted.

Normalize once on ingest instead: `normalizeModelSpecs` fills a missing
label from the name where the selector derives its spec list, so every
consumer works from a complete spec and the per-site guards are removed.
It returns the original array and the original spec objects when nothing
needs filling in, so memoized consumers see no new identities.

The avatar helper had the same shape of problem — a second resolution
path that skipped what the existing one already handled:

- Resolve via `getAgentAvatarUrl`, which supports agents persisting
  `avatar` as a URL string rather than an object.
- Gate on `isAgentsEndpoint`. `tModelSpecPresetSchema` permits `agent_id`
  alongside any endpoint, so a leftover id on a non-agent spec would
  otherwise surface an unrelated agent's identity.
- Thread the avatar through `getSelectedIcon`, so the selector trigger
  keeps the agent avatar after selection instead of reverting to the
  generic endpoint icon.

* 🩹 fix: Treat Empty Spec Icon Fields as Unset

`??` only skips null and undefined, so an `iconURL: ''` — what a form-backed
config writer persists for an untouched field — stopped the chain and
suppressed both the agent avatar and the endpoint icon, leaving the generic
fallback.

Resolve the first non-empty candidate instead. This matches
`applyModelSpecPreset`, which already ignores an empty `iconURL`, and keeps
`showIconInMenu` as the explicit way to render no icon.

* 🔁 refactor: Normalize Spec Labels at the Query Boundary, Cover Favorites

The context-level normalization missed a parallel consumer: the
favorites sidebar reads `startupConfig.modelSpecs.list` directly, so a
pinned label-less spec rendered a blank row and its icon ignored the
agent avatar — the same two gaps this PR fixes in the selector.

Move normalization to the true shared boundary instead of adding
another per-consumer call: `useGetStartupConfig` normalizes in its query
function, once per fetch, cached — so the selector, search, mentions,
favorites, and provider-key reachability all read complete specs. The
`ModelSelectorContext` call is removed as redundant.

- `normalizeModelSpecs` is now a single pass with a lazy copy (allocated
  only on the first incomplete spec), replacing the `some` + `map`
  double scan.
- `FavoriteItem` threads `agentAvatarURL` into `SpecIcon`; the list
  resolves it per spec from the agents map it already holds, passed as a
  primitive so memoization is unaffected.
2026-08-16 11:04:25 -04:00
Danny Avila
06bf324cf0
🛤️ feat: Per-Agent Code Execution Routing With Stateful Session Scopes (#14848)
* feat: route code execution per agent profile

* chore: sort execution profile imports

* test: preserve stateful environment literal types

* fix: isolate stateful code environments by user

* fix: preserve per-agent code routing end to end

* fix: route code priming by execution profile

* fix: isolate code profile lifecycle state

* fix: preserve mixed-profile code resources

* fix: complete stateful skill routing
2026-08-16 09:42:15 -04:00
Danny Avila
d411512a98
⬆️ chore: Bump @librechat/agents to v3.6.0 (#14890)
* ⬆️ chore: Bump `@librechat/agents` to v3.6.0

Bumps the pin in `api` and `packages/api` from `^3.5.1` to `^3.6.0`. The
caret on `^3.5.1` cannot cross the minor, so both manifests and the lockfile
need the explicit bump.

v3.6.0 contains three changes over v3.5.1, all additive:

- `fix: Close Subagent Child-Graph Run Steps` — subagent child graphs run via
  `workflow.invoke()` outside `Run.processStream`, so the terminal sweep never
  reached their steps. They now close on both the success and error paths,
  which is what makes `on_run_step_closed` reliable for subagent tool cards.
- `fix: Restore Run Steps Across Process Resumes` — open run-step lifecycle
  state is now persisted in LangGraph checkpoints, so a step opened by one
  process closes correctly after a resume on another.
- `feat: route code execution per agent profile` — new optional
  `codeSessionKey` partition for code-session ids and file refs.

No breaking changes: every new field on the public type surface is optional,
and the package's own dependency set is unchanged between the two versions
(verified against the registry), so the lockfile diff is limited to the
`@librechat/agents` entry itself.

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

* 🔒 chore: Sync `bun.lock` with the agents v3.6.0 bump

`bun.lock` still recorded both workspace requirements and the resolved
package as `@librechat/agents@3.5.1`, which no longer satisfies `^3.6.0`, so
`bun install --frozen-lockfile` would reject the committed state.

`bun install --lockfile-only` cannot run in this environment: bun stores no
integrity for the `xlsx` URL dependency and therefore re-fetches
`cdn.sheetjs.com`, which the sandbox network policy denies (403 on CONNECT).
The entry was updated directly instead, which is exact here because the
package's dependency graph does not move between the two versions: its
`dependencies`, `peerDependencies` and `optionalPeers` at 3.6.0 are identical
to 3.5.1 (checked against the registry), so only the version, the resolution
id and the integrity hash change. The integrity matches the one npm resolved
into `package-lock.json`, and the two existing
`@librechat/agents/*` hoisting overrides stay valid because the dependency
set they resolve is unchanged.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 09:15:35 -04:00
Marco Beretta
cf30661d20
🗂️ feat: Display Chat Title in Tab Setting (#14881)
* Add setting to toggle chat title in browser tab

Adds a General > Layout toggle controlling whether the browser tab shows
the conversation title or the app title, defaulting to on so existing
behaviour is unchanged.

The tab title had no single owner: several call sites assigned
document.title directly. Route the chat-title writers through a shared
setDocumentTitle helper so the setting applies consistently to sidebar
navigation, search results, SSE title generation, title polling, and the
share view.

* test: cover document title settings

* fix: address chat title review findings

* fix: keep app title for new chats

* fix: distinguish new chat title placeholder
2026-08-16 08:59:31 -04:00
Danny Avila
db675209e8
🧩 refactor: Extend Step Status To Remaining Cards; Separate Cancelled From Failed (#14873)
* 🧩 fix: Extend Explicit Step Status To Specialized Tool Cards

Follow-up to #14871, which covered the generic tool card and the five
sharing `useToolCallState` but left the cards carrying their own
cancellation logic on the whole-message heuristic.

Each needed its own treatment rather than a forwarded prop:

- `RetrievalCall` is the exact analog of the reviewed shape.
- `WebSearch` feeds `effectiveProgress` into `finalizing` and `complete`
  as well, so forcing it terminal naively would strand a cancelled final
  search as "finalizing" forever. A closed step now settles on its own
  status instead of waiting for the submission to end.
- `OpenAIImageGen` resolves through `computeCancelled`, which now
  short-circuits on explicit status ahead of both the agent and legacy
  paths — the legacy path has no submitting signal at all, so this is
  the first real stop signal it has ever had.

In all three the status is authoritative on its own terms: never gated
on the output-parsing error check, a closed step forces progress
complete so it cannot keep animating, and `failed` reports as an error
even when the output text looks benign. The prior heuristic remains the
fallback for messages saved before `on_run_step_closed`.

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

* 🧩 fix: Resolve Subagent Card State From Closed Step Status

`SubagentCall` uses a tri-state (`running`/`cancelled`/`finished`) built
from the subagent's own phase envelopes plus `!isSubmitting`, so it
could not distinguish "this subagent was stopped" from "the parent
stream ended for some other reason" — the distinction `on_run_step_closed`
exists to make.

A closed step now resolves the tri-state directly: `cancelled` maps to
the cancelled state, `completed` and `failed` both count as finished,
and `failed` additionally reports as an error. The phase-and-isSubmitting
inference remains the fallback for messages predating the event.

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

* 🩹 fix: Give `failed` Its Own Terminal Path In Each Card

Codex found the same mistake in three places: I resolved `cancelled`
carefully and let `failed` fall through into a success or in-progress
path. Forcing a closed step's progress to 1 made that visible rather
than latent.

- `OpenAIImageGen`: `hasError` did not account for the status, so a
  failed generation rendered and announced as a finished image. Updated
  to match every other card.

- `WebSearch`: a failed close left `complete` false and dropped the card
  into the streaming branch, shimmering forever. `error` now folds into
  the `cancelled` early-return, which is where an errored search has
  always gone — rather than inventing a failure UI this component has
  never had.

- `RetrievalCall`: the live region did not consult `errorState`, so a
  failed retrieval announced "retrieved files" while the card showed a
  failure. Announces the failure first now, mirroring the same fix made
  to `ToolCall` in #14871.

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

* 🧯 fix: Separate Cancelled From Failed, Stop Terminal-Step Timers

Codex round 2. Two distinct classes, plus the same leak in already-merged
code.

Rendering — cancellation and failure were collapsed:

- `OpenAIImageGen` fed `cancelled` into `ProgressText`'s `error` prop, so
  a user-stopped generation read "image generation failed" visually
  while the live region announced "cancelled". Meanwhile a `failed`
  close reached neither consumer, since `computeCancelled` returns false
  for it — adding the status to `hasError` alone changed nothing.
  `ProgressText` now takes `cancelled` alongside `error`, and both the
  card and the live region resolve the two states independently.

Timers — masking a hook's output does not stop it:

- `useProgress` keeps a 200ms interval alive whenever its input is below
  1, and a closed step usually never receives the completion that would
  raise it. Every site that masked the result now passes the terminal
  value in instead, so a closed card schedules nothing.
- The agent-style image ticker had the same problem one layer up: its
  interval effect ignored the close entirely and its cleanup keyed on
  `cancelled`, so a step closed as `failed` mid-submission kept
  rerendering for up to ~50s. Both effects now observe the close.
- `ToolCall` and `useToolCallState` carried the identical masking from
  #14871; fixed here rather than left as a known leak in merged code.

Also dropped a JSDoc line that narrated its own assignments.

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

*  fix: Failure Outranks Cancellation; Update The Test That Encoded The Conflation

Codex round 3, plus the CI failure it explains.

- Failure now takes precedence over cancellation in both `ProgressText`
  and the image-gen live region. The legacy inference folds `hasError`
  into its cancellation signal, so checking `cancelled` first relabelled
  a genuine failure on an older saved message as a user stop — a
  regression introduced by the previous commit.

- `RetrievalCall` passed `finishedText={intent ?? 'Retrieved files'}`
  regardless of state, so a cancelled retrieval read "Retrieved files"
  beside a cancellation icon while the live region announced
  "Cancelled". The finished label is now cancellation-aware.

- `OpenAIImageGen.test.tsx` asserted `data-error === 'true'` for a
  heuristically cancelled step — the exact conflation this work removes.
  Updated to the new contract and extended with cases for explicit
  cancellation, explicit failure with benign output, and failure
  precedence under the legacy inference.

Verified locally for the first time in this work stream: building the
`@librechat/client` and `data-provider` workspaces made the client suite
runnable here. 15/15 in the image-gen spec, 851/851 across
`Content` and `hooks/SSE`.

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

* 🧹 style: Drop Narrating Comment From Cancellation Test

The test name and the `data-cancelled` / `data-error` expectations state
the contract on their own; the JSDoc above them only restated it.

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

* ⚖️ fix: Explicit Cancellation Outranks Parsed Errors; Force Progress Synchronously

Codex round 5, both findings consequences of round 3's fixes.

- Failure precedence was applied unconditionally, so a step explicitly
  closed as `cancelled` whose output happens to be error-formatted —
  aborting a tool can itself produce one — reported failure despite an
  authoritative status saying otherwise. Precedence is now scoped to the
  legacy inference, which is the only path that folds `hasError` into
  its own cancellation signal. Explicit cancellation wins.

- Passing 1 into `useProgress` stops its interval but does not make the
  returned value 1 on that render: the hook settles through 0.99 and a
  200ms timeout. For a step closing while mounted, that window rendered
  a failed retrieval as "Searching files" and left a completed one
  shimmering. Both halves are needed — pass 1 in to stop the timer, mask
  the result so the terminal value is observable immediately — in
  `RetrievalCall`, `ToolCall`, `useToolCallState` and `OpenAIImageGen`.

Regression test added for the cancellation-outranks-error case.
16/16 in the image-gen spec, 851/851 across Content and hooks/SSE.

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

* 🛑 fix: Explicit Subagent Cancellation Outranks Child Error Phase

Codex round 6.

- `SubagentCall` checked `hasError` before cancellation, so a subagent
  explicitly closed as `cancelled` whose last live envelope carried the
  `error` phase — which aborting a child can produce — rendered "Agent
  errored" instead of cancelled. Scoped the same way as the image path:
  the live error phase is suppressed when the authoritative close says
  cancelled.

- Removed a narrating comment in `WebSearch`; `isClosed`,
  `effectiveProgress`, `finalizing` and `complete` name the flow.

852/852 across Content and hooks/SSE.

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

* 🧽 style: Sweep The Narrating Failure Comments

Codex flagged two; the same one-line paraphrase of `runStepStatus ===
'failed'` had been copied into five files, so all five are removed
rather than leaving three to surface next round. The named `hasError` /
`errorState` / `error` booleans carry it.

The remaining comments in these files explain non-obvious behavior
rather than restating code — why `useProgress` needs both the terminal
argument and the mask, why an errored web search renders as nothing, and
which precedence applies to the legacy inference versus an explicit
close.

852/852 across Content and hooks/SSE. Typecheck baseline is now 1 error
(`useRum.ts`, unrelated) rather than 487, since building the
`@librechat/client` workspace resolved the module errors that were
masking it — identical with this diff and at the branch point.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 08:50:20 -04:00
Danny Avila
a23ab9d16e
💄 style: Align the Overflow Menu's Icons and Ease the Assistant Header (#14888)
`DropdownPopup` already wraps every item icon in `mr-2 size-4`, but the
header menu's icons carried `icon-md mr-2` of their own — so the margin was
applied twice and an 18px icon sat in a 16px box, leaving the column ragged
between items whose icons happened to render at different intrinsic sizes.
The icons now match the box they are given and let the wrapper own spacing.

Separately, the assistant name sat directly on the first line of its own
response. A small bottom margin separates the two without opening a gap.
2026-08-16 08:30:35 -04:00
Danny Avila
e7fa54dacf
📱 feat: Give the Mobile Nav the Whole Screen (#14849)
* 📱 feat: Give the Mobile Nav the Whole Screen

The drawer was `min(85vw, 380px)` with the 52px icon rail inside it, so the
conversation list got well under half the screen while ten unlabelled
glyphs held a permanent column.

The drawer now takes the viewport. Neither side needs a width literal any
more: the panel is `fixed`, so `w-full` is the initial containing block,
and the chat pane's `translateX(100%)` is self-referential and survives
rotation. The shared transition moves to a constant — the two elements must
stay frame-locked or the seam shows mid-animation.

Drop the rail on mobile by not rendering `ExpandedPanel` rather than
branching inside it, so desktop keeps an untouched file. Its four jobs move
to a drawer header (panel switcher, account, close) and a bottom bar.

The switcher doubles as the drawer title, answering "where am I" and "take
me elsewhere" with one control, and lists panels as labelled rows.

Search and new chat were both in the top corner — the two most frequent
actions in the hardest place to reach one-handed. They move to a bottom bar
built as a flex footer, not an overlay, so the virtualized list shrinks
around it and can never be occluded.

The backdrop is gone: at full width it can never be tapped, and `Root`
already marks the covered pane `inert`. That makes the header's close
button the primary dismissal, so it keeps `CLOSE_SIDEBAR_ID`, which
`OpenSidebar` focuses after opening.

Reset the drawer closed once per mobile mount. `sidebarExpanded` persists,
and at full width a stale open state would launch into the nav rather than
the conversation.

Conversation rows revealed their overflow menu on hover, which touch does
not have, leaving it reachable only on the active row. Touch now gets a
cheap always-visible trigger that mounts the real menu already open, rather
than mounting six mutations per overscanned row.

* 🩹 fix: Address Codex Findings on the Full-Width Mobile Nav

The panel switcher was unreachable. `DropdownPopup` portals to
`document.body`, where `usePopoverZIndex()` hands it 50 outside a dialog —
behind the opaque full-screen drawer at 110 — so none of its destinations
could be selected. Render it inside the drawer instead; nothing between the
trigger and the drawer root clips overflow. The drawer's z-index moves to a
named constant carrying that reasoning.

Panel keyboard shortcuts stopped working on mobile. They locate a panel by
its rail button, read `aria-pressed`, then click it, and the rail no longer
exists — so Agents, Prompts, Memories and the rest silently no-opped on a
narrow window or a tablet with a keyboard. Hidden persistent targets keep
that contract without reviving the rail. Routing the shortcuts through
`useActivePanel` instead would mean hoisting `ActivePanelProvider` above
`SidebarChatProvider`, which exists to keep panel changes from re-running
`useChatHelpers`. Only available links render, so a shortcut for a panel
this endpoint lacks still correctly does nothing.

The persisted-drawer reset ran after the first paint, so a reload with the
drawer open showed the nav covering the app and then animated it shut —
the exact state it was meant to prevent. `atomWithLocalStorage` already
accepts a normalizer, so the value is corrected during atom initialization
and the closed state reaches the first paint. Drops the effect entirely.

Note the normalizer also rewrites the stored value, so opening the drawer
on a phone leaves that browser's desktop sidebar collapsed until toggled.

* 🩹 fix: Address the Second Codex Round on the Full-Width Nav

The conversation row's overflow menu was unreachable on mobile for the same
reason the panel switcher was: `ConvoOptions` portals to `document.body`,
where `usePopoverZIndex()` gives it 50, behind the drawer at 110. It now
portals only off mobile — on desktop the sidebar is in normal flow, so
portaling still buys escape from the list's clipping.

The touch trigger also lost its own first tap. Touch browsers focus a
button mid-tap, and the row's `onFocus` sets `hasInteracted`, which swaps
the trigger for `ConvoOptions` before the click can land. Moving to
`pointerdown` runs the handler before the swap.

Crossing into the mobile breakpoint left the drawer open. The persisted
value is normalized when the atom initializes, which covers loading on a
phone, but narrowing a window or rotating a tablet has no such moment and
an expanded desktop sidebar became a drawer covering the app. Collapse on
the transition specifically, so the initial mobile paint still comes from
the normalizer rather than an effect.

The new spec pins the tap contract: it fires only `pointerdown`, so a
click-based handler fails it.

* 🩹 fix: Address the Third Codex Round on the Full-Width Nav

The touch options trigger handled only `pointerdown`, so assistive tech,
voice control and keyboard activation — which dispatch `click` with no
preceding pointer event — did not reach it, and the click bubbled to the
row and navigated away instead. It now handles pointer, click and Enter or
Space through one handler. The two paths cannot double-fire, since
`pointerdown` removes the button before a click could follow.

The breakpoint reset still animated. Correcting it in an effect meant the
first render after crossing into mobile painted the drawer open with the
conversation translated fully offscreen, then moved both back over 300ms.
The closed state is now derived during the transition render itself, and
the effect only commits it.

That derivation has to be shared: `UnifiedSidebar` draws the drawer while
`Root` translates the pane, and both read the atom independently, so either
one deciding alone would disagree with the other for that frame. Both now
read through `useSidebarState`.

* 🩹 fix: Restore Portaling and Scope the Drawer's Close Identity

Revert the mobile menus to `portal={true}`. The premise behind rendering
them in place was wrong: the drawer's z-index only ranks it inside `Root`'s
`relative z-0` stacking context, so it cannot occlude a popup portaled to
`document.body` regardless of the values involved. `ConvoOptions` has
always portaled from inside this drawer and has always worked.

Rendering in place cost real breakage: the row sits under the nav's
`overflow-hidden` and a virtualized list, and the drawer's transform makes
it the containing block for fixed descendants, so menus near a list edge
were clipped and their rename, archive and delete actions unreachable.

Scope the drawer's close button to the open state. It stays mounted while
closed so the drawer can slide, and a translated element still counts as
visible, so anything probing for `close-sidebar-button` found a control
sitting off-viewport — which is what stalled the mobile visual specs. The
rail this replaced only published that id while expanded; match it, and
keep the closed drawer out of the tab order.

* 🩹 fix: Let an Ordinary Click Open the Conversation Menu

The trigger committed on `pointerdown`, so beginning a vertical scroll on
an ellipsis opened that conversation's menu before the browser could tell a
tap from a swipe.

That handler only existed to beat a race of our own making: `hasInteracted`
is hover- and focus-driven, which is meaningful on a pointer device but not
on touch, where focus lands mid-tap — swapping the trigger for
`ConvoOptions` while the finger was still down. Key the swap to the menu's
own state on touch and the race disappears, so a plain click suffices. The
browser already withholds a click until a press resolves as a tap, and
synthesises one for keyboard and assistive-technology activation, which the
`pointerdown` path had to special-case separately.

Also correct the drawer z-index comment, which described the opposite of
the layering the code settled on and would have led the next caller back
into the clipping bug, and restore `aria-keyshortcuts` on the new-chat
button so its binding stays discoverable.

* 🩹 fix: Let Escape Leave the Menu Before the Drawer

Menus opened from the drawer portal out of it, so their Escape still
reached the drawer's document listener and collapsed the whole thing rather
than the level the user meant to leave. Those menus unmount when closed, so
their presence in the document is the signal to stand down.

Also restore the toggle binding on the close control. It is the only close
affordance while the drawer is open — the header's `OpenSidebar` is inside
the inert, translated chat pane — so assistive technology had no way to
discover the shortcut from there.

* 🩹 fix: Only Treat an Open Menu as Reason to Keep the Drawer

The Escape guard matched any `[role="menu"]` in the document, but not every
menu unmounts when closed — the account menu stays mounted and merely
`hidden`. Once it had lazily loaded, a closed menu would have suppressed
Escape for the drawer permanently. Match only menus that are actually open.

*  test: Pin the Ariakit Closed-Menu Contract

The drawer's Escape guard stands down only for menus that are actually
open, which depends on Ariakit keeping a closed menu mounted and marking it
`hidden` rather than unmounting it — the account menu behaves this way and
would otherwise suppress Escape for the drawer permanently.

Exercised against the real library rather than a mock, so a change in that
behaviour fails here and points at the guard.

* 🩹 fix: Keep the Row's Menu Mounted Once It Has Been Opened

Keying the swap to `isPopoverActive` meant dismissing the menu unmounted
`ConvoOptions` immediately, destroying Ariakit's own button — its
final-focus target — mid-close. The lightweight trigger that took its place
is a different node and never received focus, so a keyboard or
assistive-technology user was dropped to the document instead of returning
to the control they opened.

Once a row's menu has been opened, keep the real one. Rows the user never
touched still mount nothing, which was the reason for the trigger.

* 🩹 fix: Complete the Retained-Menu Path for Touch Rows

Three gaps in the retained-menu approach, all reachable.

`hasOpenedMenu` was only set by the touch trigger, but the active row
already renders the real menu and never passes through it. A row opened
while active and later demoted would swap its focused button for a new node
and drop focus — the same defect the retention was added to prevent.
Recorded on every opening instead.

The retained button then stayed invisible: `ConvoOptions` reveals its
trigger on hover or focus when the row is neither active nor open, and
touch has neither, so an interacted row was left with an invisible hit
target. Kept visible on small screens.

The touch trigger also restated the shared control's sizing, rounding and
text treatment by hand, losing the focus ring, transitions and disabled
handling that come with it. Composed from `Button` with only the local
sizing retained.

* ♻️ refactor: Give the Row's Overflow Control One Owner

Five review rounds in this file each fixed something the previous fix
introduced — trigger swap, activation path, scroll-versus-tap, focus
return, retention completeness. The cause was structural rather than any
one mistake: two controls can represent a row's menu, `ConvoOptions` and
the cheap placeholder that stands in for it, and the rules they must agree
on were spread across four separate expressions and a button, so each
repair taught one of them something the other never learned.

`ConvoActions` now settles them together — which control renders, when the
real one becomes permanent, how it stays visible without hover, and how
activation is claimed — with the reasoning for each recorded where the
decision is made, including why a plain click is the right event and what
breaks if a press is claimed earlier.

Behaviour is unchanged; this is the same set of rules in one place.
`Convo` keeps the open state, which it needs to suppress row navigation,
and now passes a single `onOpenChange` rather than driving the swap itself.

* 🩹 fix: Reveal the Real Menu Trigger on Touch and Recheck the Drawer Default

The conversation menu has two triggers — the shift-held variant and the
Ariakit button used the rest of the time — and only the first was taught
to stay visible without hover. The second restated the same class string by
hand instead of sharing it, so the earlier fix silently missed the trigger
that actually matters. It now composes the shared string, which is why the
two could disagree at all.

Separately, the sidebar default is captured when the store module is
evaluated, and `atomWithLocalStorage` only ran its normalizer when a saved
value existed. A first visit that loaded wide and narrowed before the app
mounted — a login screen being resized — therefore kept `true` with nothing
to correct it, and `useMediaQuery` now resolving on the first render means
the breakpoint guard sees no transition either. Normalize the default at
initialization as well; callers without a normalizer get the identity
function, so nothing else changes.

*  test: Cover the Normalized Default in `atomWithLocalStorage`

Normalizing the default reaches every atom built with the helper, so the
cases worth pinning are the ones where a normalizer exists and could move
an untouched default: no normalizer, one that accepts the default — the
shape the speech-engine atoms have — one that rejects it, and a persisted
value, which must still be normalized as before.

* 🩹 fix: Carry the Search Text Across a Breakpoint Change

Moving search into the drawer's bottom bar left it mounted in two places —
the list on a pointer device, the bottom bar on touch — so crossing the
breakpoint mid-search destroys one instance and builds another. The field
seeded its text to an empty string and never read the stored query, so the
results stayed filtered by a term the box no longer showed, with no clear
affordance to undo it.

Seeded from the query instead, along with the clear button's state.

* 💄 style: Settle the Drawer's Panel Switcher and Bookmark Filter

The switcher's chevron trailed the panel name instead of sitting on the
edge, so the control read as text with an arrow stuck to it rather than a
menu spanning the header. The label now takes the slack.

Moving search to the bottom bar also left the bookmark filter alone on a
row of its own above the list, with nothing to sit beside. It moves next to
the Chats heading, matching the Projects heading that already keeps its
actions there, and the row disappears on mobile rather than lingering with
one icon in it.

`ChatsHeader` gains a trailing slot for that, so section actions have a
home instead of a floating row.

* 💄 style: Match the Bookmark Filter to the Section Actions

The bookmark control was built for the row it used to share with the search
field — 36px, `rounded-lg`, a larger icon — so beside a section heading it
read as a different kind of control to the Projects actions sitting one row
above it.

Both now draw from one recipe, at every width rather than only where the
move exposed it, so the two headings cannot drift apart in size, radius or
hover treatment.

* 🩹 fix: Cancel the Search Debounce the Field Leaves Behind

The debounced commit writes to shared search state, so a pending timer
outlives the instance that scheduled it. Mounting the field in two places
made that reachable: crossing the breakpoint mid-keystroke destroys the
list's field and builds the bottom bar's, and the departing timer would
then reinstate a query the replacement had already edited or cleared.
Clearing the field had the same hole within a single instance.

Cancelling needs a debounce that is stable for the field's lifetime. A
memo rebuilt on dependency changes leaves the previous instance's timer
running past the cancel meant to stop it, and cancelling on that rebuild
discards live keystrokes instead — so the handlers are read through a ref
and the debounce is built once.

Renames the spec, since hydrating the arriving instance and silencing the
departing one are two halves of the same remount.

* 🩹 fix: Hand the Uncommitted Query to the Arriving Search Field

Cancelling the departing field's debounce stopped it overwriting a query
the replacement had edited, but it also stranded the simpler case: a user
who crosses the breakpoint and then just stops typing. The commit that
would have published their query died with the instance that scheduled
it, so the arriving field showed text the list was not filtered by and
`isTyping` was never cleared — the loading state has no other way out
while `debouncedQuery` and `query` disagree.

The arriving field now takes the handoff, scheduling the commit itself
when it mounts with an uncommitted query. Reading that at first render
keeps it to the moment of the swap, so a real edit still wins.

* 💄 style: Give Section Actions a Home in the Button Recipe

The two sidebar headings shared their icon-button appearance through a
feature-local class string, which is the shallow wrapper the styling
rules warn about: sizing, radius, hover and focus ring are reusable
appearance decisions, so they belong to the shared primitive where future
theme and accessibility work will reach them. `sectionAction` and an
`iconSm` size carry that recipe now, and the call sites keep only their
layout.

The drawer's panel switcher gets the same treatment for a sharper reason
than consistency: its hand-written class string had no focus-visible
state at all, so keyboard focus on the drawer's primary navigation
control was invisible. Composing the shared ghost recipe restores the
ring and transition, leaving only the row-filling layout local.

`buttonVariants` returns unmerged recipe output, so every call site wraps
it in `cn` — a spec pins that, since forgetting it silently reinstates
whichever base utility the variant meant to override.

* 🩹 fix: Publish the Search Field's Pending Query When It Leaves

Cancelling on unmount assumed a replacement field would always arrive to
inherit the query, so the fix grew a second mechanism to hand it over. The
bottom bar disproves the assumption: switching panels drops the search
entirely, leaving `query` set, `debouncedQuery` stale and `isTyping` on
with nothing left to clear it.

Flushing replaces both mechanisms. It publishes the pending commit rather
than discarding it, so a field that leaves without a successor still
settles the state it changed. And because a flush is synchronous with the
unmount, it lands before any edit the replacement makes — which is what
the cancel was for, so nothing is given up.

Also normalizes the default on the parse-error path in
`atomWithLocalStorage`: unparseable storage falls back to the same
module-time default as a missing key, and only the missing-key path was
re-checking it against the current viewport.
2026-08-16 08:29:25 -04:00
Marco Beretta
edc6cf5936
🩹 fix: Stop Archived and Shared Chats Dialogs Crashing on Open (#14886)
* fix: stop the virtualized data table looping on render

Opening Archived chats or Shared chats with 50 or more rows threw "Too many
re-renders". DataTable passed an inline getItemKey to useVirtualizer, and
virtual-core lists that option among the deps of its getMeasurementOptions memo,
whose onChange notifies. getVirtualItems() is read during render, so every
render built a new closure, notified, and dispatched a render-phase update on
the component that was still rendering, until React gave up at 25 passes. It
only fired past the 50-row virtualization threshold, which is why both dialogs
looked fine while empty.

Memoize getItemKey and estimateSize so their identity tracks their inputs.

DataTable.spec had mocked @tanstack/react-virtual away, attributing the same
error to jsdom, which hid this from CI. Keep that mock, since its row
assertions need every row rendered, and add a spec that drives the real
virtualizer and fails without the fix.

Also restyle both dialogs, which is what made them look unfinished:

- add the 19 keys these components pull from @librechat/client but the app
  locale never defined, so the empty state rendered com_ui_no_data verbatim
- rename Shared links to Shared chats, matching the sibling Archived chats
- transparent table with a rounded hover highlight painted on the cells, since
  border-radius does not apply to a table row, which needs separated borders
- row height 56 to 40, dividers dropped, skeletons follow the same height
- row hover uses surface-secondary-alt: plain surface-secondary is 247 against a
  255 dialog in light mode and reads as nothing
- row action buttons use surface-hover-alt, because surface-hover is also 227 in
  light and would vanish against the row highlight
- drop the focus ring from the dialog containers and stop Shared chats seating
  focus in its search field, so neither flashes an outline on open
- narrow both dialogs and let the table height follow its content

* Fix compact row actions and selection count

* fix: update selected count translation test to match interpolated output
2026-08-16 06:02:44 +02:00
Marco Beretta
0a2f59ab86
📋 fix: Stop the Copy Button From Clobbering the Clipboard (#14876)
* fix: keep the copy button from clobbering the clipboard

Join only the non-empty TEXT parts instead of deciding the separator from
the raw content index, so a tool call or error after the last visible text
part can no longer append a stray trailing newline. Pasting that into a
terminal submitted the command on its own.

Skip the copy entirely when a message serializes to nothing, such as an
error-only response, rather than overwriting the clipboard with an empty
string and flipping the button to its copied state.

* fix: stop copy controls from reporting a copy that never happened

The copy hook now reports whether it wrote to the clipboard. The shared
link announcement and the redirect URI and resource URL toasts fire on
that result instead of on every click, so a click that copies nothing no
longer tells the user, or a screen reader, that the value was copied.

Give CopyButton a disabled prop and use it for the shared link and the
MCP redirect URI, whose values are empty for a beat while the share id
and the created server id resolve, so the control is not a dead target.

* fix: honor clipboard write failures and disable copy with nothing to copy

copy-to-clipboard reports whether the write landed. Return that instead of
assuming success, so a browser that refuses the fallback no longer produces
a copied checkmark, an announcement, or a toast.

Export hasCopyableText and use it to disable the copy action in HoverButtons
and MinimalHoverButtons. An error-only or tool-only response rendered an
enabled control that could not do anything, which keyboard users had no way
to tell apart from a working one.

Reveal's copy test relied on the old always-succeed path, since jsdom has no
execCommand for the real module to call. It now mocks the write and covers
both outcomes.

* fix: derive copy availability from the text the hook would copy

hasCopyableText checked raw text parts while the hook copies the cleaned
value, so a part holding only citation markup counted as copyable and then
declined the copy, leaving the enabled-but-dead control this was meant to
remove. Both now run buildClipboardText, so the predicate cannot drift from
the guard.

That needs search results to stay accurate, which HoverButtons does not have,
so copyability is decided in useMessageActions and useMessageHelpers next to
the hook call that owns those inputs, and passed down.

* perf: stop scanning a response that is still streaming for copyability

The copyability memo invalidated on every streamed chunk and rebuilt the
clipboard text from the whole accumulated response, so a long answer paid
quadratic cumulative work for a copy button that stays hidden until the
stream ends.

The hooks now hand down a getter and HoverButtons short-circuits it while
isActiveStreamingMessage is true, so nothing is scanned before the control
can be used, and the result is still memoized once the response settles.

* style: drop the narrating comment on the copyability gate

* chore: sort client imports
2026-08-16 02:36:58 +02:00
Marco Beretta
b2016898a5
🎙️ fix: Prefer Compatible Formats for External STT Recording (#14864)
* feat(stt): Prefer ogg over mp4 for openai compatible providers and firefox

* test(stt): cover recording MIME priority

---------

Co-authored-by: Pascal Garber <pascal@artandcode.studio>
2026-08-15 21:35:55 +02:00
Anubhav Anand
d513e55a62
📥 feat: Download Code Blocks as Files (#13715)
*  feat: Add Download button for code blocks

Adds a Download button next to Copy code in both the code block header
bar and the floating bar, saving the block content as a file whose
extension is inferred from the fenced-block language hint.

- getCodeBlockFilename() maps language names to extensions
  (python → code.py), passes extension-like hints through unchanged
  (tsx → code.tsx), and falls back to code.txt; reuses the existing
  triggerDownload() util for the blob download and URL revocation.
- useDownloadCode mirrors useCopyCode (focus restore, 3s reset timer).
- DownloadButton mirrors CopyButton (icon animation, tooltip in
  icon-only mode, aria-label reflecting current state).

Fixes #13470

* Address review: alias extensions + hide floating download on error

- Map alphanumeric language aliases (python3, nodejs, node, golang) to
  their real extension so they download as code.py/.js/.go instead of
  code.python3 etc. — the bare-hint fallback previously passed them
  through verbatim.
- Gate the floating-bar Download button behind error !== true, matching
  CodeBar, so error blocks (e.g. token-balance JSON) don't expose a
  download action once the header scrolls out of view.

* style: show only Run Code with a label in the code block header

Download and Copy code now render as icon-only buttons with tooltips, so
the header reads as one labelled action plus two secondary icons. Below
the md breakpoint Run Code drops its label too, leaving all three as
icons on mobile.

* refactor: extract the shared code bar action button

CopyButton and DownloadButton were identical apart from the icon and
their default labels. Both now wrap a single ActionButton that owns the
class recipe, the icon swap, the label swap and the icon-only tooltip,
so the two cannot drift apart as the bar evolves. The public props of
both components are unchanged.

* fix: keep the Run Code tooltip when its label is hidden

Below the md breakpoint Run Code renders as a bare terminal icon, so it
needs the same hover description Download and Copy already carry. The
button is now always wrapped in the tooltip anchor rather than only in
the explicit iconOnly mode.

* docs: drop the em dash from the extension map comment

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2026-08-15 21:09:27 +02:00
Danny Avila
1d789c41a5
🧩 fix: Normalize MCP UI Resource Rendering (#14868)
* fix: normalize MCP UI resource rendering

* fix: filter unsupported MCP UI resources

* fix: preserve MCP UI marker examples

* fix: handle MCP UI resource edge cases

* fix: harden MCP UI marker sanitization

* fix: scope MCP UI marker sanitization

* fix: parse MCP UI marker contexts

* fix: align MCP UI sanitizer parsing

* fix: match MCP UI renderer syntax

* fix: align blockquote marker spans

* fix: decode MCP UI text node sources

* fix: sanitize nested subagent markers

* fix: bound MCP UI sanitizer traversal

* fix: keep MCP UI marker mapping linear

* style: sort security patch imports

* fix: harden nested MCP UI sanitization

* fix: mirror citation cleanup for MCP UI markers

* fix: clean decoded citation markers

* fix: clean assembled citation markers

* fix: align MCP marker sanitization with rendering

* fix: match persisted MCP marker render paths

* fix: preserve highlighted citation boundaries

* fix: align MCP markers across content renderers

* fix: preserve citation renderer boundaries

* fix: match legacy thinking trim semantics
2026-08-15 12:49:59 -04:00
Danny Avila
eb3b353712
📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision (#14858)
* 📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision

Shared MCP servers advertised no tools to agents, so every turn failed with
"configured to use MCP tools, but none are available" (#14857).

`replaceAppServerTools` returned false whenever a publication carried no
`publicationRevision`, but only `refreshChangedTools` reserves one. Every other
app-level publisher — the first-connect snapshot, reinitialization, on-demand
catalog reads, the retained-catalog restore — was silently dropped. The agent
path fails closed on that drop: the skipped write returns null, so reinitialize
yields no tools and the turn 503s.

Startup hid it. `connectAppServers()` defers the initial refresh and calls
`refreshToolList()` itself, which does reserve, so a boot that reaches its MCP
servers looks healthy. Only a lazily created app connection — the server not yet
up when LibreChat boots, a dropped connection, a cold cache — takes the
unreserved path.

`ConnectionsRepository` now reserves before its own `tools/list`, matching the
list_changed path; a failed reservation publishes unordered rather than failing
the connection. Publishers with no pre-fetch reservation point have already
fetched by the time they reach the cache, so they take the next revision at write
time instead of being discarded. `mergeAppTools` still publishes at revision 0 and
stays deferential to a live catalog.

* 📡 fix: Bind App Catalog Ordering to the Fetch That Produced It

Addresses review feedback on the previous commit: allocating a revision at
publish time lets a slow `tools/list` of an old catalog outrank a newer one that
reserved after it started, and it would let the retained-catalog restore — which
republishes deliberately pre-mutation data — outrank a live catalog.

Ordering now travels with the data. `fetchToolsSnapshot` reserves before its
first page and returns the ticket on the snapshot, so every app-level publisher
reads the revision belonging to the read it is publishing rather than one
allocated at an unrelated moment. `fetchOrderedToolsSnapshot` carries the
refresh's revision when it defers to one, since that is whose catalog it returns.

With the reservation at the single point where app-level tools are read, no
publisher can forget it, so `replaceAppServerTools` goes back to refusing an
unordered write: a publication that lost its ticket fetched at an unknown time
and cannot be ordered.

A failed reservation is reported as `orderingUnavailable` rather than swallowed,
which keeps the list_changed path retrying instead of publishing a catalog that
would be silently dropped, and leaves inspection unaffected by a transient cache
outage.

`MCPServerInspector.getToolFunctions` becomes `getToolCatalog` and returns the
revision with the tools, so there is no variant that quietly discards ordering.

* 📡 fix: Retry an Empty App Catalog That Could Not Reserve Ordering

Review follow-up. The no-tools-capability branch destructured the reservation
result and dropped `orderingUnavailable`, publishing without a revision when the
revision store was transiently unavailable. That write is rejected in silence,
and unlike the snapshot branch this one returned without reaching
`refreshToolList()`, so whatever the server last advertised stayed in place until
the connection was recreated or the cache expired.

Both branches now route an unreservable catalog through the same retry path.

* 📡 fix: Serve Tools Whose Shared Catalog Write Could Not Be Ordered

Review follow-up. Only the shared catalog write needs ordering; the tools
themselves were just read from the server and are correct to serve. Discarding
them because the write could not be ordered is what turns a cache failure into a
server that appears to have no tools at all, which is the reported symptom.

`updateMCPServerTools` now returns the tools it built when the publication has no
reserved revision, instead of null. A superseded write still discards — there
another replica holds something newer.

Reinitialization also asks the connection to republish under backoff when its
snapshot could not reserve ordering, so the shared catalog does not stay cold
until something else triggers a refresh.

* 📡 fix: Surface a Discarded App Catalog Instead of Debug-Logging It

#14857 went a release without a diagnostic because the only trace of a dropped
app-level catalog was a debug line no deployment runs. Operators saw agents fail
every turn with nothing in the logs to explain it, and the reporter had to read
the source to find the cause.

A publication discarded because it cannot be addressed or ordered means this
server's tools are unavailable to every agent that selected them, and serving an
unpublished catalog means every request re-fetches it. Both are warnings now. A
superseded write stays at debug: concurrent replicas produce it routinely and the
winner already holds newer tools.

Tests pin the level, so a later refactor cannot quietly make the failure silent
again.

* 🧪 test: Pin the Reinitialize Path's Catalog Ordering

Reinitialization is the path an agent falls back to when the shared catalog is
cold, so it is where #14857 surfaced as "configured to use MCP tools, but none
are available". Nothing pinned that it forwards the ordering its snapshot was
fetched with, nor that it asks the connection to republish a catalog it could
not order.

Both assertions fail against the pre-fix source.
2026-08-15 12:48:23 -04:00
Anubhav Anand
a2ad0aa0c8
🤐 feat: Allow Promptless Sends When Files Are Attached (#13717)
*  feat: Allow sending file attachments without a text message

When an agent asks the user to upload a document, the user could attach
the file but still had to type a placeholder message ("OK", "Here is
the file") before the send button enabled and the submit guard let the
message through.

Attachments now count as submittable content:

- New isSubmittableMessage(text, fileCount) util: non-whitespace text
  OR at least one attached file.
- ask() in useChatFunctions uses it instead of bailing on empty text,
  so an empty draft with attached files submits.
- SendButton receives the attached file count and enables accordingly.
- ChatForm only marks the text field as required when no files are
  attached, so react-hook-form validation no longer blocks handleSubmit.

Submitting an empty draft with no attachments is still rejected at all
three layers.

Fixes #13646

* Address review: support replayed file-only turns + drop empty vision text

- ask(): count replayed attachments (overrideFiles) in the submittable
  check and skip it entirely for regenerate, so a file-only message can
  be regenerated or saved-and-resubmitted instead of being rejected as
  empty.
- formatVisionMessage(): omit the text content part when the message text
  is empty. Anthropic rejects empty text content blocks with HTTP 400,
  and an empty block adds nothing for other providers; image-only sends
  now format cleanly. Added formatMessages tests for with-text and
  image-only (Anthropic + other) cases.

* Address review: keep attachment-only turns valid for providers, answer mode, and titles

- formatMessage: substitute minimal text when a user turn carries files but no
  inline content, so Anthropic does not reject an empty user message for RAG or
  code-environment attachments.
- assistants chatV1: send the same stand-in for attachment-only Threads
  messages, which reject an empty body. The persisted message keeps empty text.
- ChatForm: attachments no longer make an empty draft submittable in answer
  mode, where submitText consumes the click without answering or sending.
- agents request: seed title generation from attachment filenames when the turn
  has no text, so immediate-mode titles are not invented from an empty string.
- useChatFunctions.regenerate.spec: mock the utils barrel over the real module
  so new exports resolve.

* Cover the agents path for attachment-only turns

AgentClient formats its payload with the SDK's formatMessage, not the local
one, so the earlier guard missed the endpoint the feature actually targets: an
attachment-only turn still reached Anthropic as an empty user message. Apply
the same stand-in after the file-context and quote merges, so a turn that
already gained inline content is untouched.

* Carry filenames on freshly attached files

The fresh-file submission mapping copied only file_id, filepath, type, and
dimensions, so the attachment-only title fallback read an undefined filename
and produced nothing. Include filename, and cover it with a test that submits
an empty draft with one attachment.

* Address review: cover assistants v2, fresh agent attachments, editor, and title fallback

- agents client: the current turn has no files during buildMessages, so read
  the resolved attachments from message_file_map instead. The previous guard
  only ever fired for persisted historical turns.
- assistants chatV2: the default assistants endpoint routes here, so it needs
  the same stand-in body chatV1 got.
- assistants title: fall back to filenames, then the response, and keep the
  default title rather than saving an empty one.
- EditMessage: retained attachments make an empty edit submittable, matching
  the composer, so the overrideFiles replay path is reachable from the UI.

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2026-08-15 12:47:31 -04:00
Danny Avila
e1ac7d2bda
ci: Settle the E2E Reply Before the Double-Click Quote (#14840)
* test(e2e): grant MULTI_CONVO.USE in the mock e2e config

`agent-skills-added.spec.ts` drives the composer's `+` command, which opens the
added-model popover. That path is gated on MULTI_CONVO.USE:

    if (!hasMultiConvoAccess || !plusCommandEnabled || isAssistantsEndpoint(endpoint)) return;

The mock config never sets `interface.multiConvo`, so the permission falls
through to the seeded role default and `handlePlusCommand` returns before
opening the popover. The spec then fails on a popover that is absent from the
DOM entirely, which reads as a selector or timing problem rather than a missing
permission.

Set it explicitly, the same way `contextCost` is set just above for the usage
gauge — the mock config's job is to make each exercised feature's gate explicit
rather than inherit a default.

* test(e2e): wait for the reply to settle before the double-click quote

`quotes.spec.ts` › 'summons the popup from a native double-click word
selection' double-clicks a word as soon as `mockReply` becomes visible. But
`sendMessage` resolves on the stream *response*, not on the final render, so the
reply can still be re-rendering.

A streaming markdown re-render swaps out the text node the selection points at,
which collapses the selection — the same mechanism the sibling
`selectionchange` test documents deliberately. A double-click landing mid-stream
therefore loses its selection before the popup can be clicked, and because the
whole gesture is wrapped in `toPass`, every retry re-runs into the same
still-streaming reply rather than recovering from a one-off.

This is a different race from the one #14777 fixed. That one is the *selection*
still settling (touch long-press, native handle drags, block-granularity
gestures) and is handled inside QuoteButton. This one is the *reply* still
streaming, which no amount of component-side settling can absorb.

Observed on a downstream fork running this suite on slower hardware: the test
fails all three attempts, deterministically on the in-memory stream store while
the Redis lane passes the same shard — the in-memory store's final re-renders
land late enough to outlive the gesture. Four separate runs, same split.

Wait for the reply text to hold steady before selecting.

* ci(e2e): install ffmpeg so first-retry video actually records (#14841)

`playwright.config.mock.ts` sets `video: 'on-first-retry'`, but the runner only
installs `install-deps chrome`, which does not include ffmpeg. Without it the
first retry fails inside `browserContext.newPage` while setting up video
recording — before the test body runs.

The cost is the retry itself: a genuinely flaky test loses the attempt that
would have recovered it, and the reported failure is a video-setup error rather
than the original symptom.

Bounded and non-fatal on purpose. The CLI has been observed hanging after the
download completes on these runners, so the step is wrapped in `timeout` and
its failure is swallowed — if ffmpeg cannot be installed the job proceeds
exactly as it does today, and no lane is blocked on it.

Applied to both jobs that run Playwright (`e2e_shards` and
`mcp_tool_list_changed`), since both configure retries.
2026-08-15 10:48:11 -04:00
Danny Avila
fe71ffdf42
🛂 ci: Grant Multi-Convo Permission in E2E Mock Config (#14839)
`agent-skills-added.spec.ts` drives the composer's `+` command, which opens the
added-model popover. That path is gated on MULTI_CONVO.USE:

    if (!hasMultiConvoAccess || !plusCommandEnabled || isAssistantsEndpoint(endpoint)) return;

The mock config never sets `interface.multiConvo`, so the permission falls
through to the seeded role default and `handlePlusCommand` returns before
opening the popover. The spec then fails on a popover that is absent from the
DOM entirely, which reads as a selector or timing problem rather than a missing
permission.

Set it explicitly, the same way `contextCost` is set just above for the usage
gauge — the mock config's job is to make each exercised feature's gate explicit
rather than inherit a default.
2026-08-15 10:47:35 -04:00
Danny Avila
88747f0ad8
🩺 fix: Render Stopped Run Steps From Explicit Status (#14871)
* 🩺 fix: Render Stopped Run Steps From Explicit Status

Tool calls decided "still running" vs "stopped" with a whole-message
heuristic:

  const cancelled = !isSubmitting && progress < 1 && !hasError;

That inference cannot tell which step actually stopped. An aborted step
keeps spinning while `isSubmitting` is still true, and when submitting
ends, every unfinished part flips to "Cancelled" at once regardless of
which one died.

`@librechat/agents` v3.4.6+ emits `on_run_step_closed`, a terminal
per-step signal carrying `status` and timestamps — including for steps
swept at end-of-run because the caller aborted. The pinned 3.5.1 already
ships it; nothing consumed it.

- `StepEvents.ON_RUN_STEP_CLOSED` plus `RunStepClosedEvent` /
  `RunStepStatus` types mirroring the SDK payload.
- `PartMetadata.runStepStatus` — a dedicated field, since `status` is
  already claimed by activity-label and question-form parts.
- Server handler forwards the event without the visibility gating the
  other step handlers apply: a step whose open reached the client must
  get its close, or the client is left inferring again.
- `useStepHandler` writes the terminal status onto the tool call part.
- Both decision points (`ToolCall`, the shared `useToolCallState`) prefer
  explicit status, keeping the heuristic as fallback for messages saved
  before this and endpoints that do not emit the event.

Threaded through the five cards sharing `useToolCallState`.

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

* 🩹 fix: Address Codex Review On Run Step Closure Rendering

- Persist the terminal status server-side. The handler emitted the
  closure without folding it into `contentParts`, so the status existed
  only on the live React message: a reload or resumable reconnect
  dropped it and fell back to the very heuristic this fixes. Now stamped
  onto the aggregated tool-call part (via `stepMap`, falling back to the
  event's own index) before forwarding.

- Honor terminal status independently of output parsing. Gating on
  `hasError` meant a `failed` step with unparseable output rendered as
  "cancelled", while a `failed`/`cancelled` step whose output did parse
  as an error was not terminal at all and shimmered indefinitely when no
  completion event arrived. A closed step now forces progress complete
  and reports `failed` as an error state on its own authority.

- Pass the status to the second `BashCall` branch, which rendered the
  same updated component without it.

- Reuse `Agents.RunStepClosedStatus` in `PartMetadata` instead of
  redeclaring the union, so a future SDK status cannot diverge between
  the event and the persisted part.

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

*  fix: Replay Closed Status On Redis Resume, Announce Failures

- Apply closure events during Redis reconstruction. The stamp added in
  the previous commit mutates only the originating process's in-memory
  `contentParts`; a resumable reconnect landing on another replica
  rebuilds from `RedisJobStore.getContentParts`, whose allowlist omits
  `on_run_step_closed`. The status was therefore absent from the sync
  snapshot and, being snapshot-covered, never redelivered as pending —
  so multi-replica resume fell back to the whole-message heuristic.
  Handled as a host-authored event alongside `on_steer_applied` and
  `on_activity_label`, since the SDK aggregator has no notion of it.

- Announce terminal failures in the live region. Forcing terminal
  progress for a closed step meant a `failed` tool reached the
  `aria-live` region through `getFinishedText()`, which only special-
  cased cancellation and otherwise announced "completed function" —
  telling screen-reader users the opposite of what the card showed. A
  regression introduced by the previous commit; error states now
  announce failure before any completion string.

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

* 🎯 fix: Resolve Closed Steps By ID, Never By Index

The steer and HITL offset wrappers clone and shift only `ON_RUN_STEP`
and `ON_AGENT_UPDATE`; every other event passes through untouched. A
stored `on_run_step_closed` therefore carries the SDK's unshifted index,
while the part it belongs to was rebuilt at the shifted one. Any run
containing a steer insertion or HITL resume would stamp the status onto
an earlier tool card, or none — leaving the real card on the fallback
heuristic while mislabeling a different one.

- Redis reconstruction builds a step ID -> index map from the replayed
  `on_run_step` payloads (which carry the shifted index) and resolves
  closures against it, mirroring what the live callback does via
  `stepMap`.

- The live handler drops its `?? data.index` fallback for the same
  reason. Skipping is the safe failure: a missing status degrades to the
  old heuristic, whereas a misplaced one actively mislabels the wrong
  card.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 10:14:16 -04:00
Danny Avila
cd4511038d
🚏 feat: Central Trace Destination Opt-Out for Langfuse (#14838)
* feat(langfuse): let callers opt out of the central trace destination

Adds `centralTraceExportEnabled` to the score-destination options and threads
it through `getScoreDestinations`, `getLangfuseTraceDestinationIds` and
`getLangfuseTraceMessageFields`.

Deployments that route traces per tenant may want a given turn's spans to reach
only the tenant destination — for example when central export is a per-tenant
setting rather than a deployment-wide one. Today the central project is included
whenever env credentials exist, with no way for a caller to decline it for a
single trace.

Defaults to `true` everywhere, so existing callers are unaffected: the option is
additive and every current call site resolves exactly as before. The three
public helpers gain an optional trailing parameter and nothing else.

While here, `getScoreDestinations` destructures its options with defaults
instead of repeating `options?.waitForCentralProjectId !== false` at both call
sites, which is what made adding a second flag awkward.

Verified: no new tsc errors (one pre-existing cacheFactory error is unchanged),
99 langfuse tests pass, ESLint and Prettier clean.

* fix(langfuse): keep the central opt-out intact when destinations resolve

Addresses two review findings on the new `centralTraceExportEnabled` option.
Both are cases where opting out of central export was silently discarded by
destination resolution, letting later feedback reach a project the trace never
went to.

1. Non-fanout deployments with no central env credentials still returned the
   configured connection, because only the central-credential branch was gated.
   `resolveLangfuseExportPlan` reports `disabled` for that shape — without a
   fanout route there is nowhere for a central-suppressed trace to go — so
   return no destinations and match it.

2. `getLangfuseTraceDestinationIds` returned `undefined` whenever any
   destination lacked an id, and `sendFeedbackScore` reads `undefined` as
   unrestricted. A tenant destination has no id when its optional `projectId`
   is unset, so a suppressed-central trace could resolve back to the central
   project at feedback time. Fail closed with an empty list, which stays
   restricted, instead.

Both paths now have regression tests, each verified to fail without the
corresponding change: the first returned 3 destination ids, the second returned
`undefined`.

* fix(langfuse): carry the central opt-out into the feedback path

`getLangfuseTraceDestinationIds` returns `undefined` when an eligible
destination has no stable id, which a tenant route hits whenever the
optional `langfuse.projectId` is unset. `sendFeedbackScore` read that as
"unrestricted" and re-resolved destinations with central export enabled,
so a suppressed-central trace still drew central feedback.

Persisting an empty list instead only traded the leak for a drop: the
destination filter rejects every id-less destination, discarding ratings
the tenant should receive. The id list restricts feedback to destinations
that survived reconfiguration; it cannot also encode deployment policy.

Thread `centralTraceExportEnabled` through `sendFeedbackScore` so the
policy is evaluated the same way at trace time and feedback time, and
restore `undefined` for unidentifiable destinations.
2026-08-15 08:41:18 -04:00
Marco Beretta
c4357fc9e3
📐 feat: Match the Message Column to the Composer (#14851)
* feat: Match the message column to the composer width

Give messages the same max-width and horizontal padding as ChatForm,
reserve the scrollbar gutter on the composer wrapper, and drop the
65ch prose cap so the body fills that column.

* style: Drop the assistant avatar gutter

Keep the icon and provider name on the same left edge as the message
body. Mid-message author headers and steer bubbles no longer outdent
past a column that no longer exists.

* style: Reveal the timestamp on the message header bar

Put the icon, provider name, and datetime on one full-width row, and
show the time only when the message is hovered or focused. CSS on
.message-render wins the hover hide that Tailwind group-hover lost.

* style: Align scroll-to-bottom with the chat column

Sit the control in the same padded column as the composer, swap the
hard-coded disc for a themed outline Button, and fade it in on an
8px rise instead of a scale pop.

* feat: Crossfade the provider name to the model on hover

Swap the assistant header label to the real model name when the
provider is hovered or focused. Skip agent_* document ids so the
hover text is only a model name.

* fix: Reserve the message column gutter without clipping the composer

`scrollbar-gutter: stable` only holds its band back while the element is a
scroll container, and a scroll container clips. Wrapping the composer in one
put the in-flight steer overlay outside the clip: it is painted above the
composer's top edge, so for the whole run a submitted steer was invisible and
its cancel unreachable. The scroll-to-bottom wrapper had the same problem in
a smaller way, cutting off the button's focus ring.

Reserve the same band with padding instead, sized by the width the app already
gives its own scrollbars, so both columns still line up with the messages
without either becoming a scroll container.

Also scopes the header label crossfade to the two labelled spans, and drops
its `:focus-within` rules, which no focusable descendant can ever trigger.

* fix: Keep document ids out of the header and name the model to screen readers

An Assistants-endpoint message keys the assistant map by `assistant.id`, so
its `model` field holds an `asst_` id, not a model name. The header label only
skipped `agent_`, so it crossfaded the assistant's name into an internal id.
Skip both prefixes, and offer `assistant.model` ahead of the message field in
the callers that already resolved the assistant.

The crossfade itself is pointer-only: the model span is `aria-hidden` and
nothing in the label can take focus, so keyboard and screen reader users had
no path to the value at all. Carry the model in text that never hides, which
puts it in the header's accessible name alongside the author and the time.

* refactor: Own the header crossfade in the component

The provider-to-model crossfade lived in global CSS even though HeaderLabel
is its only consumer. Tailwind expresses the whole effect: a named group for
the hover scope, one grid cell shared by both labels, and the existing resize
duration and easing variables. Reduced motion now follows the same
motion-reduce convention as the rest of the client.

* fix: Return a defined model name from the header lookup

Array.find over nullable candidates widens the return to include null, which
tsc rejects against the declared string | undefined. Narrow with a predicate
and sort the imports the pre-commit hook rewrote.

* fix: Keep the model reachable by keyboard and the scroll button inert

The header crossfade was pointer-only, so a sighted keyboard user never saw
the model name; the screen-reader copy covered announcement but not sight.
Focusing anything in the message row now swaps the label too, the same hook
the timestamp already reveals itself with.

The scroll-to-bottom wrapper spans the column and stays inert so it never
swallows clicks meant for the thread, which left the button to opt back into
pointer events. A descendant that opts in is hit-testable however its parent
paints, so the transition classes could not hold the control inert as they
claimed: the button took clicks while invisible. Gate the opt-in on the enter
transition settling and drop the declarations that never applied.

* fix: Measure the scrollbar gutter and keep the scroll button unreachable

The spacer assumed the gutter was the `::-webkit-scrollbar` width. Blink and
WebKit honour that rule, Firefox ignores it and sizes the band itself, and an
overlay scrollbar reserves nothing at all, so on those the composer and the
scroll-to-bottom control sat off the messages they are supposed to line up
with. Measure what the message column actually holds back and publish it for
the spacer to read, leaving the token as the pre-measurement fallback.

Gating the scroll button on pointer events alone also left it enabled, so it
kept its place in the tab order and answered Enter while invisible. Disable it
until the same gate opens, and hold its opacity so being briefly unreachable
does not dim it on top of the wrapper's own fade.
2026-08-15 07:19:34 +02:00
Marco Beretta
73812adca1
📐 fix: Size the Model Picker to Its Content (#14859)
The header trigger was w-full inside a max-w-md wrapper, so it rendered
as a fixed 448px pill no matter how short the model name was. Let the
wrapper shrink-wrap the button and cap it at 60vw (20rem from sm up), so
the pill hugs its label and long names truncate instead of claiming the
whole row.
2026-08-15 06:21:36 +02:00
Marco Beretta
8d99fd16fc
🌗 fix: Keep Code Block Header Visible in User Messages (#14856)
* fix: Keep Code Block Chrome Visible on the User Message Bubble

The code bar dropped its background in dark mode and inherited whatever
sat behind it. That works on the chat background, but a user message
bubble is surface-tertiary, which resolves to the same gray-700 as the
code block's border-light outline, so both the header bar and the
outline disappeared into the bubble and only the code body showed.

Paint the bar with surface-secondary instead. It resolves to the same
value as the old pair on the chat background (gray-50 in light,
gray-800 in dark, matching the presentation background), so assistant
messages are unchanged, while the bar keeps a surface of its own inside
the bubble. The execution output panel used the same pattern and gets
the same treatment.

* fix: Derive the Code Block Surface So Custom Themes Keep Their Colors

Painting the code bar with surface-secondary only reproduced the old
appearance because the built-in themes happen to give surface-secondary,
surface-primary-alt and presentation coinciding values. A custom theme
sets those three independently, so the bar could shift on the chat
background where nothing was meant to change.

Add a surface-code role that derives from whatever the bar used to show:
surface-primary-alt in light, where the bar was already opaque, and
presentation in dark, which is exactly what the transparent bar
inherited. Every theme therefore renders the bar as before on the chat
background, while the bubble no longer bleeds through it.

Give ResultSwitcher the same surface. It had no background of its own,
so once the output panel above it gained one it became a detached band
of bubble color, the same defect one element lower.

* fix: Scope the Opaque Code Surface to User Message Bubbles

CodeBar renders on more than the chat background. The terms dialog and
the subagent panel put MarkdownLite on surface-dialog and
surface-primary, and dark:bg-transparent was what let the bar sit on any
of them. Painting it unconditionally gave those surfaces a header that
contrasts with their own background.

Restore the original declarations and scope the opaque role with a
.user-turn ancestor selector, which MessageRow and SteerPart both
already set, so every user bubble is covered without threading context
through react-markdown. Outside a user bubble the classes are byte for
byte what they were, so no other surface and no custom theme can drift.
2026-08-15 05:39:02 +02:00
Danny Avila
530a935a74
🎨 feat: Color the Context Gauge by Category and Collapse its Breakdown (#14855)
* 🎨 feat: Color the Context Gauge by Category and Collapse its Breakdown

The context window bar becomes a stacked meter — one hue per category — and
the breakdown collapses behind a disclosure so the gauge alone is the default
view. The collapse choice persists per user.

Adds a categorical series scale (`rgb-series-1`…`rgb-series-7`) to the
versioned theme registry, so themes and `REACT_APP_THEME_SERIES_*` can retint
it. Hues are anchored on LibreChat's own brand tokens; every step was computed
rather than picked, by enumerating slot orderings and snapping each step until
all gates passed in both modes:

  worst adjacent CVD ΔE          12.4 light / 13.0 dark  (target 8)
  worst adjacent normal-vision   19.0 light / 19.0 dark  (floor 15)
  contrast                       all 14 steps ≥ 3:1 on both the popover
                                 surface and the meter track

Slot order is the colour-vision-deficiency safety mechanism, not cosmetics.
Reserved status colors are never reused for series identity, and the circular
composer gauge is deliberately untouched — it answers "how close am I to the
limit", which stays a status question.

- `SegmentedMeter` + `MeterSwatch` land beside `Progress` in `@librechat/client`,
  owning the 2px surface gaps, rounded ends, the min-width floor, and the hatch.
  The category-to-slot mapping stays feature-local: the palette is theme data,
  the mapping is not.
- Every present category gets a 2px floor so a 251-token row cannot render as
  0.09px; the shortfall comes out of free space, never another category.
- Deferred tools keep their family's hue and add a 135° hatch, so a hue never
  means two things. Segments are reordered to put each deferred pair beside its
  parent, which is also the adjacency the palette was validated on.
- Messages is drawn as a translucent fill with a solid edge: it is the only
  category the user grows, and the form difference doubles as secondary encoding.
- A row carries a swatch if and only if it is a segment. The estimate path knows
  the total but not the composition, so it keeps a single unsegmented fill.
- Usage totals gain a "Totals" heading, and row text lifts to primary ink on
  hover/focus.
- The popover widens 256px → 288px to absorb the chevron and the legend swatches.

Guardrails: the series scale is held to the 3:1 mark floor on both surfaces, the
app CSS defaults are held in step with the runtime themes, and each slot is
asserted to resolve to a Tailwind utility backed by its CSS variable.

* 🐛 fix: Address Codex Review on the Segmented Context Gauge

Three P2 findings, all confirmed.

**Gaps inflated the fill.** Segment widths were percentages of the whole track
while `gap-[2px]` was added on top, so the gaps ate into the free-space
remainder instead of living inside the filled region. Measured on the real
component: a window at 47.2% painted 55.6% full, and the bar read full at ~94%.
Each segment now surrenders its share of the gap budget, so fills plus gaps span
exactly the used fraction. Same case now paints 50.2%.

The residual 3.0pp is the `SEGMENT_MIN` floor doing its job — five sub-pixel
categories rounded up to 2px each. That overshoot is deliberate and bounded, it
comes out of free space rather than a neighbouring category, and the doc comment
now states the magnitude instead of leaving it implicit.

**No reference-theme test.** The suite only exercised the bundled token tables,
so it could not detect the shared component becoming coupled to LibreChat's
values. Adds a deliberately different reference `ThemeDefinition` and asserts the
registry accepts it, the values reach the applied CSS variables, and every
rendered mark takes its colour from those variables — no literal colours in the
tree. `SegmentedMeter.tsx` also joins the shared-primitive colour guardrail.

**Series tokens missing from the public maps.** `IThemeVariables` and
`IThemeColors` are exported for downstream consumers to type their CSS-variable
and Tailwind maps, and would have rejected the new keys. Adds the series entries
to both, plus a compile-time guard in the registry so a slot added to one map and
missed in another fails the build.

The guard deliberately lives in `registry.ts`, not the spec: `tsconfig.json`
excludes `*.spec.ts`, so an assertion there is never checked by the build —
verified by removing a key from each map in turn and confirming the error.

*  fix: Expand the Breakdown in the Context Gauge e2e Specs

`e2e/specs/mock/usage.spec.ts` asserts on rows that now sit behind the
disclosure, so four tests failed on the collapsed default. My miss — I updated
the component spec and never grepped for e2e coverage.

`openBreakdown` now expands the detail after opening, so every caller that
reads a row keeps working; the helper is idempotent, since a reload restores an
already-expanded preference. The one inline `gauge.click()` that duplicated the
helper now uses it.

Adds the case the regression should have been caught by, and which only e2e can
reach: the popover opens to the gauge alone with no detail mounted, expanding
reveals the labelled Totals section, and the choice survives a real reload
through localStorage without a second click.

`e2e/specs/real/usage.spec.ts` reads the totals the same way. It also hovered
rather than clicked, which never opened the popover at all — hover surfaces only
the compact snapshot tooltip, as the mock spec asserts.
2026-08-14 22:46:10 -04:00
Danny Avila
58f0ab7f62
➡️ style: Flush the In-Flight Steer Stack to the Composer Edge (#14854)
The stack carried its own `mx-auto max-w-3xl` cap, a second width constant
beside the composer's. They agree only at `md`. Past that the composer widens
to `xl:max-w-4xl` (or to the full pane under maximized chat space) while the
stack stays pinned at 768px and centered, so the steer bubble drifts inboard —
about 64px short of the composer's right edge at `xl`, and much further when
chat space is maximized.

`inset-x-0` already spans the composer wrapper, so the cap was never adding a
bound the composer didn't already impose; it was overriding one. Dropping it
leaves a single source of truth for the column width, and `p-2` then lands the
send-now arrow in the same column as the composer's own send button (`mr-2`
plus the 1px border). Below `md` nothing moves — the wrapper was already
narrower than the cap.

This puts the stack in the scroll-to-bottom button's lane at every desktop
width, where before `xl` kept them 64px apart by accident. That is what
`steerOverlayHeightFamily` is for: the button offsets by the published overlay
height, measured from the same edge the stack grows from, so it rests 20px
above the top of the stack (#14844).
2026-08-14 22:45:14 -04:00
Marco Beretta
8640bf89ef
fix: copy only assistant response text (#14853)
Restore the pre-#14770 copy path so the message copy button
serializes TEXT parts only and skips tools, thinking, and errors.
2026-08-15 03:24:21 +02:00
Danny Avila
6f05f2427b
👷 ci: Stop Optional Playwright Fonts From Failing E2E (#14852)
`npx playwright install-deps chrome` is the third-most-common e2e failure:
three of the last twenty-five Playwright runs died on it, taking the whole
aggregate gate with them. The step is not installing anything CI needs.

The runner's Chrome is an apt package, so apt has already satisfied every
library Playwright lists — the log shows each one "already the newest version".
All `install-deps` adds are decorative CJK/Thai/Cyrillic font packages, ~21MB
pulled from azure.archive.ubuntu.com by seven jobs on every PR. No CI assertion
depends on them: the only spec that screenshots gates its comparison behind
`E2E_VISUAL_SNAPSHOTS`, which no workflow sets, and no baselines are committed.

Keep the install, but demote it. `google-chrome --version` becomes its own
fatal step so a genuinely missing browser still fails loudly and immediately,
while the font install retries with a per-attempt cap and degrades to a warning.
The Redis install in the list_changed job stays fatal — that one is required.
2026-08-14 20:35:37 -04:00
Danny Avila
af7e890b14
🐛 fix: Give the Header's Sidebar Toggle Its Own Test Id (#14850)
The header now branches on CSS instead of `useMediaQuery`, so its mobile
`OpenSidebar` stays mounted at every breakpoint. The sidebar rail already
publishes `open-sidebar-button` for its own collapsed toggle, so both held
the id at once and `getByTestId` resolved to two elements.

Scope the header's copy to `header-open-sidebar-button` and assert the count
in the spec that broke — the click there failed only once the header had
mounted, so a count assertion pins the collision deterministically.
2026-08-14 20:34:03 -04:00
Danny Avila
0e160d2ba0
📱 style: Consolidate the Mobile Chat Header (#14843)
* ♻️ refactor: Extract `useNewChat` as the Single New-Chat Path

The new-chat sequence (clear the outgoing conversation's cached messages,
invalidate the messages query, reset the conversation atom) existed in
three places: the sidebar's `NewChatButton`, the `newChat` keyboard
shortcut, and an unrendered `Nav/NewChat` component.

Consolidate into `hooks/Chat/useNewChat`. The panel switch stays an
optional `onNewChat` callback rather than living in the hook, because
`useActivePanel` throws outside `ActivePanelProvider` and the chat header
sits outside it — the upcoming header button needs this seam.

`useKeyboardShortcuts` consumes the returned `newConversation` so the file
still instantiates `useNewConvo` exactly once.

Delete `Nav/NewChat`: it was reachable only through its own barrel export,
and carried a stale `max-md:hidden` plus a `data-testid` that collided
with the sidebar's button.

`handleNewChatClick` now also defers on shift-click, so shift-click opens a
new window like any other link.

`ExpandedPanel.spec` mocks the new hook — it reaches `useNewConvo` by deep
path, which escapes the spec's `~/hooks` barrel mock.

* ♻️ refactor: Split Header Action Logic Out of Its Buttons

Lift the behaviour behind the compare and temporary-chat header buttons
into `useMultiConvo` and `useTemporaryChat`, leaving each component as a
thin trigger. The upcoming mobile overflow menu needs the same actions as
menu items, and the visibility rules (assistants have their own comparison
surface; temporary chat can't be toggled mid-thread) have to stay in one
place rather than being restated per surface.

Add the header's new-chat button, consuming `useNewChat`. It renders as an
anchor to `/c/new` so modified clicks still open a tab, and uses a distinct
`data-testid` from the sidebar's button so queries can't match both.

`useTemporaryChat` toggles through a functional updater, dropping the
`useRecoilCallback` that existed only to close over the current value.

No visual change yet — the header layout lands next.

* 📱 style: Fold the Mobile Header Into Four Targets

The mobile header was a horizontally scrolling strip of up to seven
controls, each in its own outlined box, so nothing grouped and nothing
receded. The overflow was hidden rather than solved: ModelSelector alone is
capped at 70vw (273px) and the side clusters need ~130px, which does not
fit a 390px phone.

Mobile now reads: sidebar toggle, model selector, new chat, ellipsis.

Lift the bookmark and export/share menu items into `useBookmarkItems` and
`useExportShare`, each returning the items plus the dialog instance the
surface must render. Both menus already built `MenuItemProps[]` internally,
so the desktop buttons keep their exact markup and simply consume the hook
— the two surfaces cannot drift apart.

`HeaderMenu` composes those with the compare and temporary-chat actions.
Bookmarks nests through `subItems` rather than flattening every tag to the
top level, permission gates decide membership, and the trigger does not
render when nothing survives — reachable, since export/share self-hides on
a new conversation.

Layout is one DOM order serving both breakpoints; hidden items generate no
flex gap, so each collapses without reordering. Branching is CSS-only:
`useMediaQuery` resolves after paint, and the old
`isSmallScreen ? <OpenSidebar/> : null` popped the row a frame late on
every mount.

`overflow-x-auto` is gone. It hid the overflow instead of fixing it, and it
is a horizontal-swipe sink the later edge-swipe work needs removed.

Presets stays a visible mobile icon for now: `PresetItems` uses Radix's
`Close`, which throws outside a Popover root, so folding it needs a
controlled + anchored menu and browser verification.

Also drops two stray `console.log` calls carried along from the bookmark
mutation handlers.

* 🩹 fix: Address Codex Findings on the Mobile Header Menu

`separate: true` marks an entry as *being* a divider — `DropdownPopup`
returns only a `MenuSeparator` for it and drops the item. Setting the flag
on Share and on temporary chat therefore deleted those actions whenever an
earlier group existed. Push standalone divider entries instead.

The spec missed this because its `DropdownPopup` mock rendered every label
regardless of the flag. It now mirrors the real contract — dividers replace
items, `show: false` entries are dropped — and asserts both actions survive
alongside their dividers.

Gate the bookmark tags query on the bookmark permission. `HeaderMenu`
mounts unconditionally and called `useBookmarkItems` before the permission
result applied, so users without `BOOKMARKS:USE` issued a forbidden request
on every chat header mount; the old header dodged this by mounting
`BookmarkMenu` only after the check.

Restore two states the collapsed trigger had dropped: the shared-link
indicator and its active-link label, and a visible checked state for
temporary chat, which previously only reached assistive tech through
`aria-checked` while the old button switched to `bg-surface-active`.

Compose both new controls from the shared `Button` primitive with the same
override `OpenSidebar` already uses, rather than restating the bordered
icon-button recipe locally.

* 🐛 fix: Give the Overflow Menu's Share Indicator Its Own Test Id

Restoring the shared-link indicator on the mobile trigger reused the id
`ExportAndShareMenu` already owns. Both headers stay mounted and are only
hidden by CSS, so `getByTestId('header-shared-link-indicator')` matched two
elements and `shared-links.spec.ts` failed on a strict-mode violation.

Distinct id, matching the new-chat button, which was already separated from
the sidebar's for the same reason.
2026-08-14 18:14:05 -04:00
Danny Avila
f44ce0bb5d
⬇️ fix: Keep Scroll-to-Bottom Clear of the In-Flight Steer Stack (#14844)
Both elements claim the same corner. `ScrollToBottom` is `bottom-5`,
right-aligned, anchored to the message scroll region. `InFlightSteers` is
`bottom-full`, right-aligned, stacking upward from the composer's top edge.
They overlap at every breakpoint, and because they sit in different
stacking contexts, which one paints on top depends on ancestor DOM order
rather than intent.

The reservation mechanism already exists: `InFlightSteers` measures itself
into `steerOverlayHeightFamily` and `MessagesView` reads it to pad the
thread so the newest message clears the overlay. The scroll button was
never included. Thread that same height through and offset the button by
it — no new state, no second measurement.

Also gives the button a mobile gutter. Its width was `md:max-w-3xl` with no
base value, so on a phone it escaped the message column and pinned to the
viewport edge while the steer bubbles inset by 8px. `px-4 md:px-0` aligns
it with the message content and leaves desktop untouched.

The overlay is capped at `max-h-[35vh]`, so the button can rise at most a
third of the screen.
2026-08-14 16:16:01 -04:00
Danny Avila
b0ed8524d4
fix: avoid modal attachment menu overhead (#14847) 2026-08-14 16:15:47 -04:00
Danny Avila
e1178d3c65
🏘️ fix: Scope OpenID User Cache Keys to Signed User Identity (#14837)
* fix(auth): scope OpenID user cache by tenant

* fix(auth): preserve pre-auth cache scope

* fix(auth): type OpenID reuse secret
2026-08-14 15:51:18 -04:00
Danny Avila
ee21066590
🏎️ ci: Focus Redis E2E Coverage (#14842) 2026-08-14 12:54:30 -04:00
Marco Beretta
336703fe48
🔔 fix: Report Agent Saves That Reuse the Newest Version Entry (#14824)
* fix: report agent saves that reuse the newest version entry

An update whose result matches the newest version is written without
recording a version entry. The Agent Builder derived its success message
from the version count, so every such save reported "No changes were
made" while the edit had in fact been persisted. Base the message on
whether the submission carried an edit of its own instead, and keep the
version count for the version history panel.

Also stop suppressing the version entry when the update carries an
atomic operator. isDuplicateVersion compares direct updates only, so it
cannot speak for the operator half; suppressing there applied a change
that no version entry recorded, leaving the document diverged from every
entry in its own history.

Closes #14809

* fix: count an avatar reset as a persisted edit

An avatar upload uses its own endpoint, but a reset rides the update
payload as avatar: null, so classifying every avatar-only submission as
non-persisted was wrong for resets. Clearing an avatar the newest
version never recorded reads as a duplicate to isDuplicateVersion, since
it skips a field when both sides are falsy, so the reset landed with the
version count unchanged and reported "No changes were made".

* fix: skip the version entry when an atomic operator changes nothing

An update carrying $push, $pull or $addToSet bypassed duplicate suppression on
the operator's mere presence. Re-attaching a resource file an agent already holds
makes $addToSet a no-op, so an agent with actions recorded a version entry for a
write that never touched the document, and its version count climbed on retries.

Resolve the operators against the current document instead. $push always appends
and $pull matches arbitrary criteria, so both still count as mutating; $addToSet
counts only when some value it adds is missing. Whatever cannot be compared
cheaply counts as mutating, since over-reporting costs a redundant version while
under-reporting would apply a change no version records.

* fix: confirm the submitted edit survived before claiming a save changed anything

Treating a dirty form as proof of a persisted edit reports success for a save
that stored nothing. The server can normalize a submission straight back to the
stored value: an MCP tool the user added is dropped when authorization rejects
it, and a skill is pruned when it no longer exists. Neither moves the version
count, so the toast claimed the agent was updated when it was untouched.

Capture the agent as it stands before the write, since the mutation replaces
that cache entry on success, and compare it against the one the server returns
across the fields the submission carried. Keep the dirty check alongside it: an
agent loaded through the basic projection carries fewer fields than the update
endpoint returns, and pairing the two keeps an untouched save honest either way.

* fix: compare a save against the expanded agent, not a basic projection

The panel falls back to the basic agent query whenever the expanded one has not
resolved, and that projection drops instructions, tools, edges, skills and the
rest while reducing model_parameters to a single flag. Comparing a submission
against it made every one of those fields read as changed, so a rejected MCP
tool or a pruned skill still reported success.

Compare against the expanded agent, the only projection carrying every field a
submission sends. When it is unavailable the comparison reports true and leaves
the dirty check to decide, since claiming nothing changed for a save that did is
the worse of the two errors. Renamed to say what it now answers.

* fix: drop the operator a suppressed update judged a no-op

Suppression reads whether $addToSet would add anything from a document fetched
before the write, and that reading cannot bind a concurrent one. A $pull landing
in between leaves the operator re-adding the value while the version entry has
already been suppressed, which is the one outcome this path exists to prevent: a
change applied with nothing in the history recording it.

Drop what was judged a no-op instead of racing it. Only $addToSet reaches here,
and only once every value it adds was found stored, so removing it makes the
suppression true by construction rather than true if nothing else writes first.

* fix: leave a suppressed update carrying no operator at all

Dropping only $addToSet left the invariant resting on which operators callers
happen to send. A present but empty $push or $pull counts as no operator when
deciding suppression, yet survived into the write, so the suppressed update was
operator-free by convention rather than by construction.

Drop all three. Reaching suppression already means none of them can change the
document, so removing them states that outright and keeps the write consistent
with the history it declines to record.
2026-08-14 12:20:51 -04:00
Danny Avila
69ce4b7b00
📱 style: Reclaim the Assistant Avatar Gutter on Mobile (#14836)
Assistant content sat 36px from the left edge on mobile (a 24px avatar
column plus `gap-3`) against a 16px gutter on the right, costing ~13% of
the reading width on every response.

Move the avatar into the assistant heading and restore the gutter as
`md:pl-9` on the content column, so the column only exists from `md` up.
Geometry is unchanged on desktop: 768 - 24 - 12 and 768 - 36 both leave a
732px content box, and an absolutely positioned child resolves against the
padding box, so `md:left-0` lands the avatar where the column started.

`AuthorHeader` and `SteerPart` hard-coded `-ml-9` to reach back past that
column; both are gated to `md` so they no longer outdent off-screen.

Pure `md:` variants rather than `useMediaQuery`, which resolves
desktop-first after paint and would reflow every row on mobile.

Covers all five surfaces sharing `MessageRow`: the three message paths,
the shared-conversation view, and search results.
2026-08-14 11:57:58 -04:00
Danny Avila
5d3edeb383
🪄 feat: Smooth Activity Phase Transitions (#14832)
* feat: Animate activity phase transitions

* style: Match activity phase formatting

* 🪄 fix: Fold activity phase entrance in one direction, flush-left label

The phase header replaced <summary> with <button>, which brought the UA
`text-align: center` with it — the label span is `flex-1`, so the text
filled the row and centered inside it. Left-align it and drop the leading
glyph: the card's border and fill already carry the weight, and the child
tool groups keep their own icons.

The entrance also read as two movements. The card, header and inset all
hard-cut in at full size, displacing the transcript below by ~57px, then
folded back up past the header that had just pushed it down. The card now
mounts in the shape of what was already on screen — zero-height header,
transparent chrome, no inset — and grows the header as the panel collapses,
so the block's height only ever decreases. Chrome, padding and both heights
share one curve.

The collapse also waits for a painted start value; a single rAF can land
before paint, and a start value the compositor never saw snaps rather than
transitions.

- Restore the e2e parent-phase selectors, which still matched `summary`
- Memoize the hoisted `groupActivityPhases` pass and its phase-index set
- Finish the amber -> `text-text-warning` sweep in ToolCallGroup and Part

* 🩹 fix: Scope phase-entrance history and resolve media queries at mount

Addresses both Codex findings on #14832.

`MultiMessage` renders siblings without a key, so `ContentParts` survives a
sibling switch with its refs intact. The recorded phase-marker set outlived
the message it described, and any phase in the newly selected sibling whose
index was absent from the previous sibling's set was read as a live arrival —
already-loaded history mounted expanded and collapsed itself. Scope the set
to its messageId and treat a mismatch as a fresh mount.

`useMediaQuery` initialized to `false` and resolved only in a passive effect,
so the first render always reported "no match". Anything branching once at
mount — the frozen entrance flag here, and every other first-paint decision
across its call sites — never saw the correction, which is how a
`prefers-reduced-motion: reduce` user still got the fold. Read the query
synchronously in the state initializer and guard both paths for environments
without `matchMedia`.

*  fix: Honor reduced motion on manual phase disclosure

The entrance already respected the preference, but manually opening or
closing a phase did not: `useExpandCollapse` writes its transition as an
inline style, which cannot carry a `prefers-reduced-motion` media query,
and there is no global reduced-motion reset in the stylesheet. Before this
PR the phase used `<details>`, which had no animation at all — so the swap
to an animated disclosure handed reduced-motion readers a 300ms fold they
did not have.

Resolve the preference in the hook and drop the transition outright. Every
expanding panel in the message content shares it, so tool calls, thinking
blocks, attachments and web-search sources are covered by the same change.
The chevron and the fold's own utility classes get `motion-reduce`
overrides, which the inline styles cannot express.

* 🩹 fix: Keep the collapse completion signal under reduced motion

`transition: none` emits no `transitionend`, and ToolCallGroup waits on
that event to drop `shouldRenderBody`. Removing the transition therefore
left every collapsed tool subtree mounted indefinitely — expensive and
stateful children retained for exactly the readers who asked for less
work, not more.

Shorten the duration to 0.01ms instead. It is imperceptible, still fires
the event, and keeps the hook the single place that knows about the
preference. Caught by Codex on 3b9bd2181d.
2026-08-14 11:57:48 -04:00
Danny Avila
c06fbff475
📦 chore: bump @librechat/agents to v3.5.1 (#14830)
* 📦 chore: bump `@librechat/agents` to v3.5.0

* chore: bump agents sdk to v3.5.1
2026-08-14 11:14:07 -04:00
Ravi Kumar L
bc6392d05b
🪢 fix(langfuse): mark provider-backed agent traces (#14833)
* fix(langfuse): mark provider-backed agent traces

* fix(langfuse): mark stored response traces

* test(langfuse): isolate provider marker setup
2026-08-14 10:28:25 -04:00
Danny Avila
d170ecf481
🧹 ci: Remove Obsolete Test Server Deployment (#14823) 2026-08-14 09:59:35 -04:00
Danny Avila
0ce4c3374b
⏲️ test: Give ServerConfigsDB Mongo Hooks a 60s Timeout Budget (#14831)
`beforeAll` boots a real mongod via MongoMemoryServer, resets the module
registry and re-imports data-schemas, ServerConfigsDB and the MCP OAuth handler
before a single test runs. That exceeds the 15s global `testTimeout` once the
runner is busy: the suite finishes in ~4.5s on its own but has been observed at
16.8s under a loaded `@librechat/api` shard, failing every test in the file with
"Exceeded timeout of 15000 ms for a hook".

Give both mongo hooks an explicit 60s budget, matching
`checkpointer.integration.spec.ts`, the other MongoMemoryServer suite that
already opts out of the global default. `afterAll` gets the same treatment since
`mongoServer.stop()` is subject to the same contention.

No behaviour change — the timeout only bounds setup, and the suite still
completes well inside it.
2026-08-14 09:55:42 -04:00
Danny Avila
eaef87fa26
🚀 chore: Prepare v0.8.8-rc1 (#14394)
* 🚀 chore: Prepare v0.8.8-rc1 release

* 📚 docs: Complete v0.8.8-rc1 operator references

* 📚 docs: Mark stateful sessions experimental

* 📚 docs: Clarify background code capability

* 📚 docs: Refresh v0.8.8-rc1 operator guidance

* 📚 docs: Highlight v0.8.8-rc1 features in README

* 📦 chore: Bump publishable packages again

* 📚 docs: Add streaming question progress

* 📦 chore: Bump publishable packages again

* 📚 docs: Refresh v0.8.8-rc1 release highlights

* 📦 chore: Bump publishable packages again

* 📚 docs: Refresh v0.8.8-rc1 release guidance

* 📦 chore: Bump publishable packages again

* 📚 docs: Highlight batched Agent questions

* 📦 chore: Bump publishable packages again

* 📦 chore: Bump publishable packages again

* 📦 chore: Bump publishable packages again

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📄 docs: Note PowerPoint template support

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📄 docs: Note latest provider and file support
2026-08-14 03:24:59 -04:00