* 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.
* ✨ feat: Custom Request Headers For Langfuse
Self-hosted Langfuse behind an authenticating proxy or gateway could not
be reached: every outbound Langfuse request hardcoded `Authorization` and
nothing else. Adds `langfuse.headers`, mirroring `endpoints.custom`
headers, and applies it to all four request surfaces — trace/media export
(via the agents run config), feedback scores, central project-identity
lookup, and admin credential verification.
Values resolve through the same pipeline as endpoint headers, so
`${ENV_VAR}` interpolation and header-safe encoding come along.
`extractEnvVariable` continues to refuse infrastructure secrets, so a
config cannot exfiltrate `MONGO_URI` through a header. A header whose
variable is unset is dropped with a one-time warning rather than sent as
a literal `${...}`, which a gateway would read as a wrong credential
instead of a missing one.
Headers merge beneath LibreChat's own `Authorization` on the REST
surfaces, matching `mergeHeaders`, so a custom header can never displace
the Langfuse credential.
These are deployment-level and documented as such: trace export batches
spans from every user through a single exporter, so unlike endpoint
headers they cannot carry per-user placeholders.
The central project-id cache key now includes the headers, so the
header-less module warm-up cannot record a proxy rejection against the
entry the request path later reads.
* 🔒 fix: Keep Langfuse Headers Out Of Stored Overrides
The generic admin config API accepts any field path inside an allowed
section, so `langfuse.headers` could be written through it. Unlike
`langfuse.secretKey`, headers are a map rather than one scalar path, so
the config secret registry cannot encrypt them at rest or mask them on
read — an admin-written map would sit in Mongo in plaintext and come
back in plaintext, widening exposure of what are gateway credentials.
Rejects them on both the dotted-patch and object-upsert routes, the same
way process-backed MCP servers are held to librechat.yaml. This is what
makes "deployment-level" true rather than merely documented.
* 🐛 fix: Wire Config Middleware And Header Collisions For Langfuse
Two codex review findings.
P1 — `api/server/routes/admin/langfuse.js` never mounted
`configMiddleware`, so `req.config` was undefined in production and
credential verification silently ran without the deployment's proxy
headers: exactly the deployments this feature targets could not save a
connection. The handler unit tests injected `config` into their mock
requests, so they stayed green. Mounts the middleware after the access
checks (unauthorized callers still short-circuit first) and adds
route-level tests that assert the handler actually receives a resolved
config — the composition root, not the component.
P2 — spreading custom headers under `Authorization` only replaced an
exact-case collision. A configured `authorization` survived alongside
the managed `Authorization` and fetch appends rather than replaces,
sending both credentials in one combined value. All four request sites
now use `mergeHeaders`, which already merges case-insensitively with the
override winning; tests cover the lower- and upper-case variants.
* 🔒 fix: Mask Langfuse Headers On Read And Harden Value Handling
Three codex round-2 findings.
P1 — the write guard blocked storing `langfuse.headers` in Mongo but did
nothing for the read path: `GET /api/admin/config/base` serves the
resolved AppConfig through `redactConfigSecrets`, which only knows
registered scalar secrets, so a yaml-configured gateway credential was
returned in full to any admin with Langfuse read access. Adds a
secret-map registry that masks values while keeping key names, so an
admin can still see which headers a deployment sets. Masking is safe
precisely because these are yaml-only — a masked read cannot be
round-tripped back over the real values. A malformed non-object value at
that path is dropped rather than serialized.
P2 — `mergeHeaders` indexes one spelling per lowercase name, so a config
holding both `authorization` and `AUTHORIZATION` had only one displaced;
the survivor was then appended by `Headers` into a combined value. Case
variants are now collapsed at resolution, before any consumer sees them.
P2 — `resolveHeaders` encodes only values it substitutes a user field
into, and no user is supplied here, so a literal or interpolated
character above U+00FF reached `Headers` unencoded and threw. Final
values now go through `encodeHeaderValue`; Latin-1 still passes verbatim.
* 🔒 fix: Keep Langfuse Header Credentials Out Of Logs And Validate Names
Three codex round-3 findings, plus a documented boundary for the fourth.
P1 — `loadCustomConfig` logs the parsed config at startup (`printConfig`
defaults true), so a literal gateway credential in `langfuse.headers` was
copied into application logs on every boot, undoing the masking the admin
read path had just gained. The printed copy now goes through
`redactConfigSecretMaps`, reusing the same registry. Scoped to map-valued
secrets so scalar-secret log behavior is unchanged; the live config keeps
its real values.
P2 — a nonempty but invalid field name (` X-Token`, `X Proxy Token`)
passed the emptiness check and then threw in the `Headers` constructor,
which would break export, verification, lookup, and feedback for the whole
deployment rather than that one header. Names are trimmed and validated
against the RFC 7230 token grammar, and dropped with a warning otherwise.
P2 — unresolved `${VAR}` detection tested the *resolved* value, so a
credential legitimately containing `${...}` was mistaken for a failed
substitution and dropped. Detection now inspects the configured text and
checks the referenced variables directly, which also drops references to
denylisted infrastructure secrets instead of forwarding them verbatim.
The fourth (fanout gateway forwards only `Authorization`, so a tenant
Langfuse behind its own proxy is not covered) is a real limitation in a
separate component. Documented on the schema field and in the example
config rather than left implied.
* 🔒 fix: Scope Langfuse Headers To Configured Origins
Three codex round-4 findings.
P1 — one header map was attached to every destination a run resolves to.
Under fanout that means a credential meant for an internal gateway was
also sent to the central destination, typically Langfuse Cloud: an
unrelated third-party origin. Headers are now attached only when the
destination's origin is one the deployment explicitly configured (a
self-hosted base URL, the fanout collector, or a tenant destination set
by env). The built-in `*.cloud.langfuse.com` defaults are excluded
precisely because nobody pointed at them. For trace export this also
means attaching after the export branch settles on a `baseUrl` rather
than before, since which destination wins depends on the branch.
P2 — `encodeHeaderValue` only encodes above U+00FF, so a newline, CR, or
NUL passed through and threw in `Headers`, breaking every request rather
than the one header. Values are trimmed (the common trailing-newline
case) then validated against the legal field-value bytes; an embedded
CRLF is a request-splitting attempt and is dropped, not stripped.
P2 — the write guard matched only the exact `headers` property, so
`{ langfuse: { "headers.X-Token": "..." } }` and root-level dotted
variants slipped through into the Mixed overrides document, where the
nested-map redactor never walks them and a later read returns them in
plaintext. All dotted spellings are now rejected.
* 🔒 fix: Bind Langfuse Headers To One Configured Origin
Four codex round-5 findings.
P1 — the round-4 allowlist still authorized every configured origin, so a
deployment with both a collector and an explicit central host sent the
same credential to both. `langfuse.headers` is one map with no way to say
which endpoint it authenticates to, so it is only unambiguous when the
deployment configures exactly one Langfuse origin. Iterating on which
origins to guess was the wrong axis; headers are now sent only when there
is a single configured origin and the destination is it, with a warning
when several make the intent unresolvable. That covers the self-hosted
case this feature exists for; multi-destination deployments need
per-destination headers the schema cannot yet express.
P1 — `fetch` defaults to following redirects, and Node strips
`Authorization` across origins but keeps arbitrary headers, so a redirect
off an allowed origin would hand the gateway credential to a host that
passed no check. Requests carrying custom headers now refuse redirects;
requests without them keep the default, so nothing changes for existing
deployments.
P2 — `extractEnvVariable`'s whole-string branch is anchored and greedy, so
`${CLIENT_ID}:${CLIENT_SECRET}` parsed as one variable name and the raw
template was sent as the credential. References are expanded here now, so
only literal values reach that path.
P2 — a valid token name is not necessarily usable: `Transfer-Encoding`
makes `fetch` throw and a fixed `Content-Length` misdescribes the body of
every other request sharing the map. Request-framing names are dropped.
* 🐛 fix: Expand Langfuse Header References Exactly Once
Codex round 6 (P2). After expanding `${VAR}` references myself I still
handed the result to `resolveHeaders`, which runs `extractEnvVariable`
over it again — so a credential containing `${PATH}`, or any other name
that happens to be set, was silently rewritten on export, verification,
lookup, and feedback. The round-3 test only used an *unset* embedded
name, which the second pass leaves alone, so it could not catch this.
Resolution no longer round-trips through `resolveHeaders`. The only part
still wanted from it was stripping `{{...}}` user placeholders, which is
now applied directly; expansion, encoding, and validation were already
local. Adds a test whose embedded variable is set, which fails against
the previous pipeline.
* 🐛 fix: Process Langfuse Header Templates Before Substitution
Codex round 7 (P2), the mirror of round 6. Having stopped re-expanding
the resolved credential, the placeholder strip was still running over it:
a token containing `{{LIBRECHAT_USER_ID}}` had that span deleted and
`abc{{...}}ghi` went out as `abcghi`.
Establishes the invariant the last two rounds were circling. Every
template operation — placeholder strip, unresolved-reference check,
expansion — now runs on the operator's configured text, and the
credential is substituted last and never touched again. Gateway
credentials are arbitrary strings, so none of their bytes are syntax.
Also moves the unresolved-reference check after the strip, so it no
longer reports a variable inside a `{{...}}` span that the strip removes.
* ci: codegraph test-selection probe (observe-only)
Asks the codegraph service which test files and matrix jobs the PR
needs and writes the decision to the job summary. Gates nothing —
every path exits 0, forks without secrets no-op. Companion to the
shadow-mode evaluation: the decision CI would act on, made visible
next to the runs it would have replaced.
* chore: remove GitNexus CI and deployment configs
Superseded by the codegraph service: the index workflow spent ~45min
per invocation building an artifact the PR flow never served, while
the replacement indexes incrementally in ~1.4s per commit server-side.
Removes the four workflows (index, deploy, cleanup-pr, pr-command) and
the .do/gitnexus deployment bundle. No remaining references.
* ci: render playwright spec tiers in the codegraph probe summary
A batched `ask_user_question` interrupt rendered every question stacked in
one scrolling form, which reads as a wall on mobile and desktop alike. Show
one question per step instead, with clickable progress dots, Back/Next, and
Submit only on the last step.
The batch contract is untouched: one interrupt, one answer map, Submit still
gated on every question having an answer, Skip still declines the whole
batch from any step. Single-question batches render exactly as before.
* 🧩 refactor: Resolve Tool-Card State Once (AI-1810)
Each tool card derived its state several times over — the visible label
from one expression, the `aria-live` announcement from another, the
icon and shimmer from a third, and since #14906 the follow-scroll from
a fourth. Nothing tied them together; they agreed only because each was
written to agree. Thirteen of the seventeen review findings on #14873
were instances of one derivation being updated and another left behind,
and #14892 added more.
`resolveToolCallPhase` is now the single source: one function encoding
the precedence rules, each of which a specific review finding
established, returning `running | completed | cancelled | failed`.
Everything the card shows reads that value.
`ProgressText` takes `phase` in place of the `error` + `errorSuffix`
pair, which encoded three terminal states in two booleans — `error`
meant cancelled, a present `errorSuffix` meant failed — and made every
consumer reconstruct the distinction. That shape is precisely what let
a duration render beside "failed" (Codex round 1 on #14892).
Two things fell out once the state had one home, both dead code rather
than deletions of behaviour:
- `progress` left `ProgressText` entirely; the phase already carries
everything it was used to decide.
- The `useProgress` mask went with it. Passing 1 in still matters — it
stops the 200ms interval — but masking the output no longer does,
because the phase treats an explicit close as terminal outright. The
"both halves are load-bearing" subtlety is now one half.
Scope: the nine cards that render the shared `ProgressText`. The three
with bespoke layouts (`WebSearch`, `SubagentCall`, `OpenAIImageGen`)
still resolve their own state and are the natural follow-up — they can
adopt the resolver without adopting the component.
Refactor-only. 4891/4891 client tests pass unchanged, including the
suites that encode the cancelled/failed precedence in both directions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🐛 fix: Infer Cancellation From Reported Progress, Not The Animation
`useProgress` holds below 1 for ~200ms after a call reports completion:
it emits the previous value, then `0.99`, then `1` on a timeout. The
resolver read that animated value for its cancellation inference, so a
successful call whose submission ended inside that window rendered —
and announced — as "Cancelled".
The input is now split. `reportedProgress` is what the stream said and
drives the inference; `displayProgress` is the animated value and drives
`running` vs `completed`, so the label and shimmer still follow the
animation rather than snapping.
This restores `ToolCall` and `RetrievalCall`, whose previous predicates
used `initialProgress` and were immune, and additionally fixes
`useToolCallState`, which inferred from `rawProgress` and therefore
carried the bug already — every card the hook backs was exposed to it
before this PR.
Three tests cover the window: a reported-complete call mid-settle is
`running`, a genuinely unfinished one is still `cancelled`, and the card
settles to `completed` without a cancelled frame in between.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🧹 chore: Drop Unused Phase Predicates; Correct A Stale Comment
`isFailedPhase` and `isRunningPhase` had no callers — every consumer
compares the phase directly, which reads better than a wrapper. An
unused abstraction is the thing this PR argues against, so it should not
ship one.
The comment above the hook's resolver call still described "the raw
progress the legacy heuristic was written against", which stopped being
true when the input split into reported and display progress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
---------
Co-authored-by: Claude <noreply@anthropic.com>
* ⚡ 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
* feat: archive all chats from data controls
Adds an "Archive all chats" row under Data controls > Your data, next to
Shared links, with a confirmation dialog. It calls a new
POST /api/convos/archive/all endpoint backed by archiveAllConvos, which
archives every conversation currently visible to the user in a single
updateMany and refreshes the stats of every chat project the archived
conversations belonged to.
Temporary and retention-expired conversations are skipped: they are
already hidden from the chat list, so archiving them would only surface
them in the archived view. The update runs with timestamps disabled so
each conversation keeps its own updatedAt and the archived list stays
sorted by real activity.
Archiving a conversation now also drops the new-chat message cache alias
for it. A chat's first turn writes the same message array under both the
conversation key and the new-chat key, so without this the messages of a
just-archived chat kept rendering on the new chat screen until a reload.
Deleting already handled this; archiving did not.
* fix: keep archive-all state consistent
* fix: drop stale detail caches after bulk archive
* fix: harden archive-all request handling
* fix: reconcile archive batch failures
* Fix project stats refresh races and archive route boundary
* Fix archive-all review findings
* Fix archive scan index and partial-batch stats refresh
Reconcile project stats for already-committed archive batches when a later
batch fails, and index the archive scan as { user, _id } so non-tenant
pagination can use _id order.
* Fix Recoil reset after a partial archive-all failure
Refetch the submitted conversation on error and start a new chat only
when that conversation is still active and already archived.
* Fix archive recovery from resetting a newly opened chat
Re-read the active Recoil conversation after the archive-state lookup
resolves, so a slow getConversationById cannot start a new chat if the
user already opened another conversation.
* Fix project-stat reconciliation after archive races
Keep retrying optimistic project-stat writes instead of returning a
stale document after three lost CAS attempts, and retry destination
project discovery after a transient distinct failure.
* Fix archive reset and project-count increment races
Leave already-archived chats open after archive-all, recount new
project conversations instead of incrementing, and skip a delayed
increment when a concurrent refresh already recorded that chat.
* Recover destination projects after discovery retries exhaust
Keep committed conversation IDs when post-archive distinct fails, then
rediscover those projects in finally so a moved conversation's
destination still gets reconciled after the error is rethrown.
* Fix archive-all recovery batching and remount pending state
Recover destination projects in 500-id chunks so the final lookup
cannot exceed Mongo's command size, and share archive-all pending
state through a mutation key so Settings remounts stay disabled.
* Stamp bulk-archived chats and refresh the pinned cache
Bulk archive wrote only isArchived, so the archived table dated every
swept chat by createdAt and the default archivedAt sort dropped the whole
run into the legacy null group. Stamp one timestamp for the sweep; the
filter only matches unarchived chats, so an existing stamp cannot move,
and timestamps: false still preserves each updatedAt.
The pinned section fetches on its own key with a five-minute stale time,
so an archived pin kept rendering in the sidebar until that expired.
Invalidate it alongside the other lists on both success and failure.
Also drop the async from the failing-batch updateMany mock: its
Promise<never> is not assignable to the Query return type, while a plain
synchronous throw types as never.
* Bound archive recovery state with the sweep marker
Recovery held every committed conversation id for the life of the
request so the finally block could re-run project discovery after an
in-loop distinct gave up. Slicing that array into 500-id queries capped
the BSON command size but not the heap, so a very large history could
exhaust a worker mid-archive.
The archivedAt stamp already identifies exactly what this call
committed, so recovery is now one distinct scoped to it. That filter is
a prefix of the existing user/isArchived/archivedAt index, and the two
discovery call sites collapse into one filter-taking helper.
* Reconcile archive stats when a write outcome is unknown
A batch that commits but whose result never returns, a stepdown or a
connection drop between commit and acknowledgement, left archivedCount
at zero, so the finally block skipped both marker recovery and the stats
refresh. The chats were archived, so no retry could find them again: the
sweep filter no longer matches them and their projects kept stale
counts.
Both now key off the write attempt rather than the returned count.
Nothing else needs to change, because the marker is stamped by the same
write whose result went missing.
* Retry dropped project refreshes and guard stale pointer writes
Two ways a project could keep stale stats after archive-all.
A refresh that rejected was logged and dropped for good. Its chats are
archived, so no retry of archive-all can find them again to recompute
against, and the likeliest rejection is the recoverable one:
refreshChatProjectStatsForUser gives up when the project changed under
every compare-and-set attempt. Failures are now collected and replayed
once the rest of the run has stopped competing with them.
A save already in flight could also undo the sweep. Its conversation
document still said visible, so its tail took the pointer branch and
wrote lastConversationId back to a chat the sweep had just archived,
leaving the project advertising activity on a chat the workspace hides.
The pointer write now confirms the chat is still visible first, and
recomputes the project when it is not.
* Verify project pointers after the write, not before
Checking visibility before the pointer write only moved the race earlier:
a sweep landing between the check and the update still archived the chat
and cleared the project, and the write then restored it as
lastConversationId.
The check now runs after the write and repairs instead of preventing. A
sweep that lands earlier is caught here; one that lands later refreshes
the project itself, and refreshChatProjectStatsForUser compare-and-sets,
so it cannot commit a count it read before this write. Same single
indexed read as the check it replaces.
* Attach long pasted text as a file
Pasting more than 2500 characters into the composer now attaches the text
as pasted-text.txt instead of filling the message box. The text still
reaches the model in full: the attachment is routed to the context tool
resource, which inlines it verbatim. Shorter pastes and pasted files keep
their existing behavior.
Add a "Paste long text as a file" toggle under Settings > Chat > Sending,
on by default and persisted locally.
Number successive pastes so uploads, which dedupe on name, size and type,
do not reject a second paste that merely matches the first one's length.
handleFiles now reports whether files were accepted, so the "Attached as
text" toast is held until the attachment actually happens instead of
pairing a success message with a rejection error.
* fix: Respect long paste threshold
* fix: Preserve long paste semantics
* Fix long-paste upload failure recovery and copy
* Fix concurrent paste upload recovery
* Guard asynchronous paste recovery
* Fix long paste handling in the composer
* fix: skip delayed paste recovery in answer mode
* fix paste recovery cleanup on attachment removal
* fix paste recovery across drafts and reloads
* fix paste recovery isolation across side-by-side panes
* fix idle new-chat draft isolation and paste replacement recovery
* fix pane-scoped draft cleanup and multi-paste restore offsets
* fix paste recovery around run end, live uploads, and draft edits
* fix paste recovery when both sides of the caret were edited
* fix new-chat draft cleanup, pane-scoped abort recovery, and one-character snapshots
* fix paste persistence failures and pane-scoped file routing
* fix paste recovery before upload wait and blocked storage reads
* fix new-chat draft clearing, paste name collisions, and stale composer uploads
* keep the composer draft across late agent metadata refreshes
* resolve paste anchors by their unique intact junction
* anchor paste recovery to the junction nearest the captured caret
* honor the paste setting before file config lands and migrate pending drafts one copy at a time
* route pastes past the pending file config and chunk large recovery encoding
* sort imports in useAutoSave
The redis transport lane inherits the mock config's 10s assertion budget and
2 CI retries, both of which were tuned against the in-memory stream store.
Every stream event here crosses a real Redis round-trip, and the pause/resume
and rehydrate scenarios replay an entire job, so the same waits run much
closer to their budget than the memory shards ever do.
The suite is serial (`workers: 1`), so this is per-operation latency rather
than worker contention — which is why this lane reports flaky runs the memory
shards do not.
Raise the default assertion budget to 20s and allow one more retry for this
config only. The memory shards keep their current settings.
* 🏷️ feat: Add getEphemeralSender and Cover the Ephemeral-Id Format
* ♻️ refactor: Consolidate the Ephemeral Sender Chains
* 🏷️ fix: Decode the Ephemeral Sender for Persisted Messages
* 🏷️ fix: Mirror the Persisted Sender Chain in useGetSender
* ✅ test: Widen the Custom-Endpoint Fixture Type
* ✅ test: Expect the Spec Label in the Composer Placeholder
* 🏷️ fix: Resolve the Sender from Exact Labels, Not the Lossy Id
* 📱 feat: Swipe the Mobile Drawer Open and Closed
* 📱 fix: Harden the Drawer Swipe Against Interrupts, RTL, and Cold Mounts
* 📱 fix: Track the Initiating Touch and Settle Only What the State Confirms
* 📱 fix: Resolve Interrupted Drags to the Current State and Scope Overscroll
* ⚡ perf: Index the Conversation Fetch and Trim the Client Message Projection
* ⚡ perf: Memoize the Message Tree per Cache Write
* ⚡ perf: Serve Message Reads via the Trimmed Projection and an Ownership Probe
* ⚡ perf: Defer Collapsed Disclosure Bodies Until First Expansion
* ⚡ perf: Progressively Mount Long Threads from the Scroll Anchor
* 🩹 fix: Address Codex Findings on Retention, Anchoring, and Cache Bounds
* 🩹 fix: Poll the Oversized Export Precondition Through the Progressive Mount
* 🩹 fix: Keep Video Results in the Client Message Projection
* fix: reveal message metadata on hover, not on click
The message timestamp, the provider/model label crossfade, and the hover
action toolbar all revealed on `:focus-within` over the message row. A mouse
click sets focus, so clicking a tool card, an expand toggle, or a code block
button parked focus inside the row and pinned all three open long after the
pointer had left.
Key the focus half of each reveal on `:focus-visible` instead. A pointer
click no longer counts, while keyboard focus still does, so a sighted
keyboard user still reaches the model name and the timestamp by tabbing. An
action that opens a surface keeps the toolbar up through `hover-button-active`
as before.
* fix: fade the message row reveal instead of snapping it
The footer actions carried no opacity transition at all, so they arrived in a
single frame while the timestamp eased in behind them over 200ms and the
provider/model crossfade ran on the 300ms card-resize spring it had borrowed.
One hover, three different arrivals.
Put all three on the shared `duration-theme-normal` motion role with a common
ease-out, and add the reduced-motion guard the timestamp and the footer were
missing. `MinimalHoverButtons` now composes the shared reveal helper rather
than repeating its classes inline.
The reveal transition names `color` and `background-color` alongside `opacity`
because `cn` merges the whole `transition-*` group: a bare `transition-opacity`
would replace the `transition-colors` a `Button` contributes and the hover tint
would snap.
* fix: widen the message row keyboard-focus test
Two gaps in the `:focus-visible` reveal, both raised in review.
`:has()` never matches its own subject, so keying the reveal on
`:has(:focus-visible)` missed the row element itself. `MessageNav` moves the
reader by setting `tabindex="-1"` on the row and focusing it, which left a
focused row showing its focus ring while its timestamp, its model name and its
actions all stayed hidden.
Text-entry controls match `:focus-visible` even when a mouse clicks them, so a
click into the textareas `ToolApproval` and `AskUserQuestion` render inside a
row still pinned that row's metadata open with the pointer somewhere else. They
are excluded from the descendant half of the test. Every toolbar action is a
button, so a keyboard user still never focuses a hidden one.
Both halves are now one condition,
`:is(:focus-visible, :has(:focus-visible:not(:is(input, textarea, [contenteditable]))))`,
applied to the timestamp, the header label and the footer actions alike.
* fix: split the row focus test into two variants
Folding the row-itself and descendant halves into a single
`group-[&:is(...)]` made Tailwind emit a bare `.group$ { opacity: 1 }`, which
lightningcss refuses to minify. That failed the client CSS build and every job
downstream of it while jest and tsc stayed green, because neither ever builds
the stylesheet.
The condition is unchanged in behaviour, expressed as `group-focus-visible`
plus `group-has-[:focus-visible:not(:is(input,textarea,[contenteditable]))]`.
The plain CSS in style.css keeps the `:is()` form, which is valid there.
The stale string also had to come out of the specs: tailwind scans
`src/**/*.{ts,tsx}`, so a class literal in a test file reaches the production
stylesheet.
* fix: split the timestamp focus selector too
`:has()` nested inside `:is()` made postcss log "Failed to parse selector" on
every client build. The rule survived intact, but the warning was noise coming
from this change, and splitting it matches how the Tailwind side now expresses
the same condition.
Behaviour is unchanged: hover, a focused row and a mouse-clicked textarea all
measure the same as before.
* feat: allow users to toggle client-side image resizing
Client-side image resizing could only be configured in librechat.yaml and
defaulted to off, so users had no way to enable it for themselves.
Add a "Resize images before upload" toggle in Settings > Chat > Sending,
stored per device in localStorage. When librechat.yaml sets
clientImageResize.enabled, mergeFileConfig marks the value as enforced and
the toggle renders the admin value read-only. Admin resize parameters still
apply without locking the toggle when enabled is omitted.
Also fix shouldResizeImage, which compared file size against 10% of a 512MB
fallback limit and so only triggered above roughly 51MB. It now uses a 512KB
floor, which lets the setting affect everyday photos.
* fix: harden client image resizing
* fix(client): restrict image resizing and localize toast
* fix: harden client image resizing
* fix(client): recognize static WebP image chunks
* fix(client): clamp resized image dimensions
* fix(client): enforce safe image resize uploads
* fix(client): recheck duplicates after transforming uploads
* fix(client): keep selected files after input reset
* fix(client): disable image resizing when file config fails
* fix(client): decode resize candidates without a base64 copy
* fix(client): coordinate upload batches across hook instances
* test(client): drop the untyped conversation from shared upload state
* fix(client): start the config wait before queueing uploads
* fix(client): disable the resize switch while file config is pending
The switch stayed clickable during the initial file-config load even
though the checked state cannot update until that query settles.
* fix(client): only track upload reservations for observable state
* 💄 style: Align the Thinking Dot with the Header Icon
* 💄 style: Keep the Dot Nudge Logical and Gated to the Header Axis
* 💄 style: Route the Seeded Empty-Text Placeholder Through the Nudged Cursor
* ♻️ refactor: Guard MemoryArtifacts on Its Memoized List
* feat: record when a conversation was archived
The archived chats dialog has a "Date Archived" column that was bound to
createdAt, so it showed when the chat was created rather than when it was
filed away. Nothing recorded the latter.
Conversations now carry archivedAt, set on archive and cleared on
unarchive, and the column reads it. Chats archived before the field
existed have no stamp and fall back to createdAt, which is exactly what
that column already showed for them.
The archive view sorts on the new field. archivedAt is absent on every
previously archived chat, so the missing-value group is the common case
here rather than an edge case: the cursor's null handling, written for
titles, now covers both, and an absent stamp survives the cursor as null
instead of collapsing to the epoch and replaying the whole archive.
* fix: address review findings on the archived-at stamp
- Protect `archivedAt` from saveMessageToDatabase's unset sweep. Any
persisted field missing from endpointOptions is unset, so sending a
message in an archived chat cleared the stamp while leaving isArchived
true, silently dropping it into the legacy fallback group.
- Order the legacy group by the createdAt the dialog displays rather than
by last activity. The cursor's secondary key is now chosen per sort
field, so the fallback the cell renders and the order the server
returns cannot disagree.
- Put that secondary key in the archive index too, so paging the legacy
group does not fall back to a blocking sort.
* fix: keep archivedAt on a redundant archive request
Opening an archived chat and hitting the archive shortcut, or retrying
the POST, sent isArchived: true again and replaced Date Archived with
now. saveConvo now stamps only on the unarchived-to-archived transition
and still clears the field on unarchive.
* fix: make archive timestamp updates atomic
* test: type the archive race spy against the driver signature
* fix: archive without an aggregation-pipeline update
DocumentDB documents no support for pipeline-form updates on any engine version,
and the repository's compatibility assessment records that a prior P0 rewrote the
three that existed. Stamping archivedAt through a $cond pipeline reintroduced one,
which would have sent every archive and unarchive to the route's 500 handler on a
supported 5.0 deployment.
The conditional stamp is now a compare-and-set on isArchived, which keeps the
transition atomic without a pipeline: only the write that finds the chat unarchived
stamps it, so a duplicate or retried archive leaves the original date alone and an
unarchive that lands first is re-stamped. Schema defaults and createdAt-on-insert
go back to mongoose's own setDefaultsOnInsert and $setOnInsert, and tenantId is
once again stripped by the tenant-isolation plugin rather than by hand.
* fix: do not report a racing archive as a missing chat
Both conditional writes of the compare-and-set miss when the archive flag flips
between them: the chat was already archived when the transition write ran and
unarchived again before the already-archived write. saveConvo returned null for
a conversation that plainly exists, so POST /api/convos/archive answered 404.
Confirm the conversation is really gone before accepting that result, and retry
the pair when it is not. An unknown id still costs one existence read and falls
straight through to the 404.
* fix: resolve a fully contended archive to the chat's real state
Alternating archive and unarchive requests can split every attempt of the
compare-and-set: each transition write sees the chat archived and each
already-archived write sees it unarchived. Exhausting the retries therefore
proved nothing about whether the conversation exists, and the no-upsert archive
route turned a lost race back into a 404.
Read the conversation once more when the retries run out and answer with its
actual current state instead.
Pinning routed through saveConvo, which reads every message id for the
conversation and writes the whole array back just to set one boolean, and
can trigger a project-stats recompute on top.
None of that applies to a pin: it moves no chat between projects, changes
nothing the project workspace hides, and opens no retention window. A
dedicated setConvoPinned does the single findOneAndUpdate instead.
Measured against an in-memory MongoDB with the real message methods
wired in, on a 120-message chat: two driver commands and 3706 bytes
before, one command and 245 bytes after. The write scales with the
message count, so the gap widens on longer chats.
Archiving keeps using saveConvo, which it needs for exactly the project
stats and retention work a pin does not.
* fix: resolve context windows for Qwen3.5+ and newer model families
Qwen3.5 model ids matched the `qwen3` key and inherited its 40,960 token
window instead of their native 262,144, so pruneMessages dropped whole
conversations down to the system message once tool output grew large.
Add the 3.5 through 3.8 generations, Meta Muse Spark, Muse Glimmer and
Llama 4, plus newer DeepSeek, GLM, Kimi, Grok, Mistral, Nova, Cohere and
MiniMax entries. Llama 4 Scout is capped at 1M rather than its 10M native
ceiling, since no host serves near that and a 10M value would stop
pruning from ever firing.
findMatchingPattern now matches a vendor-prefixed id on its model segment
first. No map key contains a slash, so a prefix could only ever
contribute a spurious longer match: moonshotai/kimi-k2 matched moonshot
(8 chars) over kimi-k2 (7) and reported half the real window.
* fix: price newer models and make the pricing test helper faithful
Models added to the context map billed either at defaultRate or at an
older generation's rate. Llama 4 and Muse Spark fell to $6/1M, Kimi K3
undercharged 5x through the kimi key, and Grok 4.5/4.6 overcharged
through grok-4. Add rates for 21 models.
tx.ts needs no matcher change of its own, since it receives
findMatchingPattern by injection from @librechat/api. The vendor-prefix
fix therefore already corrects moonshotai/kimi-k2, which was matching
moonshot at $2.00/$5.00 rather than kimi-k2 at $0.60/$2.50.
The data-schemas test helper implemented a different algorithm from the
function production injects, returning the first reverse-order match
instead of the longest, so the pricing suite was not describing real
billing behavior. Mirror the real implementation. Every existing test
still passes, so nothing was relying on the lossy version.
Rates for the Qwen 3.x plus/flash tiers, DeepSeek V3.1/V3.2, Kimi
K2.6/K2.7 and Nova 2 Pro are left as they are, since published figures
disagreed by more than the current fuzzy match is wrong.
* test: assert parity between the context and pricing maps
Neither map imports the other, so a model added to one and forgotten in
the other is silently wrong at runtime: it bills at defaultRate, or it
has no window to prune against. A comment in tx.spec.ts claimed such a
test lived in packages/api, but none existed.
Exact key parity is the wrong invariant. Both maps resolve by longest
substring match, so gpt-4-32k legitimately prices through the 32k bucket
without a row of its own. Assert instead that every key resolves in the
other map, with exemption lists covering the genuine gaps and a further
test that fails once an exemption goes stale, so the lists shrink rather
than rot.
Verified the assertions actually fail: injecting a context key with no
price, a price with no context window, and a rate for an exempted model
each fails the expected test and names the offending model.
* fix: correct GLM 5.3, Kimi K3 aliases, Grok tiering and prefixed overrides
Four gaps found in review.
glm-5.3 matched the glm-5 key and reported 204,800 tokens rather than the
1,048,576 it shares with 5.2. Add both the context and pricing rows.
The dot-prefixed Kimi K3 ids were added to the context map without
pricing counterparts, so moonshot.kimi-k3 resolved to moonshot.kimi and
moonshotai.kimi-k3 to moonshot, billing completion at 2.5 and 5.0 rather
than 15.0.
Grok 4.5/4.6 carry a 500K window, which makes xAI's doubled rate above a
200K prompt reachable, but neither key existed in premiumTokenValues so
long prompts stayed on the base rate. Add the premium entries.
Matching the model segment of a vendor-prefixed id was too eager.
EndpointTokenConfig is an arbitrary record and one built from OpenRouter
is keyed by org/model, so an alias like org/model-latest could resolve to
a bare model entry and pick the wrong configured context and rates. Keep
a matched key that carries the vendor, and retry on the segment only when
the whole id resolved to a prefix-only match.
* refactor: type the model matcher as keys-only and drop the caller casts
findMatchingPattern reads keys and never inspects a value, but its
parameter was typed as a union of the two maps it happens to be called
with, so every injection site cast into it. pricing.spec.ts already
carried that cast and parity.spec.ts copied it.
Name the intent instead. Both adapters now pass their map through
unchanged, and a future change that starts reading values will fail to
compile rather than slip past a cast.
* fix: stop pinning and archiving from counting as chat activity
Both routes went through saveConvo, which lets mongoose stamp updatedAt.
The sidebar orders chats by that field, so pinning hoisted an untouched
chat to the top of Today, and unarchiving a year-old chat dropped it
there too instead of back into its own date group.
saveConvo now takes preserveUpdatedAt, and both routes pass it. They also
pass noUpsert: with timestamps suppressed an upsert would insert a
conversation carrying neither createdAt nor updatedAt, so an unknown
conversation id is now a 404 rather than a silently created stub.
* test: pin a project's activity pointer against metadata-only saves
Review raised that preserving updatedAt could drag a project's
lastConversationAt back to the pinned chat's older timestamp, since the
incremental path $sets it outright.
That path is not reachable here: a pin carries no chatProjectId, so
previousChatProjectId stays null while the conversation has a real one,
projectMembershipChanged is therefore true, and saveConvo takes the full
recompute branch instead. This test holds that in place, with a newer
sibling conversation in the project so a regression to the incremental
path would fail it.
* fix: keep updatedAt through the retention backfill
Under RetentionMode.ALL a legacy chat with no stored isTemporary gets a
second write after the main update, and that one still had mongoose
timestamps enabled. The first archive of such a chat therefore bumped
updatedAt anyway and landed in Today, defeating preserveUpdatedAt on
exactly the old conversations it was meant to protect.
* ⏱️ feat: Show Run-Step Durations On Tool Cards
Surfaces how long each tool call took, derived from the `closed_at` /
`created_at` pair already carried by `on_run_step_closed` — the same event
#14871 and #14873 use for the terminal status. No new event, no new SDK
surface.
The duration is stamped onto the content part at the same three sites as
`runStepStatus`, so it survives a reload and a resumable reconnect rather
than living only on the live React message:
- `callbacks.js`, on the aggregated part before the event is forwarded
- `RedisJobStore`, in the host-authored replay reconstruction branch
- `useStepHandler`, on the live message
Rendering lands in the shared `ProgressText`, which nine tool cards already
use, rather than in each card: one place decides whether a duration is shown
and how it reads, and the cards only forward the number. That keeps this from
adding a tenth independent state derivation to a component family whose
label/announcement/progress split is already the subject of AI-1810.
The value is deliberately absent rather than zero whenever it would be a
guess — no `created_at`, non-finite input, or a negative elapsed time from
two clocks that disagree, which is now reachable because a step can be opened
in one process and closed in another after a checkpoint resume. Sub-second
durations are suppressed as noise, and it renders only on a settled,
non-error card, where the slot is not already carrying the cancelled icon or
the error suffix.
For assistive technology the compact form (`3.5s`) is hidden and paired with
a spoken equivalent ("took 3.5 seconds"), both inside the button, so the
accessible name carries the duration without an `aria-live` region
re-announcing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🎨 style: Sort Imports In Touched Files
The import-sort gate runs against the files a PR changes, so pre-existing
drift in `ProgressText.tsx` and `RedisJobStore.ts` surfaced on this branch.
Both were already unsorted on `dev`; this is the sorter's output, with no
semantic change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🐛 fix: Accept Partial Timestamps In Run-Step Duration Helper
`getReportableRunStepDurationMs` declared its parameter as
`Pick<RunStepClosedEvent, 'created_at' | 'closed_at'>`, where `closed_at` is
required. That contradicted the function's own purpose: every guard inside it
exists precisely to handle stamps that may be missing.
The Redis replay branch reconstructs closures from persisted JSON and holds
nothing stronger than "might be a number", so it failed to typecheck against
the narrower signature.
Widened to an exported `RunStepTimestamps` shape with both stamps optional,
rather than asserting at the call site — an assertion would move the decision
about what is trustworthy somewhere it cannot be enforced, which is the thing
the helper exists to centralize. Callers holding a fully-typed event still
pass, since a required field satisfies an optional one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🐛 fix: Suppress Duration When Failure Arrives As errorSuffix Alone
At every call site `error` carries cancellation while failure travels
through `errorSuffix` with `error` false, so gating the duration on
`!error` alone rendered "· 3.5s" beside "· failed" — and announced it.
The gate now checks both terminal-failure channels.
The original test pinned only the `error: true` path, which is why this
survived; the failed-via-suffix path is now pinned separately, both the
visible and the announced half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🧩 refactor: Persist Raw Run-Step Durations, Threshold At Render Only
The three stamp sites filtered through the 1-second reportability
threshold before persisting, baking a presentation rule into stored
data: a 900ms step stored nothing, making "fast" indistinguishable from
"not derivable" and unrecoverable if the display rule ever changes.
Stamp sites now persist the raw `getRunStepDurationMs` value — absent
only when genuinely not derivable — and the renderer alone decides what
is worth showing, which `ProgressText` already did. Rendering is
unchanged. `getReportableRunStepDurationMs` is removed; it existed only
to serve the write-time filter, and a test now pins that sub-threshold
durations survive to storage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🐛 fix: Suppress Duration On Backgrounded Bash And Code Cards
A backgrounded call's run step closes when dispatch returns the handle,
so the stamped duration is the dispatch time. Rendering it beside
"Running/Finished in background" misstated a detached task's runtime as
seconds — and violated the "settled card only" rule, since the card is
still tracking the detached run. Scope is exactly the two cards that
parse background handles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🌍 fix: Format The Sub-10s Decimal For The Active Locale
The fractional seconds value was interpolated as a raw JS number, which
hardcodes the en-US decimal point into every language — "1.4s" where
the locale writes "1,4 s" — and translators cannot fix a number
formatted in code. The value is now formatted via Intl.NumberFormat
with i18n.language, following MessageTimestamp's pattern of threading
the language into the util; plural-key selection stays on the numeric
value. A malformed language tag falls back to the plain number.
Also documents the two accepted limits of the derivation, so they read
as decisions rather than oversights: positive clock skew is
undetectable from a single stamp pair, and the value is wall-clock
elapsed, so a step held open across a suspension (checkpoint resume,
HITL approval wait) includes that time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🐛 fix: Persist A Durable `backgrounded` Marker Through Harvest; Localize Minute Digits
Codex round 3, both findings confirmed.
**Background origin survived only as transient state.** The dispatch
handle in `tool_call.output` and the live status-marker attachment are
both gone once the harvester patches the settled task's stdout over the
handle — so the round-2 suppression (`backgroundHandle == null`) came
back on after harvest or reload, showing dispatch time as the task's
runtime. Following the same rule as e4bd15d (persist facts, decide at
render): the harvest patch now stamps `backgrounded: true` onto the
tool call in the same atomic write that erases the handle — on the heal
path too, which re-applies over full-row saves that reverted the part.
The cards gate on handle-or-marker; the dispatch duration itself stays
stored.
**Minute-branch digits bypassed locale formatting.** The seconds branch
went through Intl.NumberFormat while minutes interpolated raw numbers,
so Arabic/Persian locales flipped to ASCII digits above one minute. All
interpolated values now flow through the (renamed) formatDurationValue;
an ar-EG test pins the localized digits.
data-schemas cannot be installed in this environment (same npm ci 403 as
packages/api), so message.ts/harvest.ts are syntax-checked with
resolution off and otherwise verified by review; CI runs their real
typecheck and suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🧪 test: Assert The `markBackgrounded` Stamp In Harvest Expectations
The successful-harvest test's exact `toHaveBeenCalledWith` object did
not include the newly forwarded `markBackgrounded`, so the API suite
would fail on it. All three harvest-call expectations now assert
`markBackgrounded: true` — the exact-object one of necessity, the two
`objectContaining` ones deliberately, since the durable stamp (on the
best-effort file-failure path and the reapply heal alike) is now part
of the behavior under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🎨 style: Wrap Harvest Spec Expectation Per Prettier
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
---------
Co-authored-by: Claude <noreply@anthropic.com>
* 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
* feat: give the pinned chats section its own fetch
The sidebar's pinned section filtered pinned chats out of the paginated
chats list, which only holds the 25 most recently updated conversations.
Once 25 newer chats existed, a reload hid the pin until the list was
scrolled far enough to fetch the page it lived on.
Pins are now fetched directly via GET /api/convos?pinned=true behind a
dedicated query, so every pin paints with the sidebar regardless of where
it falls in the chats list. Pin and unpin invalidate that query, and the
shared conversation cache helpers keep it in step so a rename, delete or
archive is reflected without waiting for a refetch.
Pins stay out of the date groups, which groupConversationsByDate already
handled.
* fix: address review findings on the pinned chats section
- Drain the cursor rather than capping the pinned request at 100. Since
pins are kept out of the chats date groups, anything this query dropped
was invisible in the sidebar entirely, not merely further down a list.
- Apply the active bookmark filter to the pinned request and key its
cache by it, matching the chats list beside it.
- Move a pin to the top of the section when the caller asks for it, so a
pin that just received a message leads the way it does in the chats
list instead of waiting for a refetch.
- Invalidate the pinned list when a conversation is unarchived, since
archiving removes it from that cache and nothing put it back.
- Index the pinned lookup: it filters on user + pinned and sorts by
updatedAt, which no existing compound index covered.
- Protect `pinned` from saveMessageToDatabase's unset sweep. Any
persisted field missing from endpointOptions is unset, so sending a
message in a pinned chat silently unpinned it.
* fix: keep the pinned cache reconciled across the other convo mutations
Second review pass on the independent pinned query.
- Fall back to the pins already loaded in the chats pages when the
dedicated request fails. Pins are stripped from the date groups, so an
error otherwise emptied the section and hid them everywhere.
- Restore default focus and reconnect refetching, matching the
conversations query. A pin changed in another tab is only reconciled by
a refetch, since that tab's mutation never touched this cache.
- Invalidate the pinned list from the mutations that can produce or alter
a pinned chat without going through pin itself: duplicate, fork,
import, project assignment, and shared-link deletion.
* fix: invalidate pins on tag and project-deletion changes
Third review pass, same class as the last: the pinned query is keyed by
the active bookmark filter, so changing a chat's tags can move it in or
out of that filtered set, and deleting a project unsets chatProjectId on
its chats, pinned ones included.
* fix: cancel in-flight pinned fetches when deleting a conversation
Deletion cancelled the regular and archived queries but not the pinned
one, so a pinned GET issued before the delete could resolve after the row
was stripped and write the deleted conversation back, leaving a row that
navigates to a missing chat. Restoring default focus and reconnect
refetching in the previous commit made those in-flight fetches more
likely, so this widened rather than appeared.
Cancelled on mutate, and invalidated on success since cancelling a race
is best effort.
* test: make the SSE query-cache mock key-aware
The conversation cache helpers now run a second, pinned-keyed findAll
pass. This mock ignored its key argument and always returned an
allConversations entry, so those pinned writes were attributed to
allConversations and the write-count assertions saw three instead of two.
* fix: keep pins in sync through upsert and pin-only pages
Root-level SSE updates and resumable settlement call upsert rather than
update, so the independently cached pinned row never moved or refreshed.
An all-pin first page also left the chats virtual list empty, so
onRowsRendered never asked for the next cursor.
* fix: keep pins current through SSE recovery and project delete
Resumable SSE reconciliation invalidated conversation and allConversations
only, so an independently cached pin kept stale title and order.
Deleting a project-backed pin that lived only in that cache also skipped
the project query, because the mutation never read chatProjectId there.
* fix: keep pins current after bookmark edits and failed pages
Renaming or deleting a bookmark rewrote tags on conversations but left
the tag-keyed pinned cache pointing at the old filter. An all-pin page
whose next fetch failed also retried forever because the empty-list
effect had no memory of the attempt. Unpinning a pin that only lived in
the dedicated cache removed it from Pinned without inserting it into
Chats, and later cursor pages cannot recover a row whose updatedAt just
jumped ahead of the current cursor.
* fix: keep pins visible after a failed refetch
A failed pinned refetch left React Query holding the previous list, so
the nullish fallback never ran and a newly pinned chat vanished from
both sections. Unpinning an older pin also inserted it into every
cached chats variant, including bookmark and search results it would
not match. Drop the checked-in agent task prompt.
* test: type the pinned conversation fixtures correctly
The delete mutation takes a plain string conversationId, but reading it
back off a TConversation fixture widens it to string | null. Hoist the id
into its own constant so the call site passes the real string.
Type the tag fixture as TConversationTag so it carries the required _id
and user fields the mocked resolved value expects.
* style: sort the sidebar imports to the repo order
The new pinned-section imports went in out of the longest-to-shortest
order the import sorter enforces.
* fix: keep drained pins and empty chat caches from breaking the sidebar
A pinned page failing partway through the drain rejected the whole query, so
every pin already fetched was discarded and the section fell back to whatever
the chats cache happened to hold. Publish the accumulated pins before
rethrowing so the retry renders against the partial set.
Unpinning a chat that only lives in the pinned cache reinserted it into the
chats list by spreading the first page, which is absent once removal has
filtered out the last loaded row. Rebuild that page instead, matching the
upsert path.
* fix: order fallback pins by their timestamp
The merge kept dedicated rows in Map insertion order and appended the pins
recovered from the chats cache after them. A chat pinned while the dedicated
refetch is failing is the newest pin, so the server would return it first, yet
it landed last and could sit below the section's visible 30vh. Sort the merged
set newest-first so a fallback row takes the place the server would give it.
* fix: keep the shared badge and the move-to-top order on pins
The pin response has no isShared: the flag is derived per list request by
attachSharedFlags, which only runs for the list queries. Reinserting an unpinned
chat into Chats therefore dropped its shared-link badge, because unlike an
in-place update there is no existing row to carry the flag over from. Read it off
the cached pin before the update removes that row.
The chats cache refreshes updatedAt when it moves a conversation to the top, but
the pinned cache only reordered, leaving the previous turn's timestamp on the row.
Sorting the section newest-first then put it straight back. Refresh the timestamp
there too, so the move survives the sort and both caches agree.
* 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.