Commit graph

144 commits

Author SHA1 Message Date
Marco Beretta
649e68170e
🖼️ refactor: Consolidate Provider Icons Into a Single Registry (#15148)
* test: make useIsActiveItem observer assertions deterministic

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

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

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

* feat: add canonical ProviderId vocabulary and resolver

* feat: resolve custom endpoint provider identity at config load

* feat: add provider icon registry data

* feat: add ProviderIcon and ProviderAvatar components

* feat: add provider icon resolution hook

* refactor: migrate direct icon lookups to the provider registry

* refactor: migrate composite endpoint icons to the provider registry

* refactor: render message provider icons from the registry

* refactor: remove the duplicated endpoint icon maps

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

* fix: address Codex review findings on provider icons

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

* fix: address remaining Codex findings on provider icons

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

* fix: address the latest Codex review notes

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

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

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

* fix: tighten brand validation and inherit SVG fill color

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

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

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

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

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

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

* fix: annotate themeBrandTokens for isolatedDeclarations

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 13:00:38 -04:00
Danny Avila
f9c051f8ea
🪶 feat: Support Non-Persistent Controlled Themes (#15170) 2026-08-24 11:52:30 -04:00
Marco Beretta
dd85c6d6d0
🪹 feat: Shared Empty State for Side Panels (#15123)
* feat: shared empty state for side panels

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

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

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

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

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

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

A maxPerUser of 0 disables the create button on an empty list, so the
empty state must not say to create one either; the hint now follows the
same effective gate as the button.
2026-08-23 18:56:34 -04:00
Marco Beretta
8a118c7cb3
⏱️ feat: Shared Time Picker for Schedule Times (#15122)
A schedule's time was three dropdowns side by side: hour, minute, meridiem. That
is three controls for one value, it cannot be read at a glance, and the minute
list was a fixed set of four with the stored value bolted on, so a schedule
already running at :07 could be kept but never chosen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: mock message context across markdown test suites and prevent global plugin cache leak
2026-08-23 18:41:14 -04:00
Marco Beretta
44d97f859d
feat: Custom Cron Cadence for Scheduled Chats (#15084)
* feat: custom cron cadence for scheduled chats

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

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

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

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

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

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

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

A floor-violating expression disabled Create and rendered the cadence
message, but the input itself still said aria-invalid=false and its
aria-describedby never reached that message, leaving a screen reader
user with a disabled Create and no stated reason.
2026-08-23 14:35:18 -04:00
Marco Beretta
6757c65a54
feat: Context Gauge Hover Reveal and Breakdown Motion Polish (#15038)
Show the context breakdown on hover instead of click, shrink the gauge,
open and close the popover with a scale-and-fade transition, ease the
collapsible with decelerating open and accelerating close curves, render
the Messages segment solid, and pair legend row hover with a dimmed
meter via a new highlightId prop on SegmentedMeter.
2026-08-21 11:34:36 -04:00
James Todaro
e49e264487
fix: Resolve axe Violations in Sidebar, Tools Dropdown and Footer (#14979)
*  fix: Resolve axe Violations in Sidebar, Tools Dropdown and Footer

- give virtualized conversation rows the row/gridcell roles their grid and rowgroup parents require
- make the conversation row a non-interactive container, moving its accessible name, aria-current and focus ring onto the title control so it no longer wraps the options button
- open the tools menu non-modally and portal it into the main landmark, dropping Ariakit's injected dismiss button and keeping menu content inside a landmark
- expose aria-valuenow, aria-valuemin and aria-valuemax on the sidebar resize handle
- drop role="contentinfo" from the chat footer, which is never rendered outside main
- scan the loaded app in a11y.spec.ts, and cover seeded conversation rows, a hovered row and the open tools menu

*  fix: Keep the Resize Handle's ARIA Range Valid at Every Viewport

- floor the announced maximum at the aside's own min-width, so viewports where 40% falls under it no longer report a maximum below the minimum
- track the viewport so the announced range follows a resize instead of a render-time snapshot
2026-08-20 11:52:53 -04:00
Danny Avila
c5276fc63d
⏱️ feat: Run Scheduled Chats Through Durable Agent Triggers (#14939)
* feat: Scheduled Chats — agent-centric scheduled runs creating real conversations

Squash of the full review-hardened branch (PR 14540, supersedes 14373) onto
latest dev, preserving the exact verified tree. History prior to this commit
lived on the pre-squash branch; every invariant below survived 25 Codex review
rounds plus two external audit rounds (R26) with regression tests that fail
without their fixes.

Feature:
- Schedules CRUD + side-panel UI (cadence dialog, run cards, Run Now), roles/
  permissions (SCHEDULES:USE), interface.schedules availability, per-user limits
  and capacity slots, timezone-aware cadence with DST-conservative floors and
  misfire grace.
- Engine: single-process claim/fire loop with leases, loopback POST dispatch
  (signed schedule-fire JWT claims, per-occurrence idempotency key inside the
  route's clientRequestId charset), overlap/balance/capacity/duplicate skip
  policies with auto-disable streaks (too_many_failures, insufficient_balance),
  reconciliation from retained terminal-job evidence, erasure sweep.
- Scheduled runs create real conversations through the resumable agents chat
  path: HITL pauses surface on the card (requires_action), resumes re-apply the
  fire boundary's admission policy (revision fence, enabled, global kill switch,
  SCHEDULES:USE, availability) before continuing a billed generation.

Correctness invariants (the audit surface):
- Settlement discipline: every persistence-producing write happens-before a
  run's terminal outcome write; Stop/complete/pause race through single-winner
  terminal CAS claims (dev's TerminalJobClaim substrate) with retained,
  completedAt-less evidence for scheduled fires plus an owner-intended outcome
  stamp (scheduleOutcome) the reconciler prefers over re-derived success —
  round-tripped through the Redis hash mapper.
- Swallowed generation failures (client error content parts) classify to
  error/skipped_balance instead of success on both initial and resumed paths;
  stale stamps are refreshed evidence-first when persistence plus the Mongo
  outcome write both fail.
- Abort honesty: delivery judged by generation ownership and the CAS's actual
  from-status; republication escalates to the transport's acknowledged variant;
  the Stop route settles only genuinely paused runs.
- Account deletion: one-way barrier (deletionRequestedAt) with auth-cache
  tombstone-before-stamp, boundary rechecks across all auth strategies, quiesce
  of scheduled + interactive work with durable per-stream abort fences
  (positive-evidence acknowledgement only), owner-side finalization markers for
  post-terminal billed writes, deferred-deletion sweep (explicitly ensured
  partial index) that completes cascades autonomously. Remote OpenAI-compatible/
  Responses requests are documented as outside the quiesce and tracked in issue
  14594.
- Store compatibility: finalization markers optional on the legacy IJobStore
  contract with coherent degradation (registration fails -> synchronous title
  fallback; count reads 0).

* fix: fence terminal response persistence from deletion; retain stale-pause evidence

Two blockers from the third external review round.

Terminal persistence visible to account deletion:

- The finalization marker was registered only when post-terminal TITLE work was
  possible, but every persistence-owning terminal CAS opens the same window: the
  claim drops the job out of the active set BEFORE the response save (and the
  background user-message/convo saves), so a deletion quiesce landing there saw
  neither an active job nor a marker and could cascade while the admitted request
  could still recreate messages. Both controllers now register the marker before
  every persistence-owning claim — the fresh-turn path and the HITL resume — and
  release it only after their pending saves (and any post-terminal title) have
  landed, on success and failure paths alike. The TTL bounds a crash.
- settleAbortFence no longer clears a complete/error fence while
  `terminalPersistencePending` is set: terminal at the CAS is not settled while
  the owner is still persisting. The job facade now surfaces the flag.
- The marker trio is REQUIRED by the runtime store contract (assertJobStoreV2
  refuses a store without it at configure time, keeping the failure loud and
  deterministic) while remaining optional on the legacy public IJobStore type for
  source compatibility. The silent degrade path from the previous round is gone —
  it was not deletion-safe.

Stale-pause recovery retains scheduled evidence:

- Three crash/timeout recovery paths — ApprovalLifecycle.failStalePausePersistence
  and the InMemory/Redis stale-pause cleanups — unconditionally stamped
  `completedAt`, putting a scheduled fire's failed-pause error terminal on the
  short completed TTL. A Mongo outage longer than that TTL erased the evidence and
  the reconciler recovered the run as `interrupted` instead of `error`. All three
  now follow the controller-observed path from the previous round: scheduled jobs
  omit `completedAt` (retained-evidence TTL) and stamp the error outcome.

The PR description now explicitly narrows the deletion guarantee for the remote
OpenAI-compatible/Responses paths (tracked in issue 14594).

* fix: deletion-fence marker protocol — generation-scoped, atomic, fail-closed, all terminal paths

One consolidated pass over the finalization-marker mechanism, per the fourth
external review round. The invariant it establishes: NO persistence-owning
terminal CAS runs without a durable, generation-qualified marker covering the
window it opens, and every consumer treats a pending terminal as unsettled.

- Generation-scoped markers. Entries were keyed (userId, streamId), so a
  Stop-superseded generation finishing late could clear the marker its
  replacement registered on the same conversation. Marker fields are now
  qualified by the generation's createdAt; clears must present the same
  identity, and an unqualified legacy clear cannot drop a qualified entry.
- Atomic Redis registration. HSET-then-EXPIRE loses the fresh marker when the
  user's existing hash expires between the two commands (or the process dies
  there); registration is now a single Lua script carrying both.
- Fail closed everywhere. Registration failure (after one retry) now REFUSES
  the terminal CAS instead of proceeding uncovered: the completion claim throws
  into the error path, the error path skips completeJob and leaves the job
  ACTIVE — deletion-visible by itself, recovered by the stale-running reaper —
  and abortJob returns a new retryable `fence_unavailable` failure the Stop
  route answers with 503 and the deletion quiesce treats as an unacknowledged
  stop (fence kept). The previous round's log-and-proceed is gone.
- Every terminal path enrolled. abortJob now owns its window (register before
  the abort CAS, clear in its finally — the Stop route's checkpoint prune and
  partial save run inside beforePublish, between CAS and publication); the
  interactive and background generation-error paths register before their
  completeJob; the resume controller's error finalization registers before its
  completeJob. A lost or thrown claim releases the marker after pending saves
  flush instead of holding the user's deletion behind the TTL.
- Every pending terminal unsettled. settleAbortFence defers on
  terminalPersistencePending for ALL statuses — including `aborted`, whose
  route-side persistence the previous guard missed.

Also: a direct Redis regression for scheduled stale-pause retention (the P2
test gap), and the PR description no longer claims to carry every commit.

* fix: lease-token lifecycle fences — same-generation isolation, admission fence, undelivered-Stop retention, legacy abort enrollment

Fifth external review round; four P1s, handled as the requested consolidated
lifecycle-fence pass.

- Lease tokens. Marker fields were (streamId, createdAt), shared by every
  contender on the same generation — completion and Stop, or two racing Stops —
  so a losing contender's cleanup erased the winner's still-live marker. Every
  registrant now carries a unique lease token in the field and may only ever
  clear its own lease; unqualified legacy clears cannot touch qualified entries.

- Admission fence. Authentication can pass before the deletion barrier goes up,
  and the durable createJob is several async steps later — a deletion quiesce in
  that window saw neither an active job nor a marker and could cascade before
  the admitted request created its job. The controller now registers an
  admission lease and THEN rereads the deletion barrier: the ordering guarantees
  either this request observes the barrier (403, lease released, slot/claim
  cleanup) or the quiesce observes the lease and defers. Held until createJob is
  durable; released on every refusal and initialization-error path. Fail closed
  when the lease itself cannot be registered (503 retryable).

- Undelivered-Stop retention. abortJob released its lease in a finally even when
  delivery AND publication had provably failed — the job reads terminal
  (invisible to active-set scans), a user Stop writes no durable abort fence,
  and the remote owner keeps generating and will persist its abort-catch writes
  whenever the signal finally lands. The lease is now retained in exactly that
  case, and each resignal attempt heartbeats a fresh lease so the fence outlives
  the TTL for as long as delivery is still being driven. The abort-winning
  turn's own loser-side pending saves are additionally fenced in the controller
  catch (best-effort — those writes are already in flight).

- Legacy abort enrollment. abortMiddleware (assistants abort route fallback for
  non-assistants endpoints) awaited abortJob and then spent usage and saved the
  stopped response AFTER the abort's lease was released. Both writes now run
  inside `beforePublish`, between the abort CAS and publication, covered by the
  same lease as every other abort.

Barrier tests, each verified to fail without its fix: same-generation lease
isolation (store), racing two-Stop loser cleanup (manager, stale-read forced
CAS race), undelivered-Stop lease retention, resignal heartbeat, admission
refusal with lease-before-reread ordering plus release-on-durable-create, and
legacy-abort persistence inside beforePublish.

* fix: heartbeat-backed owner-lifecycle leases close the settlement handoff races

Sixth external review round: the remaining P1 interleavings were one structural
problem — lease handoffs that were not atomic — resolved as the requested
consolidated lifecycle-lease pass.

- Quiesce reads leases BEFORE the active-job scan. The admission-lease -> durable
  -job handoff is only atomic against a reader in the OPPOSITE order of the
  writer: writers hold the lease strictly until the job is active-set visible,
  so leases-first shows every interleaving either the lease or the job.
  Jobs-first allowed a request to create its job after the scan and release its
  lease before the count — hiding both, cascading, and letting the new
  generation persist into a deleted account.

- The abort acknowledgement is fenced by the owner-lifecycle lease. Redis ACKed
  the moment the owner's AbortController tripped; the stopping side released its
  lease on that ACK while the owner's asynchronous abort-catch persistence was
  still ahead. The transport now awaits a manager-installed pre-ACK hook that
  registers a DETERMINISTIC owner lease (exactly one owner exists per
  generation, and determinism is what lets the signal-time registrant and the
  owner's catch-side release agree across processes) before the acknowledgement
  is persisted or published; an owned same-replica abort bridges to the same
  lease before tripping its local controller. Both generation-owner catches
  (fresh turn, resume) release it once their writes land.

- A failed replacement handoff no longer orphans the predecessor. The atomic
  replacement removes it from active storage, and an unconfirmed handoff
  terminalizes the replacement too — leaving nothing a quiesce could discover
  while the predecessor's provider may still be generating. Its owner lease is
  now retained at the point the receipt fails delivery; the owner replica renews
  it through the pre-ACK fence when the signal finally lands.

- Leases HEARTBEAT while held. The five-minute store TTL only bounds a crashed
  holder; live persistence — a stalled save, a long deferred title — must never
  outlive its own fence. holdUserFinalization registers and renews every minute
  until released; the controllers' completion/error/admission leases all hold.
  The undelivered-Stop retention moved to a deterministic `stop` lease that
  every resignal attempt renews and the first successful one clears (no more
  opaque leases accumulating to TTL), and a THROWN abort transition releases the
  contender lease instead of leaking it.

- The user-document abort-fence mutations now invalidate the auth user-doc
  cache, matching every other user-doc write.

Barrier tests, each verified to fail without its fix: quiesce lease-scan
ordering (plus the observed-lease defer), pre-ACK fence ordering at the
transport, owned-abort owner-lease bridging, replacement-handoff predecessor
retention, held-lease heartbeat past the TTL, and failed-then-successful
resignal reaping the retained stop lease.

* fix: one manager-owned owner-lease span across every abort delivery path

Seventh external review round; four lifecycle-fence gaps, closed by making the
owner-lifecycle lease a single manager-owned, heartbeat-held span.

- Fail-closed acknowledgements. The pre-ACK hook registered a one-shot lease and
  the transport ACKed even when it failed; a same-replica owned abort likewise
  swallowed registration failure. The hook now acquires a HELD owner lease
  (heartbeat until the owner's catch releases it via releaseOwnerLease) and a
  rejection SUPPRESSES the acknowledgement — the stopping side stays retryable
  behind its retention lease, and every resignal re-drives the handler. The hook
  also stopped gating on `job.createdAt === generationId`: during a replacement
  handoff the store holds the replacement while the abort targets the
  predecessor, and that gate silently skipped exactly the generation being
  acknowledged (the store job is owner identity, never a generation gate). A
  local owned abort acquires the same held lease before tripping its provider;
  post-CAS the trip cannot be withheld, so acquisition failure downgrades
  delivery and the retention handoff keeps the user fenced.

- Committed-but-lost-reply disambiguation. A thrown abort transition released
  the contender lease as if nothing had happened, but a Lua CAS can commit and
  lose its reply — an aborted job invisible to active-set scans whose provider
  was never signalled, with no fence left. The throw path now re-reads the exact
  generation: only a job still live under the caller's identity proves no
  commit; committed or ambiguous outcomes hand the fence to the deterministic
  stop lease (kept on the contender lease if even that fails) before rethrowing.

- Replacement handoff covered end to end. A LOCAL replacement abort acquires the
  predecessor's held owner lease before the trip (failure reports the receipt
  undelivered, engaging retention). Failed-handoff retention is no longer a
  swallowed one-shot: it heartbeats with the durable acknowledgement proof as
  its renewal predicate — acquisition failures keep retrying for as long as the
  fence is needed, and the retainer stands down (without clearing the shared
  field) once the remote owner ACKs and thereby holds its own lease.

- A LOCAL resignal delivery hands off to the owner lease BEFORE clearing the
  retained stop lease, and keeps the stop lease when that handoff fails.

Barrier tests: hook rejection suppressing the ACK, commit-then-lost-reply
retention with its provably-uncommitted counterpart, local-resignal owner
handoff ordering, pre-ACK owner lease held past the store TTL until release
(and provably stopped after), and failed-handoff retention retrying on its
heartbeat — verified fail-before/pass-after by stashing the fixes.

* fix: finish scheduled chat lifecycle hardening

* fix: close scheduled chat review follow-ups

* fix: generation-fence abort recovery evidence

* test: wait for settled approval tool output

* test: preserve scheduled init reconciliation option

* refactor: rebuild scheduled chats on durable agent triggers

* test: reset MCP cache mock between cases

* test: isolate scheduler startup in server specs

* fix: harden scheduled run lifecycle

* fix: normalize schedule capacity conflicts

* test: type schedule collision fixture

* fix: re-fence scheduled resume and expiry

* fix: fence scheduled resume handoffs

* fix: release superseded manual schedule leases

* fix: release failed run-now claims

* fix: release superseded engine claims

* fix: repair schedule dialog interaction and rework its form

The agent picker was unusable: ControlCombobox portals its popover to the
body by default, which lands it outside the dialog's Radix focus trap. Clicks
passed through it, it could not be tabbed into, and the trap fighting Ariakit
for focus locked the page up on selection. The prop is documented for exactly
this case — pass `portal={false}` and give the dialog `overflow-visible`, as
ProjectButton already does. The time and day dropdowns defaulted the same way.

Alongside that:

- Extract the agent builder's instructions editor (special-variable menu plus
  expand-to-fullscreen) into a controlled `VariableEditor` and use it for the
  schedule prompt. Insertions now route through `onChange`, so react-hook-form
  sees them — the schedule PATCH is built from `dirtyFields`, and a `setValue`
  that skipped dirty tracking would have dropped an inserted variable silently.
- Replace the hand-rolled frequency buttons with the shared `Radio`. They marked
  the selection with `bg-surface-hover` on an outline button whose hover is the
  same token, so the selected option was indistinguishable from a hovered one;
  `Radio` is also a real radiogroup rather than four `aria-pressed` toggles.
- Wrap the fields in a real `<form>` and associate the footer button by id, so
  Enter submits. Group the frequency, day and time controls in fieldsets.
- Add placeholders for name and prompt, match the textarea fill to the other
  fields, and label the hourly case as minutes past the hour.
- Widen the dialog to `md:max-w-3xl` and pair name with agent so the form fits
  without scrolling on desktop.
- Move scheduled chats below skills and above prompts in the side nav.

The new dialog spec fails when the portal fix is reverted.

* test: cover scheduled and subagent deletion drains

* refactor: own the form-control appearance in the client primitives

Addresses the codex finding on ScheduleDialog: a feature-local `FIELD_CLASS`
restated the `Input` primitive's border, radius, height and background so it
could be pasted onto the schedule dropdowns, leaving those controls with no
connection to the primitive they were imitating.

Move that appearance into `packages/client/src/components/Field.ts` as the
single source `Input` and `Textarea` now compose, and give `Dropdown` and
`ControlCombobox` a `variant="field"` that applies it. The schedule dialog
passes the variant and carries no class strings of its own.

This also repairs a break the dev merge would otherwise have introduced: the
newer `Dropdown` splits `className` (wrapper) from `triggerClassName`, so the
old pasted classes would have landed on the wrapper and left the triggers
unstyled.

The semantic-token guard now watches the shared module and asserts each
primitive still composes it, which covers more than the two files it read
before.

* fix: keep an explicit schedules disable from becoming an opt-in

`use` is two things at once for a dual-purpose runtime interface field: a
permission bit, which DB overrides strip, and the runtime disable signal that
`getLimits` reads. Stripping it from `{ use: false, maxPerUser: 2 }` leaves an
object, and `getLimits` treats any object without `use: false` as enabled — so
an admin override written to stop scheduled billing for a role or user started
it instead.

Collapse an explicit disable to the boolean form before the strip, on both
paths that accept it: the `interface.schedules` field patch, which admitted the
object wholesale because bare runtime paths deliberately bypass the permission
gate, and the overrides merge, which reached the composite-field branch and
kept `maxPerUser`. Objects that only narrow limits are untouched, so a
principal can still be given a smaller cap.

Both regressions fail without the normalizer.

* fix(schedules): Wave A — null-balance CAS, atomic paused-card clear, clustered erasure sweep

Slice 1 (thread r3804518381): route the existing-null balance initialization
through a { user, tokenCredits: null } compare-and-set instead of a blind $set,
so a concurrent initializer/charge landing between the preflight read and the
write is never handed back its spent starting balance. On a CAS miss the
preflight re-reads the winner. The absent-record $setOnInsert path and the
credited-record refill-config sync are unchanged. Adds the initializeNullBalance
adapter (no upsert) and regression coverage for winner/miss/sync cases.

Slice 2 (thread r3804518388): updateScheduleById now drops a `requires_action`
lastRun projection atomically with the configRevision bump. Any pause present at
edit time was projected under the pre-edit revision and can never be replaced by
its own revision-fenced terminal outcome — a disabling edit would strand the
card on "Needs approval" forever. Implemented as classic-operator CAS branches
(DocumentDB rules out a conditional pipeline $unset), fenced on the card STILL
being the pause so a terminal outcome or newer occurrence that races in is
preserved. Terminal history survives untouched.

Slice 4 (thread r3803826204): expose initializeScheduleErasureSweep from the
schedule runtime facade and start it in every clustered (experimental) worker
after Mongo is up. It re-drives eraseScheduleIfDrained for soft-deleted rows so
a hidden prompt cannot outlive its drain when the delete/erase-on-settle
attempts miss. It arms nothing else and never infers owner death from a
process-local missing job (isTopologySafeToArm gates that).

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

* fix(schedules): Wave B.1 — reversible account-deletion schedule suspension

Thread r3804518383. Account-deletion quiesce marked every schedule `deleting`,
disabled it, and cleared nextRunAt destructively. When a later cascade step (or
the drain itself) failed, the controller cancelled the user-deletion fence —
restoring the user — but the schedules stayed `deleting` and were erased by the
sweep, silently losing all of a live user's scheduled prompts.

Replace the destructive marking with a REVERSIBLE, token-fenced suspension:

- suspendUserSchedulesForDeletion(userId, token) snapshots each schedule's prior
  enabled/nextRunAt under a per-attempt token, then fences firing (disable, clear
  nextRunAt, rotate claimToken). It never sets `deleting`, so a suspended row is
  not erasure-eligible. Snapshotting reads then bulkWrites (a classic update
  cannot copy field values under DocumentDB), fenced per row so an already-
  suspended/soft-deleted/edited row is left alone; idempotent per token.
- restoreUserSchedulesFromDeletion(userId, token) reverses it, re-enabling and
  re-arming only rows still carrying the exact attempt token and not independently
  deleted — so an owner-deleted or newer-attempt-suspended schedule is never
  resurrected.
- deleteUserController generates the attempt token, passes it to quiesce, and on
  any failure that cancels the user-deletion fence restores the suspended rows. A
  successful deletion hard-deletes them (and their snapshots) via the existing
  cascade and never restores.

Adds a `deletionSuspension` embedded field (excluded from the wire schedule),
data-method regression tests (suspend/restore/fence/idempotency/no-resurrect),
and controller tests (restore on drain-false and post-quiesce cascade failure,
no restore on success).

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

* fix(schedules): Wave B.2 — wire the interactive Stop persistence protocol

Thread r3804255932. The durable Stop primitives (requestRunAbort 'stop',
getScheduleRunAbortState, markRunAbortPersisted) already existed but production
only used requestRunAbort(..., 'deletion'). An interactive Stop flipped the job
to `aborted` and then persisted its partial message + checkpoint inside
`beforePublish`, without ever stamping the schedule Stop or acknowledging it —
so reconciliation, the generation owner, or a concurrent schedule/account
deletion could terminalize the run and release its capacity (and erase data)
mid-write.

Wire the request -> persist -> acknowledge -> settle barrier through the
schedule runtime (the route never touches raw Mongo):

- Expose beginScheduledStop / acknowledgeScheduledStopPersistence on the service.
- The abort route stamps the Stop BEFORE signalling abortJob (a serialized
  'in_progress' loser returns 409 STOP_IN_PROGRESS without a second abort),
  acknowledges only after beforePublish persistence succeeds, and on a
  persistence failure leaves the barrier unresolved so the run stays preserved
  (client retries; stale-owner timeout is the bounded recovery). A failed abort
  releases the stamp it placed so a replacement/retry is never blocked through
  its predecessor.
- recordScheduleOutcome (the owner settlement path) now waits, bounded, for the
  Stop acknowledgement before terminalizing; a resolved/non-stop/stale marker
  proceeds immediately. The paused Stop settles only after its own ack.

Adds service-layer barrier tests and route-level ordering/persistence-failure/
in-progress tests; the data-layer serialization is already covered.

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

* fix(schedules): Wave C.1 — reconcile durable trigger delivery with the reservation

Threads r3803826192 (manual limiter) and r3804255924 (PII/moderation), plus the
independently-found long-Retry-After race. fireSchedule reserves a `started` run
and a global capacity slot BEFORE the durable trigger delivery reaches the chat
route, where an interactive limiter (manual Run Now), PII, or moderation can
reject it before any generation job exists — dead-lettering the delivery while
the run sat `started` until the 30-minute orphan sweep mislabeled it interrupted.
And a valid delivery deferred by Retry-After (up to 24h) could be orphan-settled
and have its capacity released, then fire anyway.

Translate durable delivery state into the schedule outcome:

- Store the deterministic trigger deliveryKey on the ScheduleRun reservation
  (computed from the envelope BEFORE enqueue, so an ambiguous commit still has it).
- Add a getTriggerDelivery engine dep (wired to the merged trigger service's
  getDelivery) that reads the durable delivery by key.
- Schedule reconciliation, for a jobless `started` run: staging/pending/leased →
  admission is live, never orphan; dead → record `error` from the durable
  lastError and release capacity promptly (no 30-minute wait), through the
  ordinary outcome/auto-disable path; succeeded or no record → the existing
  legacy orphan policy (interrupted only past the cutoff); a delivery lookup
  failure defers rather than orphaning a possibly-live delivery. Limiter/PII/
  moderation middleware writes no schedule state.

Adds reconcile state-mapping tests (dead/pending/leased/staging/succeeded/none/
lookup-failure) and a fire test that the reservation's deliveryKey equals the
enqueued delivery's idempotency key.

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

* fix(stream): Wave C.2 — durable retry/ack for terminal host lifecycle actions

Thread r3804518375. Approval expiry won the `requires_action → aborted` CAS and
then invoked the host hook best-effort: `runApprovalExpiredHandler` swallowed a
failure, and because later sweeps enumerate only `requires_action` jobs, the now-
aborted job was never offered again. In the clustered entrypoint (no schedule
reconciler) the ScheduleRun stayed `requires_action` and its retained job
persisted indefinitely.

Make the host lifecycle work durable rather than schedule-specific:

- Add a generic `terminalHostActionPending` marker, set ATOMICALLY in the same
  terminal transition (ApprovalLifecycle.expireWithIdentity), only when a host
  adapter is installed.
- Retain and index such jobs: both stores keep them out of terminal reaping and
  expose getTerminalHostActionJobs(); Redis adds a set + extended (24h-bounded)
  TTL, in-memory a bounded 24h retention so a permanently-failing hook cannot leak.
- The manager clears the marker only after the adapter acknowledges success,
  fenced by generation identity (clearTerminalHostAction), so a replacement
  generation can neither clear nor execute its predecessor's action.
- cleanup()/expireStaleApprovals() enumerates unacknowledged terminal host actions
  across restarts and replicas and retries the idempotent hook; the relay only
  re-invokes while the marker is unacknowledged, so a successful ack prevents
  duplicate work. Store-won expiry marks it too, so a loser-replica relay still
  crosses the hook.
- Terminal SSE notification continues regardless of host-hook outcome.

Covers in-memory behavior (retry after failure, restart/other-replica retry, ack
prevents duplicates, identity fence, terminal notification on failure, no marker
accumulation for non-scheduled jobs) and updates the Redis cluster-membership
contract test for the new index.

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

* style(schedules): satisfy import sorting in fire.ts and fire.spec.ts

CI "Static checks" failed on IMPORT_SORT for the two files Wave C.1 added imports
to (the AgentTriggerEnvelope type import and getAgentTriggerIdempotencyKey).
Applied scripts/sort-imports.mts to exactly those files — imports-only reordering,
no behavior change. Other files reported by a repo-wide check are pre-existing on
dev and deliberately left untouched so this PR is not widened.

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

* fix(schedules): repair the deletion CLI and order restore before the fence release

Addresses three findings from the fresh Codex review.

P1 — config/delete-user.js called methods.disableUserSchedulesForDeletion, which
Wave B removed in favor of suspendUserSchedulesForDeletion. The file is
@ts-nocheck and its spec mocked the removed name, so neither typecheck nor tests
caught it; the real CLI would throw a TypeError before deleting anything and then
only unwind the fence. The CLI now uses the tokenized protocol: it mints a
suspension token, suspends with it, and restores that exact attempt's rows in its
finally block when the deletion does not commit. Its spec mocks the real methods,
so the breakage can no longer hide.

P2 — both the HTTP controller and the CLI released the user-deletion fence BEFORE
restoring schedules. That fence is what refuses new schedule writes/claims, so the
gap let an owner PATCH edit a still-suspended row and have its enabled/next-run
state overwritten by the older snapshot, and let a second deletion attempt
re-suspend under a new token — making the first restore a no-op and stranding the
disabled snapshot permanently. Restore now runs first, while writes are still
fenced.

Hardening for the same defect class across a crash: suspendUserSchedulesForDeletion
now ADOPTS an existing suspension's snapshot when re-suspending a row abandoned by
an earlier attempt, instead of re-capturing the row's current (already-suspended)
state. Without this, an attempt that died before restoring would have its
successor snapshot "disabled, no next run" and permanently strand the schedule.

Tests: CLI restore-before-fence ordering and no-restore-on-success; the same
ordering assertion on both controller post-quiesce failure paths; a data-method
regression that a second attempt adopts the abandoned snapshot and restores the
original enabled/next-run state.

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

* fix(schedules): converge dead deliveries in topology-safe maintenance

Codex finding: the `dead` delivery mapping added in Wave C.1 lives only inside
startScheduleEngine's reconciler, but the clustered entrypoint arms no engine — it
runs erasure-only maintenance. A delivery queued before a restart into clustered
mode and then rejected before generation creation (interactive limiter, PII,
moderation) dead-letters while its ScheduleRun stays `started`, holding a global
capacity slot indefinitely for an ordinary non-deleting schedule.

Add a dead-delivery convergence pass to the erasure sweep, so every topology that
runs schedule maintenance settles it. The pass is POSITIVE-EVIDENCE-ONLY and is
therefore safe where absence-based reconciliation is not: a `dead` delivery is
durable shared state proving no generation owns the reservation. It settles only
when the job is confirmed absent or identity-mismatched (an identity-matched job
still owns the run), defers on an unknown job lookup, on an in-flight abort, and
on an in-flight resume hand-off, ignores legacy reservations with no deliveryKey,
and applies a short grace so an accepted delivery still creating its generation is
never settled mid-handoff. Auto-disable policy is deliberately left to the armed
engine; this path records the failure and frees the slot.

Deliberately does NOT touch api/server/experimental.js — the clustered entrypoint
already starts this sweep, so the convergence arrives through the existing
initializer and the shared-file footprint stays as-is.

Tests: settles a dead delivery as error under an explicitly UNSAFE topology,
leaves live deliveries alone, never settles under an identity-matched running
generation (delivery is not even consulted), defers an in-flight abort, and
ignores a reservation with no deliveryKey.

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

* fix(stream): refresh host-action retention on each retry attempt

Codex finding: unacknowledged terminal host-action evidence was capped at the
24h pause TTL, so a host dependency (Mongo) unreachable for longer than that let
the Redis key — and its pending marker — expire with no generation-fenced
acknowledgement, stranding the ScheduleRun where no reconciler is armed.

Measure retention from the LAST retry rather than from the terminal transition:
enumerating a pending host action IS the retry attempt, so both stores refresh
its retention as they hand it to the hook (Redis re-EXPIREs the job key; the
in-memory store stamps terminalHostActionRefreshedAt and bounds from it). Evidence
therefore survives as long as some replica is still actively retrying, while a
deployment that stops sweeping entirely still lets it age out — so this does not
reintroduce the unbounded leak the cap existed to prevent.

Test: after a failed hook, a later cleanup pass keeps the marker pending and moves
its retention basis forward.

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

* fix(schedules): defer settlement when the Stop barrier times out

Codex finding: waitForStopPersistence returned after its 5s poll budget even when
the Stop was still fresh and unacknowledged, and recordScheduleOutcome then went
straight on to terminalize the run — releasing its capacity, deletion, and erasure
barriers while beforePublish may still have been writing. Slow checkpoint cleanup
is indistinguishable from a dead route on that signal, so the timeout was being
treated as if the barrier had been satisfied.

The poll budget now means "undecided", not "clear". On timeout with a fresh,
unacknowledged Stop the barrier DEFERS: recordScheduleOutcome returns false
without recording, leaving the run active/preserved. Settlement then happens
either when the route acknowledges, or once the existing stale-owner cutoff
(ABORT_OWNER_PRESUMED_ALIVE_MS) authorizes a later attempt — which the loop
already treats as clear-to-settle. Callers with durable retry (the approval-expiry
host action, reconciliation) re-drive it, so a deferral converges rather than
stranding the run.

Test: a fresh Stop that never acknowledges within the budget reports not-settled
and records no outcome.

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

* fix(schedules): require definite delivery failure, extract its message, converge deferred Stops

Third Codex round. Three findings in code from this closeout, plus one pre-existing
P1 that is a one-line operator-safety fix.

P1 — dead-delivery settlement demanded too little evidence. `dead` does not prove a
request was rejected: the trigger host marks response timeouts and invalid success
responses `certainty: 'ambiguous'`, and the engine dead-letters those once retries
are exhausted. The erasure sweep treated every dead letter as positive evidence, so
an ambiguous one sitting over a generation a peer had accepted could terminalize the
run and release its capacity mid-flight. It now settles only on a DEFINITE rejection,
unless job absence is deployment-authoritative (safe topology), where the
confirmed-absent job is itself the evidence.

P1 — `lastError` is an `AgentTriggerDeliveryFailure` object, not a string. A
duplicated local interface declared it `string` (against CLAUDE.md's no-duplicate-
types rule), so both the sweep and the engine reconciler passed the object into the
String-typed run/schedule `error` fields; Mongoose would reject the cast, the per-row
catch would swallow it, and the run would keep its global capacity slot. The dep type
now reuses the canonical `AgentTriggerDeliveryFailure` and both call sites pass
`.message`. Re-typing immediately surfaced a stale test that had asserted a string.

P1 (pre-existing) — the base-config global stop is honored in `getLimits` via
`isRuntimeDisabled` rather than a literal `=== false`. The stop has two shapes, and
deepMerge turns base `{ use: false }` plus a principal override of `true` into
`{ use: true }`, so the literal check reported the feature enabled and Run Now
dispatched straight through fireSchedule, bypassing the operator's emergency stop.
Now the same predicate the engine gate already uses.

P2 — a Stop whose settlement DEFERRED past the poll budget had no convergence path
where no reconciler is armed. `acknowledgeScheduledStopPersistence` now optionally
re-drives the terminal outcome once the barrier clears; `recordRunOutcome` is
match-guarded and idempotent, so an owner that already settled makes it a no-op. The
abort route passes it for a running generation; a paused job still settles explicitly.

Tests: ambiguous dead letters refused under unsafe topology but settled when absence
is authoritative, definite rejections settled either way, the failure message carried
through, and the abort route's re-drive present for running / absent for paused.

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

* fix(schedules): converge terminal runs in clustered workers, retry suspension restore

Fourth Codex round on 74f7af8. Both findings are in code from this closeout.

P2 - a clustered worker never settled a run whose generation finished but whose
outcome write failed. `recordScheduleOutcome` retries three times and then returns
false; the owner honors that by PRESERVING the terminal job as the only surviving
evidence, and the armed engine's reconciler replays exactly that. The clustered
entrypoint arms no engine, and its sweep only covered deleting schedules
(settleAbandonedRuns) and dead deliveries - an identity-matched job was skipped
outright. So an ordinary live schedule's run stayed `started`, held its GLOBAL
capacity slot, and kept a preserved job that carries no `completedAt` and is
therefore invisible to the store's finished-job sweep: both leaked until store
expiry.

The live-schedule pass now also converges from a retained terminal job, mirroring
the reconciler branch it stands in for: honor the owner's stamped outcome over the
generic status (so a balance refusal still walks its streak rather than resetting
it), clear the reserved conversationId when the generation never emitted its
created event, and delete the retained job only AFTER the outcome write is durable.

This stays positive-evidence-only and safe in every topology. Presence, not
absence, is the evidence: an identity-matched job is authoritative wherever it is
observed - a shared store shows the real generation, a process-local store can
only be showing this process's own - which is why it needs no
canInferOwnerDeathFromMissingJob fence, unlike the absence-based paths. The
in-flight abort/resume fences still defer, so an `aborted` job cannot settle a run
whose owner may still be persisting. Both cases now share one pass over one window
rather than two, and `retainedOutcome` moved to types.ts so the sweep reuses the
engine's mapping instead of duplicating it (and stays independent of the engine).

P2 - a failed restore stranded a live account's schedules. Cancelling an account
deletion restores the suspended rows while the deletion fence is still armed and
then releases that fence; nothing re-drives the restore afterwards, so one
transient write failure left the user with silently disabled, next-run-less
schedules. The restore is now retried at the single choke point both the HTTP
controller and the CLI share. Retrying is safe because each attempt re-reads only
the rows STILL carrying the token: a partially-applied unordered write converges
on exactly the stragglers, and a fully-applied one finds nothing.

The fence is still released when every attempt fails, deliberately: retaining it
would refuse the live account's schedule writes AND make beginAgentTriggerUserDeletion
report `in_progress` forever, blocking the retry that is the convergence path (a
later attempt adopts this snapshot, so its cancel restores these exact rows). Both
callers now log the user id and suspension token so the state stays recoverable by
hand if that never happens.

Tests: retained terminal job settled and its evidence released, stamped outcome
preferred over the generic status, stamped failure reason carried, reserved
conversationId cleared for a never-created conversation, identity-mismatched
terminal job ignored, abort-in-flight deferred; restore retried past a transient
write failure and converging on a partially applied restore.

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

* fix(schedules): converge unprojected pauses and release replayed bookkeeping jobs

Two of the four findings I flagged as pre-existing to my closeout commits. Both are
in code this PR introduces, so merging would have shipped them; both are the same
capacity/evidence-leak class the last four rounds have been closing. The remaining
two (slotless rows escaping the per-user cap, and the non-rotating `started`
reconciliation bucket) are genuinely latent and deliberately left alone.

An UNPROJECTED PAUSE held a global capacity slot forever. The pause projection is
what moves a run row off `started`; `recordScheduleOutcome` already retries it, but
the request controller discarded the result, so three failed attempts were dropped
silently. The armed engine's reconciler replays that state, but the clustered sweep
did not: a paused job is not terminal, so the retained-job path returned without
settling, and the dead-delivery path never inspects an identity-matched job at all.
The row stayed `started` with no cutoff that would ever clear it.

The call site now surfaces the failure, and the sweep converges it, mirroring the
reconciler's pause branch: project `requires_action` (which frees the slot) but do
NOT release the job's evidence — unlike a terminal job it is still live, awaiting an
approval. The resume hand-off fence still defers, so re-projecting cannot release a
slot a continuation just claimed.

The BOOKKEEPING REPLAY pass leaked its retained job. A run reaches that pass only
because its owner crashed before bookkeeping — which is also before it could release
the job it retained for exactly this recovery. The active-run pass clears its own;
this one never did, and a preserved job is deliberately kept WITHOUT `completedAt`
so the store's finished-job sweep cannot reap it early, so nothing else ever would.
It now clears after `finalizeBookkeeping` succeeds — identity-guarded, a no-op when
no job is retained, and skipped entirely when the replay itself failed, since the
retained job is the only surviving evidence in that case.

Tests: pause projected and the slot freed while the live job's evidence is kept,
paused job deferred during a resume hand-off, retained job released once replayed
bookkeeping is durable, and retained job kept when the replay fails.

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

* fix(schedules): fence the clustered pause replay against a concurrent resume claim

Codex round 5, on code I added in f735341. The finding is correct and the exposure
is one I introduced.

The pause replay lives in a sweep that runs in EVERY clustered replica, so several
sweepers can observe the same unprojected pause. Its guards were all derived from an
in-memory row SNAPSHOT: hasResumeHandoffInFlight reads the snapshot's
resumeClaimedAt, and recordRunOutcome's pause branch matched any row currently in
`started`/`requires_action` with no fence of its own. The race: sweeper A projects
the pause and frees the slot; the owner's approval then claims a fresh one
(markRunResumeClaimed takes the row to `started` WITH resumeClaimedAt in one write);
sweeper B, still holding the pre-projection snapshot, passes its hand-off check and
replays — `$unset: { capacitySlot, resumeClaimedAt }` under a continuation that is
already running. The run reverts to `requires_action` while its generation proceeds
outside global capacity.

The engine's reconciler makes the same call and has the same snapshot-derived guard,
but v1 arms exactly one engine, so it has no concurrent racer; the sweep is the first
thing to run this transition in parallel. Left the engine alone rather than widening
the change: its `requires_action` re-affirmation is deliberate and single-writer.

Fixed where the race is, in the write itself. `recordRunOutcome` takes an optional
`requireNoResumeClaim`, which adds `resumeClaimedAt: { $exists: false }` to the pause
filter, and the sweep sets it. Because the stamp is written in the SAME update that
moves the row to `started`, its absence is atomic proof no resume owns the row. The
flag is deliberately NOT set by the generation owner: its own re-pause legitimately
clears the stamp as the hand-off's completion signal.

The fence cannot block the recovery it exists to enable: markRunResumeClaimed only
matches `requires_action`, so a genuinely stuck `started` row can carry no resume
claim — it is in fact blocking its own approval until this replay frees it.

Tests: a replay from a stale snapshot leaves a resume-claimed row's status, slot, and
claim stamp intact, while a stuck row with no claim still recovers; and the sweep is
asserted to send the fence.

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

* fix(schedules): fence stale resume claims by age, not existence

Codex round 6, on the fence I added in ee00c60. Correct again, and the defect is
the mirror image of the one it fixed.

The fence was an EXISTENCE check (`resumeClaimedAt: { $exists: false }`) while its
caller's guard is a FRESHNESS check (hasResumeHandoffInFlight, bounded by
RESUME_HANDOFF_STALE_MS). They agree while a claim is fresh and disagree once it is
abandoned: a worker that dies after markRunResumeClaimed takes the row to `started`
and stamps resumeClaimedAt — but before the continuation resumes or
releaseRunResumeClaim rolls it back — leaves the stamp set forever. Past the bound
the sweep correctly stops deferring and tries to recover the row, but the write
rejected it purely because the field still existed. The row stayed `started` holding
its global capacity slot, and its approval was unresumable for good, since
markRunResumeClaimed only matches `requires_action`. That is exactly the stuck state
this replay exists to clear, so the fence had reintroduced it for the crashed-resume
case.

`requireNoResumeClaim: boolean` becomes `resumeClaimStaleBefore: Date`, and the
filter matches a row with no claim OR a claim older than that cutoff. The sweep
passes the SAME bound its in-flight check uses, so the two can no longer disagree.
A genuinely racing claim is by construction fresh — it is created after the sweeper's
snapshot — so the race from round 5 stays closed.

Tests: a row whose resume claim was abandoned by a dead worker now recovers (status,
slot and stamp all cleared), alongside the existing two — a fresh claim still repels
a stale-snapshot replay, and an unclaimed stuck row still recovers.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 11:51:30 -04:00
Marco Beretta
bf3fb17b58
fix: Keep Portaled Dropdown Menus Clickable Inside Modal Dialogs (#15026)
* fix: keep portaled dropdown menus clickable inside modal dialogs

Modal OGDialog layers set pointer-events: none on body and re-enable it
only on their own content. A portaled DropdownPopup menu is a body-level
sibling of those layers, so it inherited pointer-events: none: hover
never reached the items and every click passed through to nothing, which
Ariakit treated as an outside interaction and closed the menu.

This made the Agent File Search and File Context upload menus dead when
SharePoint was enabled, since that flag is what switches them from a
plain in-dialog button to the portaled menu.

Restore pointer-events: auto on the menu element so it stays clickable
regardless of the surrounding modal layers.

Fixes #14487

* fix: sort imports in DropdownPopup spec
2026-08-20 11:20:42 -04:00
Marco Beretta
16e4d14191
refactor: Presets, Skills Motion and Model Selector Polish (#14953)
* refactor: presets, skills motion and model selector polish

Four surfaces that had drifted from the rest of the app, plus the CI
fragility that surfaced while getting them green.

Two were functional bugs rather than styling:

Keyboard focus was invisible in the model selector. The highlight rule
existed and the background was painted, but it used surface-secondary and
the menu sits on bg-presentation, which resolve to the same value in dark
and to within 3/255 in light, so only the thin indicator bar ever showed.
Keyboard focus now uses the same surface a pointer gets.

Importing a malformed preset raised com_ui_upload_invalid, which talks
about image size limits, and FileUpload's JSON.parse had nothing catching
it at that call site. The overflow menu owns the input and reports the
existing preset import error instead.

The rest is polish: preset surfaces use the theme radius roles rather than
raw values; the edit dialog stops nesting a fixed 350px scroll box inside
an already scrolling dialog and pins its title and actions, with the
endpoint picker moved to ControlCombobox and kept out of any clipping
ancestor; Clear all and Import move into a three-dots menu matching the
conversation row; the Skills sections and pinned chats adopt the Collapse
that Projects already used; the rendered/source toggle slides between
states, is extracted rather than duplicated, and gains the accessible name
and RTL mirroring it lacked; the header toggle loses its fill and the
mobile new chat button hides when you are already in a new chat.

The CI changes are unrelated to the UI but blocked it: the MCP and Redis
cache jobs installed Redis with a bare apt-get and lost a race against the
runner's own apt-daily work, failing four times and once hanging for 30
minutes. They now stop that background work and wait for the lock.
DPkg::Lock::Timeout alone does not help, since it covers the dpkg frontend
lock and not the lists lock.

* refactor: move the section label appearance into the Label primitive

The preset dialog reached into the agent panel's private `Advanced/ui` for
its field eyebrow, so an agent-only refactor could change the dialog.

Give the shared `Label` a `section` variant and export the recipe for the
agent id row, which heads its value on a span and must not inherit the
label's block layout. Each variant carries its own size, leading and color:
the recipe output reaches that span unmerged, and a font size declared after
`leading-none` drops it.

* fix: derive the mobile new chat action from the route

The context conversation still holds the previous chat for a render after a
history or link navigation, a lag ChatView already guards against, so the
action could show on /c/new or hide while an existing chat loaded.

* style: sort imports in the touched files

* fix: return focus to the menu item after the clear dialog

The dialog is controlled and has no trigger, so Radix restored focus to
whatever held it when the content mounted, the menu's own focus trap, and a
keyboard user was left on the document. The menu stays open behind the
dialog, so the invoking item is still there to take focus back.

* fix: fall back to the trigger when clearing removes the invoking item

Confirming empties the presets optimistically, so React commits the removed
menu item together with the dialog close and the saved invoker is already
disconnected when focus is handed back.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-19 15:49:47 -04:00
Danny Avila
b91691937e
🙋 fix: Free the Composer When a Question Pause Collapses (#15011)
* 🙋 fix: Free the Composer When a Question Pause Collapses

Collapsing a live `ask_user_question` left the user with nothing to do.
A batch of questions disables the composer, the send button, and the stop
button for as long as the pause is active — and `collapse` deliberately
keeps it active, while hiding the popover that carried the only dismiss.
After the chevron there was no way to type, send, or stop the run short
of reloading the page.

Split the composer's role out of `active`: `composerAnswers` (a single
question, answered IN the composer) and `composerLocked` (a batch,
answered in its own card — and only while the popover is up). Collapsing
a batch now hands the composer back to the thread; the stop button
follows `composerAnswers`, so a paused run stays stoppable.

Both collapsed cards also carry the popover's ×, so dismiss survives the
handover, and `submitText` declines a batch's composer text instead of
claiming it — the old `return true` reported success and dropped
whatever was staged when the pause began.

Contrast, per feedback that the questions were hard to read: the answer
options, the answer textarea, and the digit chips all drew their edge
from `border-light`, which measures 1.20:1 against the panel (WCAG
1.4.11 wants 3:1 for a UI component boundary) — a column of choices read
as flat text. Adds a `choice` Button variant carrying its own fill and a
`border-xheavy` edge (5.49:1 dark / 6.54:1 light), at `font-normal` so
the question above stays the heading, and replaces the single-question
popover's hardcoded `bg-white`/`dark:bg-gray-700` with the semantic
surface role it should have been using.

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

* 🧹 refactor: Render Popover Answers Through the Choice Variant

The popover's option rows re-stated the shared `choice` variant's border,
fill, weight, and hover on a raw `<button>` — the same answer control as
the cards', so a later fix to the variant would have drifted the live
popover away from them.

Renders `Button variant="choice"` instead, keeping only what the popover
actually owns: the full-width row layout and the keyboard highlight.
Locked rows now take the primitive's `disabled:` styling rather than a
local `cursor-not-allowed opacity-60`, matching the cards.
-----
2026-08-19 14:21:20 -04:00
Marco Beretta
7480e93181
🌐 fix: Scrollable Language Dropdown in Shared Chat Settings (#14954)
* fix: make the shared chat language dropdown scrollable and use available height

The language dropdown in the shared chat settings dialog could not be
scrolled with the wheel and was capped at 256px, so most of the language
list was unreachable.

Radix wraps the dialog overlay in RemoveScroll with its shards limited to
DialogContent, so wheel events over a popover portaled to document.body
were cancelled. That same portal placement also left the popover inside
Radix's aria-hidden treatment, hiding the whole option list from assistive
technology. Render the popover inside the dialog and let that dialog's
content overflow so the popover is not clipped by it.

Drop the hardcoded max-height so the popover uses the available height
reported by the positioner. This also restores flipping, because the
positioner can now see that the natural height overflows and place the
popover above the trigger when there is more room there.

Remove declarations that never took effect: max-h-[80vh] and
overflow-y-auto on the popover, both shadowed by .popover-ui later in the
same stylesheet, and the --anchor-max-height and --anchor-max-width custom
properties, which nothing reads.

Move the theme and language selectors into their own directory so the
public share page no longer imports through the Nav settings tabs.

* chore: drop the redundant nested winston entry from the lockfile

packages/data-schemas declares winston as a peer dependency of ^3.17.0,
which the root winston 3.19.0 already satisfies, so npm deduped the
nested 3.17.0 copy.

* refactor: give Dropdown separate wrapper, trigger and popover class props

className was spread onto three elements at once: the positioning wrapper,
the trigger button and the popover. A caller styling the trigger silently
restyled the popover as well, and because className was merged after
sizeClasses it also beat the popover's own sizing. LangfuseConnection asked
for a popover the width of its anchor and got a full width one instead.

className now applies to the wrapper only, triggerClassName styles the
trigger and sizeClasses continues to style the popover. Call sites that
relied on the old spread pass the class to the part that needs it, so the
rendered result is unchanged apart from the LangfuseConnection width.

Also add portalElement so a caller can render the popover into a specific
container rather than document.body.

* fix: align the packaged popover radius with the app stylesheet

.popover-ui is declared both in the component's own stylesheet and in the
app's, and the two had drifted: the packaged copy used a 1rem radius while
the app used 0.7rem. The app copy wins inside LibreChat, so consumers of
@librechat/client saw a different corner radius from the app itself.

* fix: keep the shared chat settings dialog scrollable

The dialog content was made overflow visible so the language popover would
not be clipped, which meant the dialog itself could no longer scroll. If it
ever grew past the viewport its content would have been unreachable.

Move the scroll onto an inner region and portal the popover into the dialog
content, outside that region. The popover still sits inside DialogContent,
so it stays within the scroll lock shard and out of the aria-hidden subtree,
while the rows above it can scroll on their own.

* style: format the locales README

Applies the repository Prettier style, which the file did not satisfy.
Formatting only, no content changes.

* chore: remove the unused DropdownNoState component

The file defined a HeadlessUI based dropdown that nothing imported. It was
absent from the package barrels and from the generated type declarations, so
it was never part of the published API and no consumer can be relying on it.

It carried the same defect the Ariakit Dropdown just had, spreading className
onto the wrapper, the trigger and the popover, so deleting it is preferable
to fixing code that never runs.

* fix: declare the dependencies packages/client imports

InputNumber imports the ValueType type from @rc-component/mini-decimal and
the generated declarations re-export that import, but the package never
declared it. It resolved only because npm hoists it as a transitive
dependency of rc-input-number, so a consumer on a strict or nested layout
would fail to resolve the type. Declare it as a peer alongside the other
externals, using the same range rc-input-number asks for.

The theme test requires tailwindcss directly, so add it to devDependencies
rather than relying on hoisting there too.

Also mark the ValueType import as a type import, matching the convention
used elsewhere.

* style: group the ValueType import with the package imports

Type-only imports belong before local imports, as in Avatar.tsx.
2026-08-17 22:42:28 -04:00
Danny Avila
6eb2249620
📱 perf: Instant Mobile View Switching + Uniform Sidebar Toggle (#14913)
*  perf: Stabilize the Assistants Map Context Value

*  perf: Start the Mobile Drawer Slide Before the State-Flip Commit

* 📱 style: Mirror the Header Sidebar Toggle in the Mobile Drawer

* 🧷 fix: Apply Reduced-Motion Flips Synchronously, Drop Stale Deferred Flips

* 🎨 refactor: Promote the Sidebar Toggle Look to a Button Variant

* 🧷 fix: Drive Focus and the Release Deadline From the Commit, Not Timers

* 🚪 fix: Route Every Sidebar Mutation Through the Animated Toggle

* 🛝 fix: Carry Navigation and Focus Past the Slide, Toggle the Latest Intent

* 🆕 fix: Slide Before the New-Chat Reset, Drop Superseded Deferred Flips
2026-08-17 01:28:42 -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
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
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
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
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
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
Danny Avila
d89b11d34d
🎛️ feat: Adopt Composer Density Tokens (#14730) 2026-08-10 22:57:33 -04:00
Marco Beretta
f7d9f36922
🎨 feat: Refine Client Colors and Sharing Dialogs (#14734)
* Refine client colors and settings interactions

* Align dark dialog theme tokens

* Preserve custom hover themes and badge contrast

* feat: redesign sharing dialogs

* fix: preserve theme compatibility and role menus

* fix: address review findings and static checks
2026-08-10 22:43:57 -04:00
Danny Avila
c3a429ddcd
🎨 feat: Add Versioned Theme Foundation (#14709)
* 🎨 feat: Add Versioned Theme Foundation

* 🧩 fix: Keep Theme-Aware Chip Actions Consistent

* 🎛️ fix: Preserve Default Theme Geometry

* 🪪 fix: Keep Theme Identity in Sync

* 🧭 docs: Define Theme Styling Policy

* 🧹 chore: Sort Theme Imports

* 🧵 fix: Preserve Theme Compatibility Contracts

* 🛡️ fix: Harden Theme Compatibility Boundaries

* 🧵 fix: Publish Theme Appearance Preset

* 🐳 fix: Include Theme Preset in Docker Build

* 🪢 fix: Preserve Legacy Theme Compatibility

* 🧭 fix: Harden Theme Lifecycle Boundaries

* 🧱 fix: Align Theme Appearance Defaults

* 🧬 fix: Record Persisted Theme Provenance

* 🧭 fix: Preserve Theme Transition State

* 🧷 fix: Preserve Legacy Theme Contracts
2026-08-10 13:41:03 -04:00
Marco Beretta
152dcf4721
🔗 feat: Shared Conversation Badge and Stable Share Links (#14712)
* feat: improve shared conversation links

* test: Cover Shared Link Lifecycle

* test: Cover Shared File Snapshots

* fix: address review findings on shared links

Stop double-decoding the conversation search term. Express already decodes
req.query, so the route's extra decodeURIComponent threw URIError on any term
containing a bare percent sign and mangled percent-escape-looking text. The
sidebar already sent the term raw, so this failed there too.

Advance a share's stored target to its branch tail when an update omits one.
Updating from the conversation list could not resolve the tail and reused the
stored target verbatim, silently republishing the same snapshot instead of the
turns added since.

Require revalidation on shared files. Updates now keep the shareId, so the file
URL no longer changes and a cached response could outlive a revoked share-files
choice; an ETag over the pinned snapshot fields keeps unchanged files on 304.

* fix: keep the shared badge across conversation cache replacements

isShared is derived per list request and absent from single-conversation
payloads, so rename, pin, and the SSE conversation updates dropped it when they
swapped a server response into the sidebar cache, hiding the badge until an
unrelated list refetch. Carry the cached value forward in updateConvoInAllQueries
so every replacing caller is covered, while an explicit value still wins.

* test: mock syncStaticTools in server boot specs

initializeMCPs now calls syncStaticTools when no MCP servers are configured, but the server boot specs stub ~/server/services/Config without it. Post-listen initialization threw, hit process.exit(1), and took the jest worker down until it exceeded the retry limit.

* fix: address codex findings on the shared DataTable and file ETag

Keep the published DataTable export bound to the legacy component and ship the design-system table as VirtualizedDataTable, so external consumers of @librechat/client keep the props they compile against.

Fold the snapshot's stored location into the shared-file ETag, so a re-published output that keeps its size and revision but moves its object no longer revalidates to a stale 304.

Auto-fill the table when a first page is too short to overflow its container, since pagination is otherwise only reachable through the scroll handler.

* fix: re-scope share grants before publishing and retry stalled auto-fill

Move the shared-link ACL expiration write ahead of the content update. The shareId survives an update, so a failed ACL write after the write-through left the new messages and file snapshot readable at the same URL while the owner saw a 500.

Retry a rejected auto-fill fetch up to three times: an unscrollable table has no scroll event to fall back on, and the sentinel alone would strand it on the first page.

* fix: follow regenerated branches and pin forks to the payload they saw

advanceTargetToBranchTail only walked descendants, so a target replaced by a regeneration (a sibling, not a child) left the update parked on the obsolete branch and published none of the turns that followed. A childless target now hops once to the newest sibling the conversation continued under.

A shareId survives an update, so an owner republishing between a viewer's load and their Continue click would resolve targetMessageIndex against different messages. The fork request now carries the payload's updatedAt and is rejected with 409 when it no longer matches; the viewer gets the current version pulled in and can retry.

* fix: keep table sorting and legacy backfills from breaking share flows

Restore the union formatting a local lint-staged prettier collapsed in data-provider types, which broke the CI lint run.

Header clicks now toggle direction instead of cycling through an unsorted state, which the controlled tables translated straight back into the default and made one direction unreachable.

Re-arm the auto-fill guard on a sort change: a re-sorted first page arrives with the same row count, and the guard would otherwise suppress paging on a container that still cannot scroll.

Lazy fileSnapshots backfills no longer touch updatedAt. That timestamp is the revision a viewer's fork is validated against, so a legacy share's first read would have made the Continue click that followed it fail with a 409.

* fix: break pagination ties by id and reset share state per conversation

Both list cursors marked a page boundary with values that repeat: conversations by (sort field, updatedAt) and shared links by the sort field alone. Imported chats share a title and a timestamp, so every row tied with the boundary was skipped. Both now carry the boundary row's _id and sort by it last, and the shared-links cursor is an opaque composite the route still validates before querying.

The share dialog outlives a switch between conversations, so a link with files disabled left the next conversation's dialog showing the switch off and quietly published without files. The stored choice now falls back to the enabled default, and a stale link no longer sits in the copy field.

* fix: keep titleless shared links in the paginated list

A share has no title default, and BSON orders a missing title before every string, so encoding the boundary as an empty string skipped the remaining untitled links when sorting Name ascending and re-admitted all of them descending.

The cursor now carries the boundary's null rather than flattening it, and because $lt and $gt are type-bracketed against a string, descending adds an explicit clause for the untitled tail that a string comparison can never reach.

* style: sort share method imports

* fix: fail closed on orphaned share targets and guard snapshot backfills

getMessagesUpToTarget walked levels from the roots and returned everything it had accumulated when the target was never reached. An imported or partially deleted branch whose parent is missing therefore published the whole conversation instead of the selected branch; the walk now returns nothing unless it actually reaches the target.

A lazy backfill wrote fileSnapshots unconditionally, so a viewer's first read of a legacy link could land after a republish and restore the snapshot it replaced, re-authorizing the stable URL of a file the owner had just removed. The write is now conditional on the link still having no snapshot, and the stored one wins any race.

Regenerating a message above the shared tail leaves the whole stored branch childless, so the target walk now climbs to the closest ancestor the conversation continued under instead of stopping at the stored tail's own siblings.

Changing the search or sort also returns the table's viewport to the top, since the query holds the previous rows while it refetches.

* fix: page through titleless rows on both sides of the cursor

The route validator still required a string primary, so the composite cursor the data layer issues for a titleless boundary came back as a 400 and the shared-links table stopped at that page.

Conversations had the same type-bracketing gap the shared links just closed: a name-sorted page could not reach conversations with no title, since a comparison against a string never matches a missing field. The cursor now carries the null and the filter spells out the titleless clauses for both directions.

* fix: keep the share badge read-only and refresh rows on cell changes

ensureLinkPermissions re-granted the owner ACL entry on every call, so rendering the header's shared badge turned ordinary navigation into a permission write. It now checks for the grant first and only migrates a link that still lacks one.

A fork carrying a positional target but no revision falls back to the whole share, since nothing proves which payload the index was counted against.

The memoized table row compared row data and selection only, so a cell rendering external state (the archived list's pending Restore, for one) kept its stale rendering until the row object itself moved; rows now also compare a marker that moves with the column definitions.

* fix: keep the shared badge honest when a delete fails or a link remains

A failed delete left the conversation looking unshared: the optimistic snapshot covered only the shared-link queries, not the conversation caches the badge reads. The cleared conversations are now restored with the rest.

A conversation can hold one link per target message, so clearing the badge on delete is a guess. The conversation list is invalidated once the mutation settles, letting the server decide from the links that are actually left.

* fix: refetch every cached conversation page after deleting a link

The invalidation was pinned to page zero, so a conversation cached further down the sidebar kept the badge the optimistic update had already cleared even when another targeted link survived.

* fix: treat a failed page fetch as a failed auto-fill

React Query resolves fetchNextPage with an error result instead of rejecting, so the rejection handler never ran: the guard stayed armed on the unchanged row count and an unscrollable table could never reach the next page.

* refactor: move the share request helpers into the typed backend

Cursor validation, page-size clamping and the shared-file cache validator were plain backend logic sitting in the legacy JS route. They now live in packages/api with their own tests, and the route keeps only the Express-side wiring: reading query params, mapping domain error codes to status codes, and writing the response.

Also carries the requested file choice into the cache entry the create and update mutations synthesize, since the response never echoes it and the dialog reads a resolved entry with no choice as the enabled default.

* fix: hold auto-fill while the replacement page is in flight

A search or sort swap keeps the previous rows and hasNextPage on screen while the new first page loads, so the re-armed auto-fill asked for page two against a query that was still fetching its first. An infinite query runs one fetch at a time, so that request could cancel or interfere with the one already out. Both tables now pass their fetching state and the guard waits for it.

* fix: stop advertising links a deployment no longer serves

The sidebar badge rendered from the derived flag alone, so a deployment that turned shared links off still told owners a link was live while the public routes were unregistered.

The share dialog called the link a snapshot, but the payload populates the referenced message documents on every request: an edit to an already-shared message is visible immediately, and Update only adds newly referenced ones. The copy now says that.

Scroll pagination inspects a resolved error the way auto-fill already does, since React Query reports a failed page that way instead of rejecting.

* a11y: gate the shared conversation label on the feature flag

The icon stopped rendering when a deployment turns shared links off, but the row still announced the conversation as shared to screen readers. Both now read the same condition.

* fix: accept long title cursors and stop badge work the feature disables

The cursor cap was tight enough that a Name-sorted page ending on a long title produced a nextCursor the next request rejected, stranding the rest of the list. It now sits well clear of anything the server can issue.

The conversation list skipped straight into the shared-link lookup even where ALLOW_SHARED_LINKS is off, paying a round trip on the sidebar's first page for a badge that is never rendered.

A regeneration is newer than what it replaced, so only a newer sibling counts: an older one that still has follow-ups is the branch the target was regenerated away from, and resuming there published turns the target had excluded.

* fix: hold scroll pagination while a replacement page loads

Resetting the viewport to the top after a search or sort change fires a scroll event, and the retained previous rows still report another page, so the handler asked for page two of a query that was still loading page one.

* fix: keep the legacy share migration ahead of the owner-grant shortcut

A legacy row keeps its marker until every grant it needs exists, so an owner grant on its own is not proof the migration finished. Reading the marker first means a half-migrated public link still gets its public grant, while a fully migrated one keeps the read-only settled path the badge lookup depends on.
2026-08-09 08:14:54 -04:00
Marco Beretta
92d4705f79
🧭 refactor: make the side panels behave the same way (#14695)
* style: unify chat input tool badge styling

Every tool badge repeated max-w-fit and its own hand-written checked-state
colour triplet. Move max-w-fit into CheckboxButton's base classes, where
tailwind-merge still lets a consumer override it, and collect the accent
colours into a single map so the palette lives in one place.

Artifacts repeated the amber triplet a second time on its dropdown button;
that now reads from the same map.

* feat: add feedback when resetting model parameters

The button did nothing visible on click, so with parameters already at
their defaults it looked broken. Spin the icon a full turn on press and
announce the change politely, matching the Agent Builder panel which
already announced but had no visual counterpart.

The animation replays on consecutive clicks via a reflow, and is gated
behind motion-reduce.

* fix: keep the prompt editor open when inserting a special variable

Opening the variables menu moved focus out of the textarea, whose blur
handler exits edit mode, so the prompt snapped back to its rendered
preview as if it had been saved.

Guard the blur against focus landing inside a menu, since Ariakit focuses
the menu itself on open, and hand the menu a finalFocus target so focus
returns to the textarea on close. Without the latter the editor stayed
open but unfocused, which quietly broke click-away-to-exit.

* feat: create prompts from a dialog instead of a dedicated page

Prompts now open a dialog from the sidebar, matching how skills and MCP
servers are created, and /prompts/new is gone. The dialog reuses the
existing form rather than duplicating it, with a flag to drop the
page-level chrome that has no place in a modal.

Three things the modal exposed:

- Radix locks pointer events on the body, so the portaled category and
  special-variable menus rendered but could not be clicked. They now
  render inline when hosted in a dialog, as SetKeyDialog already does.
- The floating labels notch out the page surface, which left a visible
  chip against the dialog background in dark mode. The surface is now
  passed in rather than hardcoded.
- Creating gave no indication anything was happening; the button now
  shows a spinner and blocks repeat submits.

Create buttons for both prompts and skills use the submit variant, since
both perform a write.

* style: match prompt action button sizes

The share button sat at 36px next to a 40px Use Prompt button in the
preview. Drop the size override so it takes the icon variant's default,
and bring its row-mates in the editor header along so that row stays
uniform.

* feat: load prompts by scrolling instead of paging

The query was already cursor-based; the nav hook was slicing it back into
one page at a time behind Prev/Next buttons. Flatten the loaded pages and
let the existing scroll hook fetch as the list nears its end.

useNavScrolling only fetched from a scroll event, so a first page that
did not overflow its container produced no event and the rest of the list
was unreachable. It now tops up until the list actually scrolls, which is
why zooming in used to 'fix' it.

* feat: pin panel admin settings and scroll only the panel content

Each side panel scrolled as a whole, so its filter row and toggles slid
away with the list and the scrollbar spanned the full height. Give every
panel a fixed header, a scrolling content region, and a footer that holds
the admin settings.

The skills panel gains the standard filter input in place of its title
and toggle-to-search icon; it also rendered admin settings twice, once
from the filter row and once from the accordion.

Memories drops its client-side paging, which only sliced already-loaded
data, in favour of scrolling the full list.

* fix: repair the skills create menu and icon-only dropdowns

The create menu was built on Dropdown, which is a select rather than an
action menu, and Dropdown applies its className to the popover as well as
the trigger. Sizing the trigger therefore shrank the menu itself to 36px
and clipped both entries. Rebuild it on DropdownPopup, which is what the
rest of the app uses for action menus.

Dropdown's icon-only trigger also kept its horizontal padding and laid the
icon out in a full-width flex row, leaving too little room so the icon
flex-shrank to roughly half its width. That affected every icon-only
consumer, including the prompts category filter.

* fix: correct the gap above the MCP server URL field

The fieldset grouping the connection sections carried display: contents,
which removes its box and with it the margin that space-y puts on it. The
first section inside sat flush against the description while every other
gap kept its 16px.

* refactor: unpin a favorite in one click

The row's overflow menu held a single Unpin entry, so opening it was pure
overhead. Show the unpin button directly instead.

Its hover surface matched the row's own hover colour exactly, so hovering
changed nothing; it now uses a surface that differs in both themes, with
a border carrying the contrast in light mode where the surfaces are close.

Adds the tests for unpinning, which had none.

* fix: stop prompt skeletons stacking on top of the loaded list

The groups were rendered outside the loading branch, so a refetch with
data already cached drew three skeletons above the existing rows instead
of leaving the list alone. The three states are now mutually exclusive.

* feat: add PanelContent to standardize side panel loading states

Each panel decided for itself whether to draw a spinner, a skeleton, or
nothing, and some replaced the whole panel rather than just the list.
PanelContent owns the scroll region and the loading/empty/content
decision so a panel cannot invent a fourth pattern.

It takes isLoading rather than isFetching on purpose: a refetch that
already has rows on screen should leave them alone.

* feat: give the side panels row-shaped loading skeletons

Each panel now loads with a skeleton built from the row it stands in for,
rather than a spinner or nothing: the memory card's key and token pill,
the MCP server's icon over name and description, the bookmark's icon and
count, the prompt card's block.

Memories previously replaced the entire panel while loading, so the
filter you had just typed into disappeared. The skeleton is now confined
to the content region and the header stays put.

Loading also moves out of the list components, which had each grown their
own copy of it, and into the shared PanelContent.

* feat: show a loading state in the bookmarks panel

Bookmarks had no loading state at all: it rendered straight into its
empty state while fetching, so it flashed 'no bookmarks' before the list
appeared. Thread isLoading through and give it the same header, scrolling
content and skeleton as the other panels.

* style: tighten the favorite row and unpin button

Even padding on the row, the unpin button sitting a little closer to the
edge, and no border until it is hovered.

* feat: scroll the bookmarks list instead of paging it

Bookmarks were already fetched in full, so the pager was slicing data
that was sitting in memory. Render the whole list and let it scroll, the
same as the other side panels.

It also removes a latent drag bug: rows were reordered by their index in
the unsliced array while the list rendered a page slice, so dragging on
any page past the first moved the wrong row.

* feat: load skills by scrolling instead of capping the list

The skills panel fetched a single page of 50 and never asked for more, so
a 51st skill was unreachable. Switch it to the cursor-paginated infinite
query that already existed alongside it and wire the shared scroll hook,
matching prompts and the other side panels.

The list and its rows only ever read summary fields, so they now take
TSkillSummary and the response no longer needs casting through unknown.

* fix: stop mocking real modules as virtual in specs

Seven specs mocked @librechat/client and librechat-data-provider with
`virtual: true`, which is for modules that do not exist on disk. These
do, so the flag keyed each mock to a path derived from the spec's own
directory rather than the module's resolved id. The component under test
resolves the real id, so whether it got the mock depended on the module
id cache of whichever worker picked the file up.

UploadSkillDialog was the one that bit: when the mock missed, the real
Radix dialog rendered and portaled its content to the body, so every
assertion reading from the render container failed with the input
"not rendered" while it sat in a portal a few nodes away.

* test: give the lazy bookmark chunk room to load

Waiting for BookmarkNav means waiting for babel to transform its whole
module graph on first require, which does not fit in waitFor's default
second when the transform cache is cold or the machine is busy. The
failure looked like a missed re-render but was just an import in flight.

* build: recycle jest workers before the OS kills them

Coverage maps accumulate for the life of a worker, so a full client run
pushes workers past a gigabyte and the OS kills one, failing whichever
suite it was holding at the time. Capping idle worker memory also cut
the wall clock, since the run no longer swaps.

* fix: give the dialog prompt labels a real backdrop

Floating labels notch out the surface behind them so the input's border
does not run through the text. The dialog variant asked for `bg-background`,
which no longer maps to anything and computes to transparent in both
themes, leaving the border visible through the label. `bg-surface-primary`
is what OGDialogContent actually paints.

* fix: resolve side panel review findings

Send the removed prompt create page to a tombstone route so a stale
/prompts/new cannot render a blank form or fetch the id "new".

Drive the list footer spinner from isFetchingNextPage alone; the old
showLoading flag was set on scroll and only cleared by a later scroll,
so it stuck on after the last page.

Retry the scroll auto-fill through a ResizeObserver: the fill bailed
whenever the panel had no layout yet and nothing asked again once it
got one. A collapsed sidebar keeps its panel mounted and laid out, so
gate fetching on the sidebar being expanded rather than draining the
catalog behind an invisible panel.

Gate the MCP admin footer on the admin role, matching the memories,
prompts and skills panels; the bordered bar rendered empty for
everyone else.

Replay the reset icon spin by remounting the icon. Toggling the class
list lost the animation to the re-render that setConversation causes.

Announce panel loading from a live region carrying its own text. The
skeleton rows and the spinner are both aria-hidden, so labelling the
region left nothing for a screen reader to read out.

Cover the scroll hook, the panel content primitive and the prompt
create dialog with unit tests, and point the prompts e2e spec at the
dialog rather than the deleted page.

* chore: remove unused translation keys

com_ui_pagination and com_ui_select_or_create_prompt lost their last
callers when the prompt list moved to infinite scroll and the empty
prompt preview was dropped. Only the English file is touched; the
other locales are generated externally.

* Fix nav pagination retry loop

* Fix prompt field IDs and skills pagination

* Fix prompt dropdown ARIA IDs

* test: stub syncStaticTools in the server bootstrap specs

initializeMCPs now calls syncStaticTools from services/Config when no MCP
servers are configured. Both bootstrap specs mock that module wholesale, so
the call threw, the post-listen handler ran process.exit(1), and the Jest
worker died four times over before the suite was reported as failing to run.
2026-08-08 23:15:46 -04:00
Marco Beretta
667d97d668
⛶ feat: add fullscreen artifact previews (#14585)
* feat: add fullscreen artifact previews

* fix: harden artifact fullscreen behavior

* fix: address artifact fullscreen review feedback

* test: use standalone Recoil type import

* fix: portal fullscreen artifact menus safely

* fix: raise fullscreen artifact menu portal

* fix: keep fullscreen artifact tooltips visible
2026-08-08 23:15:04 -04:00
Danny Avila
0db511fee8
fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming (#14677)
*  fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming

Fixes the unreadable composer placeholder and the edit pencil that appears on
hover mid-generation, plus the sibling token failures found while tracing the
root cause.

Placeholder: #13879 moved the composer from `dark:placeholder-white/60` to the
semantic `placeholder:text-text-tertiary`, but `--text-tertiary` was
`var(--gray-500)` in *both* themes, and #595959 is a dark gray. Dark mode fell
from 5.90:1 to 1.91:1. Fixed at the token (dark -> gray-400, 4.56:1) rather than
the call site: the token has 99 usages and was failing at 1.91-2.77:1 on every
dark surface. The .gizmo dark theme already uses a light gray (#999999) for the
same token, so only the default dark theme carried the inverted value.

Two more instances of the same "token never tuned per theme" bug:
- `--text-warning` was amber-500 in both themes: 2.15:1 in light across 13
  real warning strings. Now amber-700 (5.02:1).
- Light `status-{success,warning,error}` on their own `-subtle` fill measured
  3.58 / 3.07 / 4.41 -- the exact pairing Alert, Badge, Tag and Chip use for
  every status variant. Bumped to the 700 ramp (5.21 / 4.84 / 5.91). Solid
  `bg-status-*` is only used for dots, so nothing renders text on it.

Edit action: `hideEditButton` already covers `isSubmitting` and the button got
`isVisible={false}` -> `opacity-0`, but `group-hover:opacity-100` (0,2,0)
outranks bare `opacity-0` (0,1,0), so hovering the row revealed a disabled
pencil. The reveal classes are now gated on `isVisible`, with
`pointer-events-none` so the hidden button is inert.

Both token sources of truth (style.css and themes/*.ts) were updated and verified
in sync across all 67 tokens.

Tests: new HoverButtons spec covers both hover states; semanticTokens.spec.ts
gains a contrast guardrail over text tokens x surfaces and each status hue
against its subtle fill, verified to fail on the original values.
applyTheme.spec.ts now derives its expectation from the theme object instead of
pinning a hex, so retuning a hue no longer breaks an unrelated plumbing test.

* 🔤 style: Sort imports in HoverButtons spec

CI's changed-files import-order check flagged the new spec; the previous
commit bypassed the lint-staged hook that would have caught it.
2026-08-07 00:52:07 -04:00
Danny Avila
bd5c1ad05c
🧩 refactor: Shared UI Design System Tokens (#14670)
* feat: harden shared design system tokens

* fix: address design system review findings
2026-08-06 22:13:02 -04:00
Marco Beretta
4f5c9fec4f
🎨 refactor: adopt the @librechat/client design system (semantic color tokens + component migration) (#13879)
* refactor: unify Tailwind color tokens into a single source

Both the client SPA and @librechat/client Tailwind configs now consume one
createTailwindColors() map, eliminating config drift. Fixes the package-side
build along the way: shadcn tokens are wrapped in hsl(), the broken opacity
helper is removed, and text-destructive/border-destructive/switch-unchecked
plus the gray/green palettes are included.

* refactor: replace hardcoded colors in sidebar conversation list with tokens

Migrate the Conversations sidebar section to semantic tokens: focus rings to
ring-text-primary (keeps >=3:1 contrast in both modes; the mid-gray ring would
fail WCAG 1.4.11 on dark), the active-conversation indicator and hover-fade
gradient to surface/text tokens, and the pagination controls. Removes every
dark: color twin; no behavior change.

* feat: add semantic status-color tokens; migrate MCP status badge

Add a status-color layer (status-{success|info|warning|error|neutral} plus
-subtle variants) to style.css and the unified createTailwindColors map, with a
blue palette for the info hue. Migrate MCPStatusBadge (badges + dots) and
MCPCardActions to the new tokens, removing all hardcoded status colors and
dark: twins. Status colors are now themeable like the rest of the system.

* refactor: migrate status badges to semantic status-color tokens

Migrate the genuine status badges to the status-* tokens: MCPConfigDialog
connection pills (info/warning/neutral/error/success + dot), MemoryUsageBadge
usage levels, and DialogImage quality badge (also gains dark-mode support it
previously lacked). Removes hardcoded colors and dark: twins.

* feat: add Alert component and migrate alert banners to it

Add a reusable Alert component (@librechat/client) with error/success/warning/
info/neutral variants backed by the status-color tokens, default per-variant
icons, and role=alert. Migrate the duplicated colored-div banners to it:
Auth ErrorMessage, RequestPasswordReset success, and the identical error boxes
in ToolSelectDialog, AssistantToolsDialog, and MCPToolSelectDialog.

* refactor: migrate remaining alert banners and error states to tokens

Migrate the last banners to the Alert component: ResetPassword success,
MessageContent connection error, and MemoryInfo storage-full errors. Tokenize
the Agents ErrorDisplay error state in place (icon badge, headings, message,
retry button) since it's a full error state, not a compact callout. Also
tokenize ResetPassword field-validation errors to text-text-destructive
(fixes the low-contrast dark:text-red-900).

* refactor: tokenize SidePanel Memories/Parameters/Bookmarks colors

Delete-confirm buttons to surface-destructive tokens (MemoryCardActions,
BookmarkCardActions), drop redundant text-white on submit Buttons (the variant
already sets it), legacy preset button green hover/focus to submit tokens, and
slider hover borders to border-light. Leaves DynamicCheckbox dark overrides for
a separate pass against the Checkbox component.

* refactor: tokenize Settings danger/destructive buttons

Map the DangerButton, the Data tab destructive actions (RevokeKeys, ClearChats,
DeleteCache), and the DeleteAccount button from bg-red-*/bg-destructive to the
surface-destructive tokens.

* refactor: tokenize Chat file-upload table and upload status colors

Tokenize TemplateTable th/td/border classes (surface-primary, border-light,
text-primary/secondary) and FileUpload status colors (text-text-secondary,
text-text-destructive, text-status-success) plus the import button hover.

* fix: explicit type annotations on Alert for isolatedDeclarations

@librechat/client builds with tsdown --isolatedDeclarations, which requires
exported consts to have explicit type annotations (TS9010). Annotate
alertVariants and Alert to match the Button.tsx pattern.

* refactor: add soft status-border token layer for Alert and lighten dark status foregrounds

* refactor: tokenize Chat menus, popovers, and message surfaces

* refactor: tokenize Chat message content, tool output, and file UI colors

* refactor: add semantic link color token and migrate hyperlinks to it

* refactor: tokenize Files and Auth surfaces, text, borders, and CTAs

* refactor: add accent-primary brand token; tokenize Nav/Input/Prompts/Endpoints colors

* refactor: tokenize Auth brand-green accents, Skills, Sharing, Plugins, MCP colors

* refactor: tokenize OAuth, Share, ui, Bookmarks, Tools, Messages, Web, SharePoint colors

* refactor: final solid-color cleanup (brand-green accents, neutral grays, error text)

* refactor: migrate status callout banners to status-subtle/border tokens

* refactor: tokenize token-usage gauge, mic, and oauth countdown status colors

* refactor: replace shadcn color vocabulary with semantic tokens

Remove the shadcn/ui color tokens (background, foreground, card, popover,
muted, accent, secondary, destructive, input) and migrate every usage to
LibreChat semantic surface/text/border tokens.

Add surface-inverted/text-inverted for the neutral inverted CTA and
surface-fixed/text-fixed for controls that must not flip with the theme
(favicon chips, QR container, carousel arrows). New tokens are defined once
in style.css (light + dark), createTailwindColors, the theme types,
applyTheme and the default/dark theme objects so they stay overridable at
runtime.

Collapse paired dark: color variants into the dark-aware tokens and tokenize
the remaining raw palette and white/black utilities, mapping status colors to
the status-* tokens and legacy ring-black/ring-white focus rings to
ring-text-primary.

Retain the background, primary and ring tokens, which are still referenced by
the SidePanel/Agents and SidePanel/Builder panels (excluded from this pass).

* refactor: tokenize remaining status, neutral and message-text colors

Map the leftover semantic colors to tokens: skill error/dirty states and the
selected-version/selected-skill highlights move to status-warning/status-success,
the global indicator to status-success, and the markdown message text to
text-text-primary. Drop the redundant dark: overrides on the dynamic checkbox,
which the Checkbox primitive already handles.

What remains is intentional and stays raw: categorical color sets (category
icons, principal avatars, per-tool toggle accents), brand marks, the
WCAG-tuned toast severities, code/diagram surfaces, scrims, and text-white on
submit/destructive action surfaces.

* refactor: remove unused CSS rules, dead comments, and duplicate keyframes

Drop ~829 lines of dead styles across style.css (2992->2355) and
mobile.css (323->131): unreferenced classes (legacy token utilities,
orphaned animations, form/prose/scrollbar leftovers), commented-out
blocks, and duplicate/orphaned keyframes. Library-injected (hljs, sandpack,
codemirror, markdown language) and dynamically-applied (scroll-animation,
icon sizes) classes were retained.

* fix: resolve ESLint and frontend test failures

- Format with prettier (Alert, MCPStatusBadge, ApiKeys, Memory, etc.) after
  --no-verify commits skipped the hook
- Localize the 'Or' auth divider (com_auth_or) instead of a bare literal
- Drop dead InvocationModePicker imports in Skill forms; fix VerifyEmail
  unused arg + useEffect deps
- Revert out-of-scope color edits in legacy Files/VectorStore views that
  carried pre-existing untranslated-string lint debt
- Update Memory tests to assert status-* tokens (text-status-error,
  bg-status-error-subtle) instead of the old hardcoded red classes

* refactor: migrate theme tokens to RGB channels for opacity support

Convert semantic + palette CSS variable values in style.css from hex to bare
'R G B' channel triplets, and emit Tailwind colors as
rgb(var(--token) / <alpha-value>) via createTailwindColors. This makes opacity
modifiers (bg-surface-primary/50, bg-border-medium/60, etc.) resolve correctly
and remain dark-aware, fixing ~26 existing usages that previously fell back to a
hardcoded light hex.

- Wrap direct var(--token) color usages in CSS rules as rgb(var(--token))
  (style.css, Dropdown.css, Tooltip.css) and two inline component styles
- applyTheme writes bare triplets to match the new wrapping
- shadcn tokens (HSL) and the JS palette (hex) are unchanged

* fix: prettier formatting after dev rebase

* refactor(client): migrate low-risk primitives to @librechat/client

Swap raw <label>, <textarea>, and native title= tooltips for the
@librechat/client Label, Textarea, and TooltipAnchor components across
Agents, Endpoints settings, Export modal, Prompts, Sharing, and Memory
dialogs. Add localization keys (scroll, sibling navigation, none
selected, select var) for the remaining swap waves.

* refactor(client): migrate buttons, inputs and labels to @librechat/client

Swap raw <button>, <input> and <label> elements for the @librechat/client
Button, Input and Label components across Auth, Chat, Conversations,
Endpoints, Nav, Prompts, Skills, Tools and Web. Preserve bespoke geometry
and behavior via cn className merging, keep data-testid/aria wiring, and
localize previously hardcoded aria-labels. Skip swaps that would break
floating-label animations, tiny bespoke controls or inline-text links.
Add com_ui_reload_page key.

* refactor(client): migrate dialogs, toggles and remaining controls to @librechat/client

Swap behavioral controls for @librechat/client equivalents: HeadlessUI
and legacy dialogs to OGDialog, native checkbox/switch to Checkbox/Switch
(onCheckedChange), and remaining buttons/inputs across Chat, Skills,
Tools, Sharing, Memories and Settings. Convert applicable native title=
tooltips to TooltipAnchor and localize close/scroll aria-labels. Skip
swaps that would break floating-label animations or bespoke select
behavior. Update co-located test mocks to provide the newly-used Button
and cn dependencies.

* style(client): soften dropdown and settings search inputs

Remove the heavy focus ring on the settings search and the searchable
Dropdown's search input, replacing it with a subtle border-light. Make
the search field background inherit the dropdown surface so it matches in
both light and dark mode, and reduce the Dropdown trigger border from
medium to light.

* refactor(client): migrate Agent Builder and Tool Library to @librechat/client

Swap raw buttons, inputs, labels, textareas and native title tooltips for
the @librechat/client Button/Input/Label/Textarea/TooltipAnchor components
across the Agent Builder panel (SidePanel/Agents) and the Tools
marketplace. Remove heavy input focus rings in favor of subtle borders,
soften dropdown trigger borders, and convert stray shadcn/raw colors in
touched lines to semantic tokens. Localize the tool delete aria-label and
toast messages. Update co-located test mocks to provide the newly-used
Button component.

* fix(client): keep Input border static on pointer focus

The pointer-focus override in Field.css used border-color: var(--border-light),
which became an invalid value after the theme moved to RGB channel tokens and
was silently dropped, letting the border fall back to currentColor (text-primary)
on mouse focus. Wrap it in rgb() so mouse focus produces no border, ring, or
outline change; keyboard focus keeps its ring for accessibility.

* refactor(client): remove residual shadcn color tokens

The background/primary/primary-foreground/ring and unused chart-* tokens were
retained only for the then-unmigrated Agent Builder. With that panel migrated,
replace the last usages with LibreChat semantic tokens (ring-primary/ring-ring
-> ring-text-primary; bg-primary/text-primary-foreground -> bg-surface-inverted
/text-text-inverted; text-primary -> text-text-primary; bg-background ->
bg-surface-primary) and drop the token definitions from createTailwindColors,
applyTheme, the theme objects, types, and style.css.

* fix(client): address semantic theme review feedback

* fix(client): use boolean Monaco hover option

* fix(client): resolve CI validation failures

* test(client): update shared component mocks

* fix(client): expose status tokens to runtime themes and document channel format

Add the status, text-destructive and border-destructive families to IThemeRGB,
IThemeVariables, IThemeColors, mapTheme and the bundled light/dark themes so
ThemeProvider consumers can theme Alert and the status badges instead of falling
back to the stylesheet palette.

Update the theme README to document the channel-triplet contract that the RGB
migration introduced, since the previous examples used complete CSS colors that
now produce invalid declarations.

* test(e2e): use accessible message action locators

* fix(client): address theme env, dialog padding and locked button review feedback

Expose every IThemeRGB token through REACT_APP_THEME_* instead of the
hand-maintained subset that omitted the status, destructive, inverted and
fixed families.

Drop the padding OGDialogContent contributes to the Tool Library so the
header divider spans the panel again, and stop disabled:opacity-100 from
overriding the locked delete-account button's dimmed state.

* fix(client): read theme environment variables from the build-time env

getThemeFromEnv read process.env, which vite-plugin-node-polyfills replaces
with an empty shim in the browser, so every REACT_APP_THEME_* value was
dropped and the loader always returned undefined.

Read import.meta.env instead and register the REACT_APP_THEME_ prefix with
Vite so the values are inlined at build time. The env source is now a
parameter, which lets the tests cover the mapping without mutating globals.

* fix(client): replace Tailwind classes that no longer resolve

Several class names in the client and shared component package emit no CSS
rule at all: legacy token- names with no definition, Tailwind v1/v4 names,
and plain typos. They fail silently past typecheck and tests.

- text-md -> text-base (Tailwind has no md font size)
- text-grey-100, text-tertiary -> text-text-tertiary
- text-token-secondary -> text-text-secondary
- bg-token-surface-primary/tertiary, bg-token-main-surface-secondary and
  border-token-border-hover -> their semantic tokens
- bg-surface, bg-surface-50 -> bg-surface-primary
- bg-surface-primary-hover -> bg-surface-hover
- outline-hidden -> outline-none where focus styling already exists
- drop focus:shadow-outline, border-d-0 and the malformed
  ring-offset-ring-offset, which have no meaningful replacement

MemoryArtifacts keeps its default outline instead of gaining outline-none,
since that button has no other focus indicator. MentionItem drops its dead
background rather than adopting one, which would have matched its hover
colour and erased the hover affordance.

Localize the two literal strings the pre-commit lint flagged in the touched
files, reusing the existing com_ui_upload_image and com_ui_more_count keys.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-06 09:15:17 -04:00
Marco Beretta
26ba2c2954
️ a11y: improve keyboard operability, focus retention, and accessible naming (#14600)
* fix: improve accessibility with semantic HTML and keyboard support

* fix: preserve focus on attachments and stop CSS leaking into label text

Passing `Wrapper` to FileRow as an inline arrow made it a new component type on
every render, so React remounted the whole file row. A keyboard user who tabbed
to an attachment thumbnail lost focus to <body> the moment the upload settled.
Hoist the wrappers to module scope so their identity is stable.

BlinkAnimation rendered a <style> tag into the DOM; stylesheet text becomes part
of the ancestor's textContent and leaks raw CSS into label readouts. Move the
keyframes into the tailwind config, named logo-blink to avoid colliding with the
existing `blink` keyframes in style.css, and honour prefers-reduced-motion.

* fix: make preset row actions reachable by keyboard

The pin, edit and delete buttons on a preset row were hidden with `invisible`,
which sets visibility: hidden and removes them from the tab order entirely. The
`group-focus-within` variant meant to reveal them never fired, because nothing
inside the row ever receives DOM focus during keyboard navigation. Verified in a
browser: arrowing and tabbing through the presets menu skipped the row and the
buttons reported focusable: false, while hovering made them focusable.

Hide them with opacity instead, which keeps them in the tab order, and reveal on
focus as well as hover. At rest they still compute to opacity 0, so there is no
visual change.

* fix: harden a11y heading, Space activation, and preset hit targets

Gate the page heading on a title that matches the routed conversation so
stale Recoil state is not announced during navigation. Ignore key-repeat
on role=button TooltipAnchor activation while still blocking Space scroll.
Disable pointer events on transparent preset actions until hover or focus.

* fix: address a11y review follow-ups and eslint formatting

Use the shared layout test harness for ChatView heading tests, default
role=button TooltipAnchors into the tab order, ship spinner keyframes in
package CSS, and let native preset buttons handle activation once.
2026-08-05 13:03:30 -04:00
Danny Avila
ad0f72dede
🌀 ci: Deterministic Circular Dependency Checks (#14579)
* 🌀 ci: Deterministic Circular Dependency Checks

* 🌀 ci: Enforce Type-Level Edges in Circular Dependency Scan

* 🌀 ci: Materialize Import-Type Expression Edges in Cycle Scan

* 🌀 ci: Collect Inline Type-Only Specifier Edges in Cycle Scan
2026-08-01 14:43:26 -04:00
Danny Avila
6dae785e31
🌯 chore: Retire Rollup-Era devDependencies After tsdown Migration (#14496)
Removes 26 of the 32 Rollup-era devDependency declarations left behind when
these packages moved to tsdown, plus two stale config references and an
override that went inert in #14483.

- Drop all 8 from `packages/api`, all 8 from `packages/client` (including
  `concat-with-sourcemaps`), and all 10 from `packages/data-schemas`. None of
  their tsdown configs import anything from rollup, and none has a rollup
  script or config file.
- Keep all 6 in `packages/data-provider`. Five of them back the `rollup:api`
  script, which the "Circular dependency checks" CI job runs to surface
  rollup's circular-dependency warnings, and `@rollup/plugin-replace` is
  imported directly by that package's tsdown config.
- Drop `rollup.config.js` (exists nowhere in the repo) and
  `server-rollup.config.js` (real, but never read by the `build` task, so
  listing it only caused spurious cache invalidation) from `turbo.json`.
- Drop the `**/rollup.config.js` glob from `eslint.config.mjs`. It matches
  nothing, and never matched `server-rollup.config.js`.
- Drop the root `svgo` override, dead since #14483 removed
  `rollup-plugin-postcss`, the only thing that pulled svgo into the tree.
2026-07-28 22:08:52 -04:00
Danny Avila
9c95bf445f
🍂 chore: Prune Deprecated Packages From the Dependency Tree (#14483)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Removes three of the eight deprecation warnings emitted on `npm install`.

- Drop `@types/winston` from `packages/api` and `packages/data-provider`.
  The published tarball ships no type declarations at all, so `winston`'s own
  types were already being used. Declare `winston` as a devDependency instead,
  since both packages `import type { Logger } from 'winston'` and were relying
  on hoisting to resolve it.
- Drop `rollup-plugin-postcss` from `packages/client`. It is unreferenced since
  the package moved to tsdown, and pulled in `cssnano -> postcss-svgo -> svgo@2`,
  which is the only consumer of the deprecated `stable`.
- Override `test-exclude` to ^8 so `babel-plugin-istanbul` stops resolving
  `test-exclude@6`, which pins the deprecated `glob@7`.

The remaining five warnings (`ldapjs`, `whatwg-encoding`, `node-domexception`,
and workbox-build's `glob`/`source-map`) are transitive with no non-deprecated
version available upstream.
2026-07-28 09:28:06 -04:00
Marco Beretta
edd614bbff
🧰 feat: Redesign Agent Builder with Unified Tools Marketplace, Skills & Orchestration (#13952)
* feat: redesign the agent builder tools, skills, and advanced panels

Replace the stacked capability/MCP/skill/tool/action form sections with a unified tools marketplace, per-item configuration dialogs, and a consolidated Advanced panel.

- unified tools marketplace (catalog, sidebar, polymorphic cards/rows) covering built-in capabilities, plugins, MCP servers, and actions, each with a detail/config dialog
- dedicated Skills picker and a Tools section with selected-item summaries and empty states
- redesigned action editor and authentication dialog (method cards, segmented controls)
- rebuilt Advanced panel: orchestration hub (subagents, handoffs, chain), max steps, skills kill-switch, copyable agent id
- restyled version history (timeline, tool/capability counts, in-app restore confirmation)
- shared component updates (Radio, Input/Textarea, dropdown z-index, dialog primitives) and keyboard-only focus rings via useInputModality
- format-hint placeholders for tool credential fields
- sanitize numeric parameter inputs to prevent comma truncation

* feat: refine agent builder tools, actions, and MCP sections

* feat: restore Memory capability toggle in agent builder tools catalog

* feat: refine agent tools picker (skills, MCP connect/OAuth, web search)

- Skills picker: per-card visibility (public) and shared-author badges,
  category filtering, and an in-place Create skill flow that auto-attaches
  the new skill without leaving the builder
- MCP: inline Connect button in the first dialog plus a dedicated OAuth
  dialog (continue, copyable URL, QR code) shown only when OAuth is required
- Web search: auth-aware affordance, settings cog when user-provided and an
  info icon when system-defined
- Remove orphaned com_ui_unavailable/com_ui_initializing keys and the dead
  Tools/MCPToolItem component

* refactor: streamline MCP OAuth dialog

- Remove the Cancel button (the flow auto-closes on connect / times out)
- Show the URL in a read-only single-line scrollable input (cursor moves
  through it, not fully visible) with the shared CopyButton's smooth
  Copy/Check icon swap, matching the OAuth callback-URL field
- Put the primary Continue with OAuth action (icon trailing) and an
  icon-only QR toggle together in a row at the bottom, below the URL
- The QR reveals between the description and the URL with a smooth height
  animation (grid-rows 0fr to 1fr, matching MCPToolItem's reveal)

* feat: smoothly collapse MCP connect button once connected

* feat: cross-fade MCP tools between loading, list, and empty states

* feat: show MCP server icon in OAuth dialog title

* fix: vertically center OAuth dialog title against the MCP icon

* feat: smoothly animate auth field changes in the MCP server dialog

* feat: match Code Interpreter file upload to the File Search dropzone

Swap Code Interpreter's thin btn-neutral bar for the same dashed dropzone
(DropzoneContent + dropzoneClassName) File Search already uses, so the two
capabilities' upload UIs are consistent.

* feat: show a saving spinner and allow cancelling credential edits

Drive the tool credential Save button from the real mutation state so it
shows a spinner while the request is in flight, and add a Cancel button
when re-editing already-saved credentials so the edit can be dismissed.

* feat: make the skills create button a compact icon button

* fix: restore MCP attach semantics and confirmations in the tools marketplace

Connecting an MCP server from the item dialog now enables all of its tools
once the connection settles, deselect-all keeps the server attached via its
placeholder token instead of detaching it, adding a server writes the token
so a zero-tool attachment survives a save, and removing a server from the
tools list asks for confirmation again. Consume-only servers are excluded
from the catalog, matching the old select dialog.

Also share the catalog/selection pipeline between ToolsSection and the
marketplace through useAgentItems, hoist NEW_ACTION_ID next to ActionItem,
drop unused status/view union members and stale TranslationKeys casts,
document the phase-2 Favorites/Made-by-you views, fix the needs-setup dot
semantics and card focus suppression, remove the redundant close button in
CreateSkillDialog, move useInputModality into @librechat/client so external
consumers can mount it, and delete dead files and orphaned translation keys.

* fix: scope tooltip elevation to dialogs and restore dialog close button size

Tooltips go back to z-150 globally; inside a dialog they now borrow the
depth-aware popover z-index so they still clear nested dialogs (the Tool
Library item dialog) without outranking freshly opened modals everywhere
else. The default dialog close icon returns to its original size, and the
lc-field pointer-focus suppression ships with the package next to Input and
Textarea so external consumers get the whole mechanism from @librechat/client.

* feat: add favorites for marketplace tools, MCP servers, and skills

Reintroduce the favorite star from the old skill picker, generalized to
every marketplace item kind except per-agent actions. Cards in the Tool
Library and Skills dialogs get a hover-revealed star (always visible once
favorited), and the existing Favorites views in both dialogs now filter to
starred items.

Favorites persist in a dedicated ToolFavorite collection, one document per
(user, itemType, itemId) with a unique compound index, exposed through
atomic per-item PUT/DELETE endpoints under /api/user/settings/favorites/
tools. Per-item writes are idempotent and race-free across tabs/devices
(the unique index backstops concurrent toggles), reads are a single
index-backed query capped at 100 favorites per user, and the client keeps
React Query as the source of truth with optimistic updates. Handlers live
in @librechat/api with a thin route wrapper; methods follow the
data-schemas factory pattern with tenant isolation.

The favorites filter now matches on compound kind:id keys instead of bare
ids, closing a cross-kind collision where a tool and a skill sharing an id
would both match. The skill-favorites data-service stubs and the reserved
TUserFavorite.skillId field are replaced by the new tool-favorites service.

* feat: anchor the favorite star at the card's right edge

Swap the ToolCard action-bar order so the star sits rightmost with the
configure/info icon to its left. Every card can be favorited but only some
are configurable, so anchoring the star keeps it in a consistent position
across the grid.

* chore: remove translation keys orphaned by the tool library redesign

* fix: gate marketplace creation entries and resolve off-page selected skills

The Create New menu exposed MCP server creation to users without the
MCP_SERVERS create permission and action creation on deployments with the
actions capability disabled; both entries are now gated like their
pre-redesign counterparts, and the button hides when neither applies.

Selected skills missing from the first catalog page (limit 100) were
dropped from the Skills section entirely, leaving them impossible to
inspect or remove. useResolvedSkills restores the per-id lookup: off-page
skills are fetched individually and confirmed misses (deleted or no longer
shared) stay visible under an Unavailable skill placeholder so the stale
allowlist entry remains removable.

* fix: refetch favorites when toggled before the list loads, lint fixes

An optimistic favorite written over an unpopulated cache seeded the list
with only the toggled item, and cancelQueries killed the initial fetch
that would have corrected it, hiding existing favorites until reload. The
optimistic write now only applies over known data; otherwise onSettled
invalidates so the authoritative list is refetched.

Also unnest the version date-label ternary and drop an unused form watch
flagged by CI.

* fix: sync skills_enabled with selection edits and hydrate agent file entries

skills_enabled is the master opt-in for the skill allowlist, and an empty
allowlist with the flag on means the full accessible catalog. Selection
edits now sync the flag on empty/non-empty transitions via a shared
skillsEnabledTransition helper: picking the first skill enables it so the
choice takes effect on save, and removing the last one disables it so the
agent doesn't silently escalate to every skill. Mid-selection edits leave
the flag alone, preserving the Advanced kill switch's
disable-without-clearing behavior.

Agents loaded from the API carry only tool_resources.*.file_ids; the
client-only context/knowledge/code file entry arrays were read directly,
so existing attachments rendered as empty and could not be removed. A new
useAgentFileEntries hook restores the legacy derivation (agent files query
merged into the file map via processAgentOption) and now feeds AgentConfig,
the item dialog, and the selected-items pipeline.

* fix: hide plugin tools from the marketplace when the tools capability is off

buildCatalog gated built-ins, MCP, and skills on their capabilities and
permissions but pushed regular plugin tools unconditionally, so deployments
that removed the tools capability still offered attachable tool cards in
the marketplace. The loop now requires AgentCapabilities.tools, matching
the old Add Tools gate.

* fix: strip legacy MCP tokens on removal, guard action creation, model button spacing

MCP selection accepts every historical token format (server placeholder,
raw server name, mcp_-prefixed, and per-tool ids in prefix/suffix shapes)
but removal only filtered the new placeholder plus the server's current
tool ids, so a legacy token left the server permanently selected and its
tools still expanded after save. Selection and removal now share a
matchesMcpServer predicate.

Creating an action from the marketplace on an unsaved agent opened an
editor whose save was guaranteed to fail; it now surfaces the existing
save-the-agent-first error, matching the action-removal guard.

The model picker button keeps its tight px-1 with a provider icon but gets
px-3 in the empty Select-a-model state so the placeholder is not flush
against the border.

* fix: strip legacy prefix MCP tokens in useRemoveMCPTool

The hook only filtered the raw server name and suffix-delimiter tokens,
so confirming removal in the selected-tools section left persisted
prefix-format tokens (mcp_<server>, mcp_<server>_<tool>) in the form and
the row reappeared as selected. It now shares the matchesMcpServer
predicate with the selection logic so removal can never lag selection.

* fix: exact MCP token matching and keep errored skill lookups removable

The mcp_<server>_ prefix clause in matchesMcpServer was invented by the
redesign, not a persisted format (mcp_prefix is only ever used as the
exact mcp_<serverName> pluginKey), and it claimed longer server names
sharing a prefix: with servers github and github_extra, removing github
also stripped github_extra's tokens. The predicate now only matches exact
or delimiter-bounded shapes.

An off-page selected skill whose per-id lookup failed with a transient
error (retry disabled) vanished from the selected list until remount. Any
settled lookup failure now keeps the placeholder entry so the allowlist id
stays visible and removable; only in-flight lookups are briefly hidden.

* fix: route file-backed built-in removal to the file manager

Code Interpreter and File Search stay selected while they hold code_files
or knowledge_files, so removing them by flipping the capability flag left
the row visible and unremovable. Their removal now opens the config dialog
where the files are managed, mirroring the file-only context built-in;
with no files attached the flag still toggles off for a clean removal.

* fix: preserve negative values in numeric parameter inputs

sanitizeIntegerInput stripped every non-digit, so typing -1 in a numeric
parameter field became 1. That broke Google thinkingBudget, where -1 is
the dynamic/auto-thinking sentinel (range min is -1): users could no
longer select auto and risked sending a one-token budget. The sanitizer
now takes an opt-in allowNegative flag that keeps a single leading minus,
and DynamicInput passes it when the field's range permits negatives.
Thousands-separator cleanup is unchanged for all other fields.

* fix: keep in-progress negative numeric input and localize the actions heading

Typing a leading minus in a negative-capable numeric parameter (Google
thinkingBudget) sanitized to a lone '-', which was then coerced by
Number('-') to NaN, so the sign could not be typed before the digits. The
lone '-' is now stored as a string until a digit resolves it to a number,
matching how the empty-string case is already handled.

The agent builder actions panel heading hard-coded 'Add'/'Edit actions';
it now uses com_assistants_add_actions and a restored
com_assistants_edit_actions key so non-English locales translate it.

* chore: fix import order drift flagged by CI

* fix: treat pending web-search auth verification as needs_setup

While useVerifyAgentToolAuth is still loading, data is undefined so
web_search was not marked needs_setup, and the marketplace card takes the
direct-enable path only when status is not needs_setup. On a slow
connection a click before the response arrived enabled web_search without
collecting the required user-provided key. The auth map now flags
web_search needs_setup while the query is loading, routing the click to
the config dialog; once verification resolves, a system-defined deployment
or a satisfied key clears the flag for a direct toggle.

* test: update agent builder e2e selectors

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-07-05 11:30:12 -04:00
Danny Avila
03ecac8ac1
🧪 ci: Resolve DataTable test infinite re-render (#13947)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
DataTable.spec failed with "Too many re-renders" (35 tests). Root cause: @tanstack/react-virtual is measurement-driven, and jsdom has no real layout, so its re-render loop never converges. This went unnoticed because packages/client had no jest CI job (only the client workspace runs jest in frontend-review.yml).

- DataTable: only read the virtualizer (getVirtualItems/getTotalSize) when virtualization is active; the non-virtualized branch renders rows directly, so engaging it for small tables was wasted render-phase work.
- Spec: mock @tanstack/react-virtual, since jsdom can't exercise real virtualization layout.
- Add a test:ci script to @librechat/client and a Tests: @librechat/client CI job so packages/client specs run on every frontend PR.
2026-06-24 23:40:18 -04:00
Danny Avila
5c5ef37e30
⬆️ chore: Migrate off deprecated @ariakit/react-core to @ariakit/react-components (#13940)
* ⬆️ chore: Migrate off deprecated @ariakit/react-core to @ariakit/react-components

@ariakit/react-core and its dependency @ariakit/core are deprecated (split into successor packages) and emit install-time warnings. @ariakit/react already ships the non-deprecated @ariakit/react-components transitively; the only direct use of react-core was the SelectRenderer deep import in ControlCombobox, which is now sourced from @ariakit/react-components/select/select-renderer (identical symbol and subpath). Both deprecated packages drop out of the lockfile and react-components dedupes to the single version @ariakit/react pins.

*  test: Resolve ESM-only @ariakit split packages in jest

@ariakit/react-components and its peers are ESM-only (type: module) and declare only an import export condition, so jest's CJS resolver can't load them when @librechat/client's CJS build requires SelectRenderer. Add a custom jest resolver that resolves these @ariakit/* split packages with the import condition, and extend transformIgnorePatterns so babel transpiles them to CJS. Applied to both the client and packages/client jest configs.
2026-06-24 23:13:57 -04:00
Danny Avila
9e74cc0e57
v0.8.7 (#13907)
Some checks failed
Publish `@librechat/client` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2026-06-24 14:49:32 -04:00
Danny Avila
3945533a4e
🏷️ chore: Bump Individual Package Versions (#13891) 2026-06-22 09:54:19 -04:00
Danny Avila
465cb6e394
👐 a11y: Bump @ariakit/react, Improve a11y of Token Usage, Archived Chats, Reduce Table Layout Shifts (#13874)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Publish `@librechat/client` to NPM / pack (push) Waiting to run
Publish `@librechat/client` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* chore: Update `@ariakit/react` and `@ariakit/react-core` dependencies to v0.4.29 and v0.4.26 respectively, and add new `@ariakit/components`, `@ariakit/react-components`, `@ariakit/react-store`, and `@ariakit/react-utils` packages to package-lock.json and package.json files.

* fix: restore keyboard navigation for Tools dropdown submenus

Compose the Artifacts and MCP submenu triggers as a `MenuButton` that
receives the parent `MenuItem`'s props/ref directly, instead of nesting a
`MenuItem` inside the submenu's own provider and placing the ref on a
wrapper div. This registers the focusable trigger with the parent menu
store so arrow-key navigation reaches the items, which fully broke under
Ariakit 0.4.29.

* fix: Improve keyboard navigation for TokenUsageIndicator popover

Refactor the TokenUsageIndicator component to enhance keyboard accessibility. The popover now maintains focus on the gauge trigger, ensuring that the Escape key closes the popover without shifting focus to the non-interactive panel. Additionally, the autoFocusOnShow property is set to false to prevent unwanted focus behavior when the popover is displayed.

* fix: Stabilize focus and layout shift in Archived Chats dialog

Anchor dialog focus to the content element so rapid tabbing during the
virtualized table's loading state no longer escapes to the page's top
focus guard, and stabilize the columns memo to keep the focus trap intact.
Reserve a fixed height and stable scrollbar gutter, and drop the redundant
nested scroll wrapper in the shared DataTable to eliminate load-time
layout shift.

* fix: Add stable scrollbar gutter to SharedLinks DataTable

Enhance the layout stability of the SharedLinks component by adding a "scrollbar-gutter-stable" class to the DataTable. This change aims to prevent layout shifts during loading, improving the overall user experience.

* fix: Enhance keyboard accessibility and focus management in TokenUsageIndicator

Refactor the TokenUsageIndicator component to improve keyboard navigation and focus behavior. Introduced a useRef hook for the disclosure button to ensure focus remains on the gauge trigger when the popover is opened. Updated the popover's finalFocus property to return focus to the trigger on close, enhancing the overall user experience for keyboard users.
2026-06-21 12:53:24 -04:00
Marco Beretta
a468becf8c
🔑 feat: Agent API Keys management UI in Settings → Data controls (#13819)
* feat(client): add optional copyButton slot to SecretInput

* refactor: redesign Agent API Keys settings into a Data controls dialog

* chore: remove unused com_ui_last_used i18n key
2026-06-18 12:58:17 -04:00
Marco Beretta
9de3249e9c
🎛️ feat: Redesign Settings with Registry-Driven Dialog, Search, and Mobile Drill-In (#13722)
* i18n: add settings reorganization keys

* feat(settings): add tab/section types and tab metadata

* feat(settings): add useSettingsContext guard hook

* feat(settings): add pure settings search filter with tests

* feat(settings): extract selectors and add control wrappers

* feat(settings): add setting registry, memory and billing controls, integrity test

* feat(settings): add Section and Advanced disclosure with test

* feat(settings): add content pane with tab and search views

* feat(settings): add sidebar and dialog shell with tests

* refactor(settings): wire new dialog and remove superseded containers

* fix(settings): restore speech external engine option, escape-to-clear search, results a11y

- SpeechControls.tsx: read sttExternal/ttsExternal from useGetCustomConfigSpeechQuery
  instead of hardcoding false, so external engine options appear on qualifying deployments
- Sidebar: Escape clears search input when non-empty, stops propagation to avoid closing dialog
- Content: persistent aria-live="polite" wrapper covers both populated results and empty state
- context: useMemo on returned ctx object so Content's useMemo deps are referentially stable
- locales/README.md: update stale path from deleted General.tsx to Selectors.tsx

* refactor(settings): reorganize categories, remove advanced disclosure, add About

- Re-categorize settings into logical groups (username display -> Chat/Messages,
  keep-screen-awake -> Accessibility, fork/prompts surfaced into Chat sections)
- Dissolve thin Personalization tab; move Memory into Data & Privacy
- Remove the Advanced collapsible; all settings always visible, destructive
  actions grouped in an always-visible Danger zone
- Wire the new About tab into the registry-driven dialog
- Standardize spacing with bordered, evenly-divided section cards
- Use semantic text-text-* / border tokens so dark mode renders correctly
- Sync LangSelector language-loading indicator from dev

* feat(settings): move archived chats to the account menu

Add an Archived chats item to the account dropdown next to My Files,
opening the archived chats table in a modal. Removes it from the
settings dialog where it no longer fit the data/privacy grouping.

* feat(settings): polish About panel and use shared CopyButton

- Flatten the build-info into a single divided key/value list (drop the
  redundant inner card now that it sits inside a section card)
- Replace the hand-rolled copy button with the shared animated CopyButton
- Shorten the copied label so it fits the button without clipping

* fix(settings): set primary text color on setting rows for dark mode

Leaf control labels rendered without a text color and fell back to the
browser default (black), making them invisible on the dark panel. Set
text-text-primary on the section and search-results row containers so
labels inherit a visible color, matching the old container behavior.

* fix(settings): use visible icon for dialog close button

The plain multiplication-sign close button had no text color and was
invisible on the dark panel. Replace it with the lucide X icon using
text-text-secondary/hover:text-text-primary so it shows in both themes.

* fix(nav): drop focus ring on account menu items, use hover background only

The account-settings popover drew a 2px ring around the active menu item.
Remove that override so items show only the standard hover background,
consistent with every other menu.

* fix(settings): replace native search clear with a real X button

The settings search used type=search, whose native WebKit clear control
rendered as a blue X. Switch to a text input and add a real lucide X
clear button styled text-text-secondary, shown only when there's a query.

* fix(speech): disable dependent dropdowns and switches when STT/TTS is off

Add a disabled prop to the shared Dropdown component, then gate the
speech engine/voice/language dropdowns and the automatic-playback switch
on their parent toggle (speechToText / textToSpeech), matching the
controls that already disabled correctly.

* feat(settings): mobile drill-in navigation for settings tabs

On small screens the horizontal scrolling tab row is replaced with a
full-width vertical list (with chevrons); tapping a tab drills into its
content with a Back header. Searching shows results full-width. Desktop
keeps the side-by-side sidebar + content layout unchanged.

* chore(settings): remove orphaned i18n keys, fix import order and review notes

- Drop the i18n keys left unused after the refactor (old Commands/Balance/
  Personalization tab labels, the Speech simple/advanced labels, and the
  former About section headings)
- Sort imports in the rebased files the lint-staged hook never touched
- Guard the language fallback against an empty navigator.languages
- Import the RefObject type instead of leaning on the React namespace

* feat(settings): searchable language dropdown

Add an opt-in searchable mode to the shared Dropdown (Ariakit Select +
Combobox) and use it for the language selector, which has 40+ options.
The trigger styling is unchanged so it stays consistent with the other
settings rows; only the popover gains a filter input.

Accessibility: the filtered listbox is labeled, the empty state is moved
out of the listbox and announced via an aria-live status region, and the
decorative selected-state checkmark is hidden from assistive tech.

* fix(settings): restore guards dropped in dialog refactor

- Fall back to the General tab when the active tab becomes hidden
  (e.g. About when buildInfo is disabled) instead of rendering an
  empty panel.
- Normalize a deprecated/invalid engineTTS (e.g. 'edge') back to
  browser during speech init so read-aloud controls keep rendering.
- Hide the cloud browser voices toggle unless Browser TTS is active.

* test(e2e): match agent-creation toast exactly to avoid SR-announce collision

The agent builder spec asserted the creation toast with a non-exact
getByText, which also matched Radix Toast's transient role="status"
announce region ("Notification Successfully created ..."), causing a
strict-mode violation. Mirror the mcp spec by using { exact: true }.

* fix(settings): render the active panel as a tabpanel

Wrap the non-search settings body in Tabs.Content so the selected
panel gets role=tabpanel with Radix's id/aria-labelledby wiring,
resolving the aria-controls target on each tab trigger. Search
results stay a labeled live region (the tab list is hidden during
mobile search, so a tabpanel aria-labelledby would dangle).
2026-06-18 08:51:07 -04:00
Danny Avila
b917e0418b
v0.8.7-rc1 (#13592)
* chore: Bump LibreChat to v0.8.7-rc1

* docs: Sync Chinese README
2026-06-15 13:10:30 -04:00
Lacy
dea71c8396
🪟 fix: Cross-Platform Absolute-Path Check in tsdown neverBundle Predicates (#13700)
The deps.neverBundle predicates in the four package tsdown configs detect
first-party (resolved) module ids with !id.startsWith('/'). On Windows,
resolved ids are absolute paths like C:\..., which never match, so every
project module is externalized. Builds still exit 0 but emit near-empty
bundles — e.g. packages/client dist/index.mjs drops from ~276 kB to
~2.7 kB and dist/style.css is never produced, breaking the client dev
server with "Failed to resolve import @librechat/client/style.css".

Replace the startsWith('/') check with path.isAbsolute(id), which is
behavior-identical on POSIX and correct on Windows.

Co-authored-by: phoenixtekk <phoenixtekk@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:04:46 -04:00
Ravi Kumar L
db863e75e3
⚙️ refactor: Lazy load locale resources (#13640) 2026-06-10 08:48:58 -04:00
Danny Avila
cb6dbc8f60
refactor: Migrate @librechat/client build to tsdown (#13596)
*  refactor: Migrate @librechat/client build from Rollup to tsdown

Mirrors the data-schemas migration. Replaces Rollup (rpt2 + postcss) with
tsdown (rolldown + oxc); the package build drops from tens of seconds to ~0.3s.

- Emit isolated-declaration .d.ts via oxc (dts.oxc) and enforce
  isolatedDeclarations in tsconfig for editor DX (source made clean: explicit
  export type annotations added across src, no `any`).
- Extract component CSS to dist/style.css so the CJS output stays valid
  CommonJS (the prior postcss runtime-injection produced an ESM import in the
  CJS bundle that breaks jest/require). Imported once in the client app entry;
  Vite bundles it for the app.
- Repoint package.json to dual .mjs/.cjs + .d.mts/.d.cts and add ./style.css
  and ./package.json exports.
- Update CI build-cache keys to hash tsdown.config.mjs; remove rollup.config.js.

* 🔧 chore: address Codex review on client tsdown migration

- Add tsdown.config.mjs to turbo.json build `inputs` so changes to the new
  bundler config invalidate the Turbo cache (the shared inputs only listed the
  rollup configs). Also covers the already-migrated data-schemas.
- Name the memoized default export (ControlComboboxMemo) instead of the
  codefix-generated `_default_1`, for clearer stack traces / grepping.
2026-06-08 11:20:03 -04:00
Danny Avila
6edbafd09d
⬆️ chore: Bump TypeScript to 5.9.3 (+ typescript-eslint 8.60.1) (#13584)
Bumps typescript 5.3.3 -> 5.9.3 across all workspaces. typescript-eslint must move 8.24.0 -> 8.60.1 too: 8.24's typescript peer was capped at <5.8.0; 8.60.1 widens it to <6.1.0.

Two errors surfaced by the newer compiler are fixed:
- api/src/rum/proxy.ts: TS 5.9 made `Buffer` generic (`Buffer<ArrayBufferLike>`), which no longer structurally matches `BodyInit`; cast the fetch body (Node's fetch accepts a Buffer at runtime).
- client usePresetIndexOptions.ts: drop a dead `|| {}` on an object spread (always truthy — flagged by the new TS2872 check).

All four package typecheck jobs + the client app typecheck pass under 5.9.3; builds (tsdown + rollup) and the rum proxy tests are unaffected.
2026-06-07 22:20:44 -04:00
Danny Avila
bfb6b224d2
🔧 chore: Update ESLint config, Import Sorting script, Test Sharding, Bump @librechat/agents (#13552)
* 🔧 chore: Update ESLint config, add import sorting script, Test Sharding, Bump `@librechat/agents`

* Change 'no-nested-ternary' rule from 'warn' to 'error' in ESLint config
* Add new scripts for sorting imports in the project
* Update lint-staged configuration to include import sorting
* Modify GitHub Actions workflows to support sharding for unit tests

* chore: remove nested ternary expressions

* refactor: Extract scale multiplier logic into a separate function in CircleRender component
* refactor: Simplify auto-refill rendering logic in Balance component for better readability
* refactor: Improve width style handling in DataTable components for clarity and maintainability

* chore: remove CircleRender component

* delete: Remove CircleRender component as it is no longer needed in the project

* chore: Bump @librechat/agents to version 3.2.31 and update Node.js engine requirement

* Update @librechat/agents dependency from 3.2.2 to 3.2.31 in package-lock.json, api/package.json, and packages/api/package.json
* Change Node.js engine requirement from >=20.0.0 to >=24.0.0 in @librechat/agents

* chore: Add import sorting check to ESLint CI workflow

* Implement a new job in the GitHub Actions workflow to verify import ordering on changed files.
* The job checks for changes in specific file types and reports any import order drift, providing instructions for local fixes.
2026-06-06 12:31:55 -04:00
Dan Lew
a11751e58a
🖼️ fix: Upgrade Framer Motion for Vite 8 Compatibility (#13512)
LibreChat recently updated Vite (see 7dba640c9).

The older version of framer-motion we're using is incompatible with
this newer version of Vite; if you try to use it, you get the error
"e is not a function."

(One easy way to reproduce: try to enable 2FA on your account.)

Updating to the latest framer-motion fixes this issue.
2026-06-04 13:19:04 -04:00