Commit graph

2466 commits

Author SHA1 Message Date
Yorgos K
9f8d71a3c5
🪢 fix: Preserve Response Identity and Branch During Resumable SSE Sync (#14788)
* fix(client): preserve resumable response identity

Fixes #14787

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(client): align resumable sync regressions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(client): clarify resumable response ownership

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(client): preserve resumed regeneration ordering

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(client): cover missing resumed response row

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(client): preserve resume identity on page reload

* fix(client): replace reassigned resume placeholder

* fix(client): preserve content during response id handoff

* fix(client): limit resume placeholder handoff

* fix(client): preserve resume display metadata

* fix(client): reconcile resume metadata in one pass

* fix(client): reconcile preliminary resume user

* fix(client): restore regenerated branch on early abort

* test(client): cover external regeneration resume

* fix(client): preserve regeneration history on errors

* fix(client): replace reused regeneration error ids

* fix(client): preserve exact-id regeneration rollback

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 14:25:03 -04:00
Oliver Faust
b399ad8370
📄 fix: Serve Stored Text for "Upload as Text" File Downloads (#14723)
* 📄 fix: Serve Stored Text for Text-Source File Downloads

"Upload as Text" attachments store extracted content in the DB with
source 'text'; OCR uploads persist the OCR strategy name (e.g.
'mistral_ocr') as a filepath placeholder since no backing file exists.
The download route resolved these records to the local strategy and
passed the placeholder to fs.createReadStream, which failed with
ENOENT — and the response was never ended after the stream error, so
the request hung until the client timed out.

Serve the stored text directly as a .txt download for text-source
files (re-fetched by _id, as getFiles excludes 'text' by default),
and end the response on stream errors: 500 without the download
headers before headers are sent, otherwise abort the truncated
response so clients detect the failure.

* fix: Preserve text-source preview semantics

* fix: Complete text-source download coverage

* fix: Tie text downloads to blob lifecycle

* fix: Isolate preview downloads and share text snapshots

* fix: Keep shared previews in share scope

* style: Sort text download imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 14:24:43 -04:00
Danny Avila
d602452c05
🪪 fix: Support MCP Server Titles With Hyphens (#15094)
* fix: Support MCP Display Titles With Hyphens

* fix: Preserve Legacy Regex Target Compatibility
2026-08-21 12:44:31 -04:00
Danny Avila
5a598a138f
🔭 fix: Attach to Runs This Pane Did Not Start (#15074)
* 🔭 fix: Attach to Runs This Pane Did Not Start

A run started somewhere else — another tab, another device, a scheduled
trigger — announces itself to this client only through the user-scoped active
job list. Nothing consumed it for attachment: `useActiveJobs` feeds the
sidebar's generating indicators and a `hasActiveJob` hint inside the messages
query, and that is all.

That left the status query as the only path to an attachment, and it closes
for the rest of a conversation's mount the moment it has answered inactive
once, because `processedConvoRef` is set on that answer. So a pane already
sitting on a conversation when a run begins elsewhere never attaches, never
refetches (the messages query disables refetch on focus, mount and reconnect),
and shows history it cannot see has moved on — until a reload or a navigation
remounts the query. Worse than the stale render: a send from that pane derives
its parent from the stale tail and forks a sibling branch. The two send-time
staleness guards in `useChatFunctions` do not fire, because nothing invalidated
this pane's cache, so it looks fresh.

Re-arm the status query when the viewed conversation appears in the active
list. The announcement is consumed once per run rather than held open — a job
stays listed for its whole lifetime, and re-opening on every poll would turn a
five-second heartbeat into a five-second status read — and released when the
run leaves the list so the next one re-arms in turn.

* 🩺 fix: Make the External-Run Re-Arm Survive Warm Caches and Back-to-Back Runs

Five gaps between the announcement and the attachment it was supposed to
produce, none of which the happy-path test could see.

The announcement could never arrive. `useActiveJobs` disables its interval
while nothing is listed and `refetchOnWindowFocus: true` refetches only stale
queries, so a run another client started inside the five-second `staleTime`
window was invisible on return to the tab — the exact sequence this is for.
Focus refetches unconditionally now.

Re-arming could consume a stale answer. Toggling `enabled` only fetches when
the cached data is stale, and `useStreamStatus` holds `staleTime: 1000`, so an
inactive status answered moments earlier was replayed as "nothing running" and
recorded as handled. The re-arm invalidates the status query rather than
trusting the toggle.

Attaching could graft onto a hole. An external client may have completed whole
turns this pane never saw before starting the one now running; the resume
submission and `finalHandler` both build on the local snapshot. And when the
announced run turned out to be already terminal, nothing refreshed history at
all — the messages query disables refetch on focus, mount and reconnect, so
those turns simply stayed missing and a send from here still forked. The
re-arm invalidates history too, which also re-gates `messagesLoaded` so the
check waits for it.

Consecutive runs could be missed. A latch released by observing the list empty
never releases when a second run starts before the next poll, since the list
reads the same throughout. Rate-limit to the list's own heartbeat instead,
keyed on `dataUpdatedAt` — structural sharing keeps the payload reference
stable across identical refetches, so only the fetch stamp moves.

Wiring, found by these tests rather than by review: clearing a ref neither
schedules a render nor re-runs an effect, so the arm is a state value the
check depends on.
2026-08-21 12:14:12 -04:00
Marco Beretta
f5f462a1c6
🫥 feat: Add Temporary Chat Empty State and Active Indicator (#15086)
* feat: add temporary chat empty state and active indicator

Temporary Chat gave users a toggle but no page-level confirmation that
they had entered the mode or what it changes. The only cue was the
toggle's pressed state, which is easy to miss, and the toggle itself
retires once the conversation starts, leaving an active temporary chat
with no indication at all.

The landing now swaps its identity block for a temporary-chat empty
state: a dashed message icon, a "Temporary Chat" heading, and a line
explaining that the chat stays out of history and is deleted
automatically. It clears on its own once the first message is sent,
since the landing unmounts at that point.

useTemporaryChat gains isActive for the window where temporary mode is
locked in for a conversation in progress. TemporaryChatIndicator renders
exactly then, so the toggle and the read-only pill never overlap. It is
shown at every breakpoint, collapsing to the icon alone below md while
keeping its accessible name.

The copy matches actual behavior: buildRetentionVisibilityFilter keeps
isTemporary conversations out of the list query, and temporary chats are
stamped with expiredAt from temporaryChatRetention.

* fix: keep temporary conversations out of the sidebar and compose the status pill

The empty state told users a temporary chat would not appear in their
history, but the client seeded it into the conversation list caches
anyway, so the chat sat in the sidebar for the rest of the session until
a refetch or reload dropped it. The history query already excludes
temporary conversations server-side, so the copy described the intended
behavior while the UI contradicted it.

Temporary mode lives on the submission rather than on the draft
conversation, so the optimistic record never carried the flag and every
consumer of that cache entry read a new temporary chat as an ordinary
one. It is now stamped onto the optimistic conversation, only when true
so the legacy expiredAt inference is untouched, and the sync handler
gains the same isTemporary guard the title handler already had.
upsertConvoInAllQueries refuses temporary conversations outright, which
holds the invariant at one point rather than at each caller.

The header indicator now composes the shared Chip primitive instead of
hand-building a pill. Its theme size and shape tokens resolve to the
same 2.25rem height, 0.75rem radius and 0.375rem gap the local classes
hardcoded, so the appearance is unchanged while the indicator follows
future theme work. It also carries role="status" so the mode change
reaches assistive technology, which matters below md where the label is
visually hidden and only the icon remains.
2026-08-21 11:35:12 -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
Danny Avila
393742016e
🔀 perf: Swap the Transcript With the URL on Conversation Switch (#15054)
*  perf: Swap the Transcript With the URL on Conversation Switch

Switching conversations left the PREVIOUS transcript painted under the new
URL. Two things on the critical path caused it, both fixed here.

`RouterProvider` commits location updates inside `React.startTransition` by
default in react-router v7, and a transition keeps the outgoing tree on
screen until the incoming one has fully rendered — so every millisecond the
next thread took to render was time spent looking at the previous one, and
React yields during that render, stretching it well past its CPU cost.
Nothing here reads route data through router loaders, so the transition
bought no pending UI; conversation state also still lives in Recoil, whose
transition-safe reads are gated behind `_TRANSITION_SUPPORT_UNSTABLE` hooks
this app does not use. `useTransitions={false}` puts the route change back
in the click's own task.

`navigateToConvo` also awaited `GET /api/convos/:id` before calling
`navigate()`, so the route did not change until a full server round trip
completed. The clicked row already carries its conversation, so the route
and conversation state now change together and the refetch reconciles
afterwards. The row is a list projection, so any previously fetched full
record underlays it — prompt prefix, sampling params and files survive the
switch, and a send during the reconcile window still carries the real
settings.

Measured on the built client with a 250ms conversation-fetch latency,
switching between two 30-turn conversations:

  before  cold  click→url 527ms  click→paint 931ms  14 stale frames (297ms)
          warm  click→url 474ms  click→paint 838ms  12 stale frames (277ms)
  after   cold  click→url ~190ms click→paint ~450ms  0 stale frames
          warm  click→url ~280ms click→paint ~280ms  0 stale frames

The warm switch now paints the new transcript in the same commit as the URL.
The warm-cache message loading this depends on is untouched.

Adds `e2e/benchmarks-navigation`, a react-scan benchmark that guards the
result: an in-page sampler records the route and the mounted conversation
once per animation frame, so a frame pairing the next URL with the previous
transcript is caught directly. The react-scan harness the reasoning
benchmark had inlined moves to `e2e/perf/scan.ts` and is now shared.

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

* 🎯 fix: Resolve Sidebar Rows by Their Accessible Button in the Nav Benchmark

The a11y pass on the sidebar moved the conversation row's `role="button"`
and `aria-label` off the `convo-item` container and onto a real `<button>`
that `ConvoLink` renders inside it. The benchmark's click helper required a
single node carrying both the testid and the label, so after merging dev it
found nothing and threw.

Match on whichever node inside a row carries the label and let the click
bubble to the container's handler, which still owns the navigation.

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

* 🛡️ fix: Close Three Navigation Races Found in Review

Codex review of the optimistic-navigation path found three real defects.

Superseded reconciliations were written unconditionally. Selecting B then C
before both records settled let B's response land last and restore B into
conversation state while the route and transcript showed C — and sends read
from that state, so a user could submit into a conversation they were no
longer looking at. Navigations now claim a shared token before any await and
late responses are discarded. The token is module state rather than a ref
because every sidebar row mounts its own hook instance, so a ref cannot see
that a click on a different row superseded this one.

The first visit to a conversation installed the sidebar row as active state.
That row is a projection without prompt prefix, sampling params, tools or
files, so the composer became usable with settings that silently fell back to
defaults. Only a conversation whose full record is already cached now takes
the instant path; the first visit keeps the previous behavior and moves the
route once the record is in hand. Every later switch to it is instant, which
is the case this PR set out to fix.

A failed record fetch removed the target's message cache even though, after
optimistic navigation, that query is already mounted — a transient error
could cancel an in-flight history fetch, or discard one that had succeeded,
with no route change left to remount it. That removal is now limited to a
conversation confirmed gone, and the first-visit path still clears before the
route moves, where a fresh mount follows.

The benchmark's round-trip assertion was also unfalsifiable: nothing delayed
the record request, so an implementation that awaits it still answered inside
the threshold. It now holds that request open and asserts the warm switch
completes while it is unresolved, which no wall-clock bound can fake.

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

* 🧭 fix: Tie Pending Navigation Work to the Route, Not a Token

Codex found that the navigation token only tracked calls made through this
hook. Every other way out of a conversation — `useNewConvo`, a link, a
redirect, the back button — moves the route without touching it, so a record
still in flight for the conversation being left passed the guard. On the
cached path that overwrote the new route's conversation state; on the
first-visit path it was worse, calling `navigate()` and pulling the user back
into a chat they had already left.

The token was the wrong question. What makes pending work still wanted is not
"was this the last conversation clicked" but "is the user still where they
were when it started" — and only the browser's own location sees every way
that can change. Each async step now captures the route before its request
and re-reads it before writing, which subsumes the superseded-click case the
token was added for and removes the module state entirely.

Reading `window.location` directly rather than `useLocation` keeps this free
of subscriptions: every sidebar row mounts this hook, so subscribing would
re-render all of them on every navigation — the cost this hook exists to
avoid. Comparing pathname against pathname also makes the basename cancel.

The tests move from `MemoryRouter` to a real history, since the mechanism is
now the browser location itself, and cover both bypass paths.

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

* 🔢 fix: Keep the Last Click Authoritative Across First-Visit Navigations

Codex found that the route guard cannot separate two first-visit clicks from
each other. That path deliberately leaves the route where it is until the
record arrives, so clicking two uncached conversations in quick succession
has both requests capture the same pathname — whichever the network answered
first then navigated, and the later click was discarded. Response order
decided where the user landed instead of click order.

Restores a generation counter alongside the route check. Claiming the last
PR's removal of the token as a subsumption was wrong: the two guards answer
different questions and neither covers the other. The generation says "a
newer intent replaced this one"; the route says "the user left by some means
this hook never saw". Both are needed, and both are cheap.

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

* 🧹 refactor: Stop Writing Server Snapshots Into User-Owned Conversation State

Four review findings on this branch were all the same defect: navigation
started a background fetch and wrote its result into the conversation atom.
That atom is user-editable — model, endpoint, prompt prefix, sampling params
— and the target chat is interactive from the moment the route changes, so a
late write races the user and every other writer. Each round added another
predicate to the write ("is this still the last click?", "is the user still on
this route?"), and each predicate left one more writer uncovered; the last one
is a setting picked on the same route by the same navigation, which no
ordering or route guard can see.

Remove the write instead of guarding it. The warm path refreshes the React
Query cache and stops there, so the optimistic merge that lands with the route
is the navigation's last word. The refreshed record is consumed by the next
switch to that conversation, which is where a cached record is read anyway.

This also dissolves the queued-focus finding: `applyConversation` (and its
`requestChatFocus`) is now reachable only from paths that navigate, so a focus
intent can no longer outlive the navigation that requested it.

Scope the synchronous route commit to conversation switches. `useTransitions`
on the provider disabled transitions for every route, including the lazily
loaded prompts, skills, insights and project screens, where yielding to input
during a large first render is worth more than an atomic swap. The opt-out
now travels per navigation as `chatNavigation` (`flushSync`), applied in
`useNavigateToConvo` and `useNewConvo`.

Tests: the four behavioural guards fail against an implementation that
restores the background write, including a new case where the user picks a
model while the refresh is in flight.

* 🎯 fix: Decide Route Commit Once, and Keep Refreshed Settings Refreshed

Reverts the per-navigation transition opt-out from the previous commit. Review
asked to scope `useTransitions={false}` to conversation switches, and I scoped
it by passing an option at the call sites I knew about — then immediately
missed one: `finalHandler` promotes `/c/new` to the server-assigned ID and
navigates without it, so the atom identifies the real conversation while the
route and message query still say `new`.

That is not a missed call site, it is the wrong shape. Fourteen call sites
across components, chat hooks and SSE handlers navigate into `/c/*`; an opt-out
carried by each one is a list that rots as call sites are added, and five of the
fourteen were covered. The property is route-shaped, so the decision goes back
to the one place that sees every navigation. Answering the original critique on
its merits: nothing in the app reads route data through router loaders or
renders pending UI from `useNavigation`, so the transition produces no
interstitial on any route — it only defers the commit, which on the chat route
is the bug this PR exists to fix.

Two conversation fixes alongside it:

Sidebar rows no longer reinstate settings the background refresh replaced. The
row projection carries `endpoint`, `model` and `spec`, and the warm path
spreads the row over the cached record — so a row from before an edit made on
another device would undo that edit on every switch until the list refetched.
The refresh now merges the record into the list cache, which is what made
"picked up on the next switch" true rather than merely intended.

Starting a new chat now supersedes a pending first visit. "New chat" from
`/c/new` lands on `/c/new`, so the pathname is unchanged and the record for a
conversation the user just abandoned would land and pull them into it. The
navigation counter is exported as `supersedeNavigation` and called from
`useNewConvo`. Deliberately not called from the stream recoveries in
`useEventHandlers`/`useChatFunctions`: those are the app reacting, not the user
changing their mind, and they should not cancel a conversation the user opened.
Intent is a closed set; navigation is not.

Both new tests fail against the implementation they guard.

* 🧷 fix: Keep the Record Refresh Off List State and Off Background Composers

Three fixes to the previous two commits, all the same underlying mistake in
different places: something that started earlier landing on top of something
the user did later.

The list-cache write added last commit merged the whole fetched record into
every sidebar and pinned row. That response is a snapshot from before the
target was interactive, and the list is where renaming, pinning and sharing
land — so a rename completing while the request was in flight was silently
undone. This is the same stale-snapshot-over-live-state mistake the refresh
had just stopped making against the conversation atom, reintroduced one layer
down. It now writes `endpoint`, `model` and `spec` only, which is what the
staleness it exists to fix is about, and which no list mutation touches.

The route comparison ignored the query string. `/c/new?projectId=A` is a
different conversation scope than `/c/new`, and the landing chip re-scopes a
draft by writing the atom and rewriting search params in place — never through
a conversation hook, so neither the pathname nor the recorded intent moved. A
pending first-visit record would then land on the draft the user had just
re-scoped. The comparison now includes `search`.

Superseding moved from `switchToConversation` into `newConversation`, guarded
by `keepComposerState`. That flag marks a call that re-renders a composer an
earlier call already opened — agent metadata arriving late, for instance. The
user asked for nothing there, so it must not cancel a conversation they clicked
while it was in flight. `switchToConversation` has no callers outside this
hook, so the move loses no coverage.

The first two are covered by tests that fail against the implementation they
guard. The third is verified by inspection: exercising it needs the whole
`useNewConvo` provider tree, which is disproportionate for a one-line guard.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 11:28:18 -04:00
Marco Beretta
29f6ec6eae
🙈 feat: Config Option to Hide Response Feedback Buttons (#15085)
* feat: add interface option to hide response feedback buttons

Adds `interface.feedback` to librechat.yaml. When set to false, the
thumbs up/thumbs down buttons are removed from the message action row
and the feedback endpoint rejects writes with 403, so deployments that
do not consume the data can stop collecting it. Defaults to true.

* refactor: hoist the feedback gate out of the message row and into typed middleware

Reading startup config inside HoverButtons put a query observer and two Recoil
subscriptions on every message row, and rows never unmount, so the cost grew
with the conversation. Resolve the flag once per chat in useChatHelpers and
carry it on TMessageChatContext; useMessageActions withholds handleFeedback
when it is off, which the action row already treats as "no feedback controls".
The flag now stays false until the config resolves, so a disabled deployment
never flashes controls whose writes are rejected.

Move the server-side policy into requireFeedbackEnabled under packages/api so
the route keeps no policy of its own.

* test: stub the feedback gate in specs that replace the api package

The messages router now imports requireFeedbackEnabled, and express rejects an
undefined handler at require time, so every spec that mocks @librechat/api
wholesale has to carry the export.
2026-08-21 11:16:44 -04:00
Danny Avila
876a087558
🛰️ feat: Show Child Agent Activity in a Side Panel (#15075)
* feat: add parent-scoped subagent thread reads

* fix: tighten child thread read bounds

* perf: project child activity messages

* test: update child activity route fixtures

* test: satisfy response mock types

* fix: bound child activity reads at storage

* style: sort child activity imports

* fix: bound child activity storage reads

* feat: show child activity in a parent-owned panel

* fix: preserve side panel identity

* fix: refresh child activity safely

* fix: follow the selected child task

* test: update child panel fixtures
2026-08-21 11:10:41 -04:00
Danny Avila
8c14f03432
🗂️ feat: Scope Scheduled Chats to Chat Projects (#15056)
* 🗂️ feat: Scope Scheduled Chats to Chat Projects

Adds an optional chat-project destination to a schedule, plus the operator
config to require one — or to pin every scheduled run to a specific project.

Feature:
- `chatProjectId` on the schedule row, accepted on create/update, projected on
  the wire, and carried into the run's conversation through the durable trigger
  envelope's `run` context.
- `interface.schedules.requireProject` refuses schedules that are not filed
  under a project; `interface.schedules.projectId` pins every run to one
  project and implies the requirement.
- Dialog gains a project picker (required when configured, a read-only row when
  pinned); the card shows the destination and the new disabled reasons.

Invariants:
- ONE resolver (`resolveScheduleProjectId`) decides the destination for the
  write handler, the fire path, and the wire projection alike, and an operator
  pin OUTRANKS the stored id in all three. Tightening the config therefore
  redirects — or stops — existing schedules instead of grandfathering where
  their runs land. A pin implies `requireProject` for the same reason: without
  it, a row created before the pin would keep firing with no project at all.
- Create/fire precheck symmetry, mirroring `resolveAgentFireAccess`: a write
  this handler accepts is one the next fire also accepts. Any edit leaving a
  schedule ENABLED re-validates its EFFECTIVE (possibly stored) project, like
  the existing stored-agent and cadence-floor rechecks. A DISABLING edit skips
  the requirement, or a schedule auto-disabled for `project_required` could
  never be turned off.
- Fire-time enforcement auto-disables rather than filing runs loose, matching
  agent_deleted: new `project_required` (requirement raised after creation) and
  `project_deleted` (gone, or pinned to a project this owner does not have)
  reasons, both refused BEFORE a billed generation is dispatched, and both
  advancing so a schedule can never wedge on the occurrence.
- `computeCreateDigest` appends the field only when present, so a payload
  without a project digests byte-identically to one from before this change —
  a create retried across the upgrade still matches its own row instead of
  reading as key reuse.
- Project reads are scoped to the owner, so ownership and existence are the
  same lookup; a read ERROR propagates instead of failing closed, so a Mongo
  blip retries the fire rather than auto-disabling the schedule.

The trigger idempotency key hashes principal/event/target and never
`envelope.run`, so the added run field cannot destabilize delivery identity.

* 🗜️ fix: Keep the Schedule Dialog Inside Its Height Budget

The project picker landed as a new ROW in the schedule dialog, which broke the
e2e edit spec: `md:overflow-visible` turns off the template's scrolling from
`md` up, so the dialog's content must fit the viewport. The extra row pushed the
footer's Save button below a 720x1280 window, where Playwright reported a
visible, enabled button it could never click — 226 scroll-into-view retries and
a 2-minute timeout, on all three attempts.

Measured against `dev` at 1280x720 (Save button's bottom edge, viewport 720):
  dev              688   (32px slack)
  project row      ~790  (off-screen, CI failure)
  3-column row     704   (16px slack — half the budget spent)
  this commit      686   (34px slack, 2px better than dev)

The identity row is now three columns — name, agent, project — and its caption
moved out of the agent cell to sit full width beneath the row: at a third of the
dialog that sentence wraps an extra line, and the row is the tallest thing
competing for the budget. The caption is grouped with the row rather than left
to the form's own 4-unit rhythm, which spent more height on the gap than the
caption occupies.

The e2e spec now asserts the button is in the viewport before clicking it, so
the next field that overflows this dialog says so in one line instead of a
two-minute timeout on a visible element. FOLLOWUPS.md records what the planned
dialog controls (multi-day weekly, timezone, attachments) need first: give
`ControlCombobox` the `portalElement` prop `Dropdown` already has, portal the
popovers into the dialog content, and let the form scroll again.

* 🧹 fix: Address Codex Review on Scheduled Chat Project Scope

Four P2 findings, all real.

Unreachable clearing path (ScheduleDialog). The picker only held live projects, so
`com_ui_schedule_project_none` was a PLACEHOLDER — nothing selectable. Once a
schedule had a project the owner could never take it away, leaving the server's
`chatProjectId: null` path reachable only by API. The picker now carries a real
"No project" option whenever a project is optional, and omits it when one is
required, where there is nothing valid to select.

Placeholder shown for a real project (ScheduleDialog). A stored or pinned project
outside the first loaded page had no name in the paged map, and the combobox
renders its placeholder for an empty display value — telling the owner a scoped
schedule had no project. That one project is now read by id, with the raw id as a
last resort: a poor label, but an honest one.

Project policy skipped at the resume boundary (service.ts). `claimScheduleResume`
re-applied the schedules gate, the revision fence, the kill switch and
SCHEDULES:USE, but not the project policy this PR added. Approving a paused run
whose project was deleted — or whose owner now sits under a requirement or a pin
it no longer satisfies — billed a continuation the very next scheduled fire would
refuse and auto-disable the schedule for. The effective-project resolution now
runs there too, refused before the lease and the capacity slot so a policy refusal
costs nothing and leaves no state to unwind. NOTE: agent access and balance are
still not rechecked on resume; that gap predates this PR and is left alone.

Per-card project derivation (ScheduleCard). Every card ran the projects hook and
rebuilt the full option array, name map, and one icon element per project, to use
a single name — O(schedules x projects) per render and per project-list refresh.
The hook is split: `useChatProjectNames` (map only, for the panel, which resolves
every card's name once and passes it down) and `useChatProjectPicker` (options and
pagination, for the dialog's one combobox). The panel skips the query entirely
until some schedule actually has a scope.

Tests: four at the resume boundary (verified failing without the gate) and four in
the dialog spec. The picker selection in one existing test now goes through the
search field — the popover's VIRTUALIZED renderer sizes its window from a scroll
height jsdom always reports as 0, so with three options it materialized only two.
Full schedules e2e re-run green against the rebuilt client.

* 🎯 fix: Settle Project-Policy Refusals and Keep Create Retries Idempotent

Second Codex round, four P2s. Three were consequences of the resume gate added in
the previous commit, which was half-built: it admitted where it should not and
stranded the run where it refused.

Project policy moves from `claimScheduleResume` into `isScheduleLive`'s `policy`
branch. Both entry points consult that branch FIRST, and both already route its
refusal through abort-and-settle — so a policy stop now settles the occurrence
instead of answering a bare 409 while the job stays `requires_action`, the card
keeps reading "Needs approval", and every retry repeats the same 409 until expiry.
No change to resume.js: the existing branch does the work.

The rule is deliberately NARROW. It refuses only where no valid destination is
left — the requirement is on with nothing satisfying it, or the schedule's own
project is gone (which also unset it on the conversation). It does NOT refuse
because an operator's pin moved: the paused conversation cannot be rebound
(`chatProjectId` is excluded from the resume context and the continuation reuses
the same conversationId), so refusing would strand a pending approval over a
destination it can never reach, for a pin that governs only where the NEXT run
lands — which the fire path already redirects.

Create retries are idempotent again. Project policy had been applied BEFORE the
`clientRequestId` replay lookup, so a raised requirement, a deleted project, or a
moved pin could answer 400 for a create that already committed — pushing the
client to rotate its key and create a DUPLICATE schedule, the exact failure the
key exists to prevent. Policy now applies only to a genuinely new insert, and the
digest is computed from the CLIENT's payload rather than the resolved destination,
so today's policy can no longer re-digest a genuine retry into a mismatch.

An explicit `chatProjectId: null` under a pin is refused rather than silently
resolved to the pin. The payload contract defines `null` as clearing the scope;
answering 201 while filing under the pin reported success for the opposite of what
was asked. Only an OMITTED field takes the pin silently.

Tests: five on the policy branch (both refusals verified failing without it, plus
guards that a moved pin and a live project still admit) and two on the handlers
(the pinned explicit clear, and a committed create recovered by retry after the
policy tightened — verified answering 400 without the reordering).

* 🧭 fix: Converge the Stored Project on the Destination a Fire Resolved

Third Codex round, four P2s.

The root confusion behind the resume findings: an operator pin outranks the stored
id at fire time, `fireSchedule` sends the pin in the trigger envelope, and the row
keeps its old value. The row therefore LIED about where that occurrence's
conversation went, and every later re-validation — the resume boundary above all —
checked a project the conversation was never filed under. A schedule storing A,
pinned to B, with B later deleted and A still live, was admitted for resume into a
conversation that had just been unscoped.

Fixed at the source rather than at each reader: a fire that resolves a destination
different from the stored one writes it back, claim-token fenced like every other
worker-side write and deliberately WITHOUT a configRevision bump — this is the
server reconciling itself to policy, not an owner edit, and a bump would fence an
in-flight occurrence off its own run. Written only AFTER the destination validates,
so an unusable pin never lands in the row, and best-effort: the envelope already
carries the right destination, so a failed write costs accuracy on a later recheck,
never the run itself. The wire projection already reported the pin, so this also
stops the row and the UI disagreeing.

An explicit `chatProjectId: null` under a pin is now refused on the DISABLING edit
path too. The pin check and the requirement are independent rules, and folding them
together let `{enabled: false, chatProjectId: null}` skip the pin check entirely,
unset the row, and answer with a wire projection still naming the pin. Only the
requirement is waived for a disabling edit.

The dialog no longer requires a project for an edit that leaves a schedule DISABLED.
The server waives the requirement there precisely so a row auto-disabled for
`project_required` can still be renamed or tidied up; requiring it in the form made
that unreachable, and an owner with no projects could not edit the stopped schedule
at all.

FOLLOWUPS.md records the two residual gaps with their exact triggers: the sub-second
deletion race inside the resume claim window (which needs the effective project
persisted per OCCURRENCE plus a distinct policy conflict routed through
abort-and-settle), and the fact that convergence happens only when a schedule fires.

Tests: four on convergence (pin written, no write when unchanged, no write for a
destination that failed validation, fire survives a failed write), two on the
disabling-edit pin rules, two in the dialog. Schedules e2e re-run green.

* 🔑 fix: Keep an Explicit Project Clear Out of an Omitted Field's Digest

`computeCreateDigest` appended `chatProjectId` on `!= null`, so an OMITTED field and
an explicit `null` produced the same digest. Because the replay lookup and
`matchesCreateIntent` deliberately run before project policy, a request could reuse a
pinned create's `clientRequestId` while explicitly sending `chatProjectId: null` and
receive 201 describing the pinned row — success reported for the opposite of what it
asked, and the pinned-clear refusal the normal create path applies never reached.

Now `!== undefined`: an omitted field still digests byte-identically to a payload from
before project scope existed, so a create in flight across the upgrade still matches
its own row, while an explicit clear is a distinct intent and digests differently. A
pre-scope client never sent the field at all, so nothing legacy can carry an explicit
null.

* 📍 fix: Validate a Paused Run Against the Project Its Own Occurrence Used

The schedule-wide convergence from the previous commit was not enough, and the
reason is the single-active run index: it covers `status: 'started'` only, so a
PAUSED run does not block the next occurrence. While run 1 sat paused in project A,
a pin move plus one later fire rewrote the schedule row to B — and the resume
policy then validated B while run 1's conversation was still filed under A. Delete
A and the continuation was admitted into a conversation that had just been unscoped.
That window lasts as long as the pause, not the sub-second race the previous commit
documented.

The reservation now records the destination THIS occurrence used, and
`isScheduleLive` validates that record when given the occurrence's `scheduledFor` —
which `resume.js` already reads two lines above the call. No new conflict type and
no settlement-path surgery: the refusal rides the abort-and-settle branch that check
already has.

An ABSENT record falls back to the schedule-level resolution rather than reading as
unscoped. A pre-scope occurrence, or one whose row is gone, must never be treated as
evidence to stop a run — the fallback keeps legacy paused runs behaving exactly as
they do today.

Schedule-wide convergence stays: it keeps the row honest for the UI and for every
check that has no occurrence in hand.

FOLLOWUPS.md now describes the one remaining gap accurately — a deletion inside the
claim window, which needs a distinct policy conflict routed through abort-and-settle
rather than the bare 409 an `inactive` conflict produces.

Tests: three on occurrence-vs-row precedence (the decisive one verified failing
without the lookup), two on what the reservation records, and the resume controller
spec now pins `scheduledFor` in the policy call. Schedules e2e re-run green.

* 🏷️ fix: Tell a Deliberately Unscoped Occurrence From an Unrecorded One

The occurrence fallback added in the previous commit conflated two different
absences. A post-upgrade run that deliberately went unscoped omitted the field
exactly like a row written before the field existed, so both took the fallback —
and a paused unscoped run was then validated against the schedule's CURRENT
project. Under a requirement or a pin added while it sat paused, that admitted a
billed continuation into a conversation satisfying no present policy.

The reservation now ALWAYS records its decision, `null` for unscoped, and the read
reports `recorded` from key PRESENCE rather than truthiness. Only an unknown record
— a pre-scope row, or no row at all — falls back to the schedule-level resolution;
a recorded null is the genuinely unscoped occurrence it says it is, and is refused
once a project becomes required.

The distinction rests entirely on a stored `null` surviving as a present key while a
never-written field stays absent, so that is asserted against real Mongo rather than
assumed: if it ever stopped holding, unscoped runs would silently start being
validated against the schedule's current project again.

Tests: two against mongodb-memory-server (recorded null vs never-written vs missing
row), plus refusal of a recorded-unscoped occurrence under a new requirement and the
preserved fallback for a pre-scope one. Schedules e2e green.

* 🔁 fix: Validate an Initial Scheduled Start Against Its Own Occurrence

The initial-start policy check in `request.js` called `isScheduleLive(..., { policy:
true })` without `scheduledFor`, so it fell back to the schedule-level resolution
even though the run row already carries the occurrence's recorded scope. An
occurrence reserved unscoped, with a pin introduced while its loopback request sat
queued, was therefore admitted against the new pin — producing a billed unscoped
conversation under a requirement it does not satisfy, from an envelope already built
without a project.

`scheduledFor` was already in scope there. Passing it makes the initial start and the
resume validate the same way: against the destination the occurrence itself recorded.
2026-08-21 11:09:04 -04:00
Marco Beretta
3b339219ba
📜 feat: Add Optional Collapse for Long User Messages (#15034)
* feat: add optional collapse for long user messages

Add a Chat > Messages preference (off by default) that clamps long
user messages to a preview height with a gradient fade and a Show
more toggle, so pasted text or code cannot dominate the thread.

The clamp is visual only: overflow-hidden keeps the full text in the
DOM, so it stays readable by assistive tech, copyable, and findable
by in-page search. Renders children untouched while the preference
is off, keeping the DOM identical to before.

* fix: address review findings on the long-message clamp

- Apply the clamp only when content actually overflows, so sub-tolerance
  content is never hidden without a toggle
- Measure the inner unclamped wrapper so growth such as a font size
  change or late media layout re-trips the toggle while collapsed
- Reveal the message when focus reaches clipped content, so links and
  code actions stay reachable without focusing hidden elements
- Reset the reveal when the preference turns off, so re-enabling always
  starts from the collapsed preview

* fix: refine clamp reveal, use Button primitive, cover steer parts

- Reveal on focus only when the focused control is actually clipped, so
  tabbing into a visible link no longer expands the message
- Measure in a layout effect so the first paint carries the clamp
- Render the toggle through the shared Button primitive (link variant)
  instead of feature-local button styling
- Persisted steering messages now collapse under the same preference;
  search-result previews stay unclamped by design

* fix: reveal focused controls clipped by any amount at the boundary

The overflow tolerance exists to absorb trailing markdown margins when
deciding whether the message overflows; the focus check compares the
focused control directly against the clamp boundary instead.
2026-08-21 03:38:01 -04:00
Danny Avila
d6d6b04804
📱 fix: Recover the Stream After a Mobile Tab is Backgrounded (#15050)
* 📱 fix: Recover the Stream After a Mobile Tab is Backgrounded

`sse.js` is XHR-based, so a mobile browser that backgrounds or freezes the
tab cancels the in-flight request and the transport reports `abort`, not
`error`. The abort listener assumed every abort was one this hook issued and
went idle — leaving the pane holding whatever partial content arrived before
the switch, looking finished, with nothing left to re-read the conversation:
`useResumeOnLoad` only runs when entering a conversation, and the messages
query never refetches on focus, mount or reconnect.

An abort reaching that listener before any terminal event and outside a
reconnect or handoff is a user-agent cancellation — every close this hook
owns is already fenced by the lifecycle signal, `reconnectAttemptRef`, the
handoff flag or `finalReceived`. Schedule the same backoff reconnect the
transport-error path uses so the existing recovery adjudicates: a live job
replays what was missed, a finished one 404s into the durable refetch.

A frozen tab can also lose its stream with no event at all — an intermediary
ends the response body, XHR reports an ordinary load, and sse.js dispatches
nothing. Re-attach on `visibilitychange` when this subscription's transport
is already closed with no terminal event behind it.

* 🩹 fix: Retire a Subscription the 404 Reconcile Already Terminalized

The foreground re-attach keyed only on `finalReceived`, but the two terminal
recoveries that do not ride a frame — the 404 and retry-ceiling reconciles —
never set it. The 404 path also leaves the submission installed and `sseRef`
pointing at the closed attachment, so every one of its guards still passed:
switching apps after the exact recovery this PR is about would resubscribe to
a stream the server no longer has, 404 again, and republish an `aborted`
run-end into the queue drain on each return.

Fold the dev-only close flag into a `subscriptionRetired` marker that both
terminal reconciles set, and gate the abort and foreground paths on it
alongside `finalReceived`.

* 🔌 fix: Fence Owned Closes Per Connection and Pin the Transport Contract

`reconnectAttemptRef` is shared across the whole reconnect ladder and stays
raised from the moment a retry is scheduled until the replacement connection
opens. The abort listener read it as "this close was ours", so a user agent
that cancelled the replacement before it opened — the ordinary case when the
retry timer fires while the tab is still backgrounded — was attributed to the
previous connection's deliberate close, and recovery stopped there with the
stream detached. Ownership is per connection, so track it per connection:
every close this subscription performs goes through `closeStream`, and the
listener keys on that instead.

An unsolicited abort is a dropped connection by every meaningful measure, so
hand it to the transport-failure path verbatim rather than running a second
ladder beside it. That path already climbs its backoff, adjudicates the retry
ceiling against durable status, and terminalizes into the durable refetch —
none of which the hand-rolled branch did, which is how the replacement's
failure could dead-end in the first place.

The mock transport now fires `abort` from `close()` like the real one, so our
own closes are exercised through the same listener rather than around it, and
a contract spec pins the two sse.js behaviours the recovery reads: a response
body that merely ends dispatches neither error nor abort but does mark the
connection closed, and a cancelled request dispatches abort.
2026-08-21 01:33:39 -04:00
Danny Avila
276f5f88fe
🗓️ fix: Hide Unsupported Schedule Variables (#15053) 2026-08-20 18:33:00 -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
f1fbaeb6d8
🔗 feat: Open Footer Site Links in a New Tab (#15019)
The footer's LibreChat site link navigated away from the chat in the
same tab. Footer content links (default version link and custom footer
markdown) now open in a new tab with noopener noreferrer.

Privacy policy and terms of service links keep same-tab navigation,
following the WCAG decision in #10997.
2026-08-20 11:17:52 -04:00
Marco Beretta
7569404a7c
🎛️ feat: Make Max Subagents Configurable via librechat.yaml (#15023)
* feat: make max subagents configurable via endpoints.agents.maxSubagents

The per-agent subagent cap was hardcoded at 10 in MAX_SUBAGENTS, leaving
orchestration-heavy deployments no option but patching limits.ts and
rebuilding. Add an optional endpoints.agents.maxSubagents key to
librechat.yaml (default 10, hard ceiling 50) that drives request
validation, model spec presets, and the agents panel UI cap.

* style: fix import order in OrchestrationHub
2026-08-20 11:16:08 -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
2f50e38217
📎 fix: Never Let a Stalled Attachment Disable the Composer (#15013)
* 🖼️ fix: Keep Composer Send Enabled When an Attachment Stalls

The composer's send button is gated on `hasIncompleteFiles(files)`, so any
attachment that can never reach `progress: 1` reads as "still uploading" and
disables send for the rest of the session — draft text intact, no error, no
way out but removing the chip or reloading. Two paths could park an
attachment there:

- `loadImage` starts the upload from `img.onload` and had no `onerror`, so an
  image the browser refuses to decode (unsupported codec, truncated bytes, a
  revoked object URL) never uploaded at all and stranded the file at
  `progress: 0.2`. Drop the file and surface the error instead.
- Upload completion reconciled against `temp_file_id`, the server's echo of
  the id the request was sent with, while every client-side handle for that
  upload — file map key, delayed-toast timer, recovery callbacks — is keyed by
  the id the client owns. A mismatch applied the completion update to a key
  that does not exist, leaving the attachment at `progress: 0.9`.

Covered by unit regressions in the file-handling suite and a composer-level
spec that drives a real upload through `ChatForm`, plus a render-bound guard
on typing (react-scan measures one ChatForm render per keystroke in a browser;
the guard fails on a multiplier).

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

* 🧹 fix: Stop the Draft Restore From Clobbering Live Composer Attachments

`restoreFiles` runs on every `QueryKeys.files` write — an upload landing, an
SSE attachment mid-run — not just on a conversation swap, and it was written
as if the draft were always the whole truth:

- An empty draft cleared the composer outright. On the swap path that is
  redundant (the effect already clears explicitly one line earlier); on the
  cache path an empty draft only means the draft write has not caught up, so
  clearing there discards an attachment the user just added — and with no text
  typed, the send button has nothing left to submit. Restoring now only adds.
- A match replaced the composer's entry with the persisted record, dropping the
  local `File`, the blob preview the chip renders from (`FileRow` falls back to
  refetching `filepath`), and the tool resource the upload was staged under,
  and stamping `attached: true` so removing a chip the composer still owns
  leaves the file orphaned server-side. It now layers the record over the live
  entry and leaves `attached` to files actually adopted from a draft.

Confirmed against a real browser run: the entry is at `progress: 0.9` when this
restore fires, so it — not the upload's own completion — is what was re-enabling
send. react-scan render counts are unchanged (typing 20 keystrokes: 111 renders,
ChatForm=20; attaching an image: 1373, FileRow=6).

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

* 🔗 fix: Keep an Attachment's Stored Temporary Id Equal to Its Map Key

Two follow-ups from review on the upload reconciliation.

Completion stored the server's `temp_file_id` echo in the entry's value while
keying the map by the id the request was sent with. `useFileDeletion` deletes
map entries by the value's own `file_id` and `temp_file_id`, so where the two
disagreed — the exact case the reconciliation exists to tolerate — Remove would
delete the file server-side and leave the chip behind, and the draft restore
could not correlate its saved key with the cached record. Store the request id.

A refused image decode also left its `uploadScope.recent` reservation behind:
reservations are released by the render that observes the file in the shared
state, which a decode failing before that render never reaches, and once the
file is deleted no later render can either. The ghost is merged into every
later batch's validation, so re-picking the same file reads as a duplicate and
its size keeps counting against the composer's limits.

Both covered; both new guards fail without their fix. Also sorts the composer
spec's imports, which the static-checks import-order gate flagged.

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

* 🧷 fix: Normalize an Upload's Temporary Id at the Cache Boundary

The composer keys its file map — and the draft it saves — by the `file_id` the
upload request was sent with; `temp_file_id` is only the server's echo of that
id. The previous commit reconciled the composer's own entry against the request
id but left the record the mutation inserts into `QueryKeys.files` carrying the
raw echo, and `restoreFiles` can only correlate a saved draft id by matching a
cached record's `file_id` or `temp_file_id`. Where the echo disagreed the draft
matched neither, so the attachment was silently dropped on the next conversation
switch or reload — the same class of loss, one layer further out.

Normalize once where the response enters client state, and hand the normalized
record to the mutation's callers, so the cache, the composer entry and the draft
all agree on one id. An agreeing response is passed through untouched.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 14:24:11 -04:00
Danny Avila
c8953b8f32
🪂 fix: Land Navigation Auto-Scroll on the Rendered Thread (#15014)
The "auto scroll to latest message" setting stopped taking readers to the
newest message when opening a conversation, most visibly on long threads.

`useMessageScrolling` fired its landing on the conversation id alone. That id
reaches the hook a commit or more before the tree does, so `scrollIntoView` ran
against the OUTGOING conversation's rows: it scrolled that thread to its end,
and — having no dependency on the tree — never ran again once the requested
thread mounted. The reader was left at whatever offset the old thread's bottom
happened to be, which on a long thread is the top.

Key the landing on the conversation that owns the RENDERED rows instead, using
the same `messagesTree[0].conversationId` fallback `MessagesView` already uses
to key the mount window, and land once per conversation so the tree identities
a stream mints cannot haul back a reader who scrolled away.

This is independent of the progressive row mounting: that window only ever
grows upward from the newest row, so the end of the mounted content is already
the end of the thread, and the landing needs no full mount to be correct.
Measured against the real client (react-scan render tallies over a 10-message
to 120-message navigation), render counts are unchanged at ~16k and the thread
still mounts progressively; distance from the bottom on arrival goes 841px to
0. With progressive mounting disabled the same navigation landed 15421px from
the bottom, confirming the anchoring was masking this rather than causing it.

Also moves the `autoScroll` setting from Recoil to Jotai, keeping the same
`autoScroll` localStorage key so a stored preference survives, and matching the
`showThinking`/`smoothStreaming` atoms already served through `ToggleSwitch`.


Claude-Session: https://claude.ai/code/session_01BDQSLdbwvtSqCmQSw7Nz91

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 14:23:08 -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
Danny Avila
e4d6bb71f9
📁 feat: Surface Stateful Workspace Downloads (#14984)
* feat: surface stateful workspace downloads

* fix: sort workspace change imports

* fix: reuse workspace button primitives

* fix: hide collapsed workspace actions
2026-08-18 22:05:13 -04:00
Ravi Kumar L
da0491d5db
💻 fix(agents): require Code Interpreter for programmatic MCP tools (#14977)
* fix(agents): require code interpreter for programmatic MCP tools

* test(data-provider): fix tool options fixture type

* fix(agents): address programmatic tool review feedback

* fix(agents): avoid no-op update on version revert
2026-08-18 17:16:58 +02:00
Ravi Kumar L
006e421cd2
💡 feat: add DB-backed admin insights (#14898)
* feat: add Mongo-backed admin insights

* feat: gate insights with environment variable

* fix: tighten insights access and activity metrics

* fix: preserve insights date selections

* perf: parallelize insights search aggregation

* test: wait for MCP conflict recovery

* test: satisfy strict MCP recovery typing

* fix: disable insights pagination while loading

* fix: localize insights range shortcuts

* fix: bound insights search input
2026-08-18 07:51:51 -04:00
Danny Avila
547bd8c4bf
🧵 feat: Persist View-Only Subagent Threads (#14957) 2026-08-18 07:41:42 -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
7d62be2ad3
🕸️ feat: Run Saved Agent Teams as Subagents (#14944)
* feat: Add graph subagent integration

* style: Sort response usage test imports

* fix: Preserve lazy graph runtime context

* fix: Use isolated graph input helper

* test: Align graph integration fixtures

* fix: Preserve lazy graph runtime capabilities

* fix: Bound lazy graph metadata preload

* fix: Harden lazy graph resolution lifecycle

* fix: Coalesce lazy graph member resolution

* fix: Snapshot initialized graph members only

* fix: Preserve lazy agent runtime context

* fix: Preserve batched lazy context preparation

* fix: Preserve graph member capability bounds

* fix: reconcile graph subagents with execution profiles

* style: align graph subagent types with formatter
2026-08-17 18:02:52 -04:00
Danny Avila
f9876eaaf0
🪜 style: Step Through Batched Questions One at a Time (#14935)
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.
2026-08-17 12:16:19 -04:00
Danny Avila
df294fa474
🧩 refactor: Resolve Tool-Card State Once (#14934)
* 🧩 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>
2026-08-17 12:05:17 -04:00
github-actions[bot]
b00a6717e7
🌍 i18n: Update translation.json with latest translations (#14919)
Co-authored-by: danny-avila <110412045+danny-avila@users.noreply.github.com>
2026-08-17 09:21:14 -04:00
Danny Avila
6b9fe97990
🎬 style: Reveal the Chat on Programmatic Drawer Closes (#14930) 2026-08-17 08:45:16 -04:00
Danny Avila
57ea1137f6
🛡️ feat: Let Admins Restrict Stateful Workspace Scopes (#14910)
* feat: let admins restrict stateful workspace scopes

* fix: enforce stateful scope policy across agent paths

* fix: close stateful scope policy activation gaps
2026-08-17 01:29:19 -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
Danny Avila
485abef3fa
🥚 refactor: Default Agents to Preferred Stateful Workspace Scope (#14908)
* feat: add user default for stateful agent workspaces

* style: sort stateful workspace imports
2026-08-16 22:31:01 -04:00
Marco Beretta
fdc9c77f6e
🗄️ feat: Archive All Chats From Data Controls (#14885)
* 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.
2026-08-16 22:11:57 -04:00
Marco Beretta
7ebf6b2548
📋 feat: Attach Long Pasted Text as a File (#14884)
* 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
2026-08-16 22:11:37 -04:00
Danny Avila
e736fcfa09
🏷️ refactor: Keep Agent Conversations From Revealing Model Labels (#14909) 2026-08-16 22:02:54 -04:00
Danny Avila
107050396e
📜 feat: Follow Streaming Args in Tool Detail Panes (#14906)
* 📜 feat: Follow Streaming Args in Tool Detail Panes

* 📜 fix: Gate Follow-Scroll to Expanded Panes, Re-Pin on Highlight Commit
2026-08-16 21:27:03 -04:00
Danny Avila
27ed491a2a
🏷️ fix: Persist the Ephemeral Agent's Display Label as Sender (#14899)
* 🏷️ 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
2026-08-16 19:50:30 -04:00
Danny Avila
c939a6fb17
📱 feat: Swipe the Mobile Drawer Open and Closed (#14902)
* 📱 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
2026-08-16 19:45:35 -04:00
Danny Avila
1b7e2a4e6a
perf: Optimize First Load of Large Conversations (#14901)
*  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
2026-08-16 19:45:21 -04:00
Marco Beretta
df5abbb377
🖱️ fix: Reveal Message Metadata on Hover, Not on Click (#14900)
* 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.
2026-08-17 01:22:00 +02:00
Danny Avila
7d850c308a
🧠 feat: Add Live Reasoning Labels (#14893)
* feat: add live reasoning labels

* fix: Stabilize reasoning label checks

* fix: Address reasoning label review findings

* chore: Bump Agents SDK for reasoning labels

* fix: Reset reused reasoning step evidence

* fix: Reconcile cleared reasoning labels

* fix: Fence reasoning label resets

* fix: Reset reasoning ownership before gap labels

* fix: Preserve THINK type through label reset

* test: Expect run-global reasoning revision
2026-08-16 18:11:55 -04:00
Marco Beretta
3bd2358805
🗜️ feat: Let Users Toggle Client-Side Image Resizing (#14883)
* 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
2026-08-16 18:04:03 -04:00
Danny Avila
2b1644406a
💄 style: Align the Thinking Dot with the Header Icon (#14895)
* 💄 style: Align the Thinking Dot with the Header Icon

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

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

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

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

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

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

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

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

* fix: keep archivedAt on a redundant archive request

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

* fix: make archive timestamp updates atomic

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

* fix: archive without an aggregation-pipeline update

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

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

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

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

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

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

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

Read the conversation once more when the retries run out and answer with its
actual current state instead.
2026-08-16 17:07:44 -04:00
Danny Avila
fb8ae881cf
⏱️ feat: Show Run-Step Durations On Tool Cards (#14892)
* ⏱️ feat: Show Run-Step Durations On Tool Cards

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

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

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

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

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

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

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

* 🎨 style: Sort Imports In Touched Files

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Codex round 3, both findings confirmed.

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

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

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

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

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

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

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

* 🎨 style: Wrap Harvest Spec Expectation Per Prettier

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two labels renamed for accuracy and consistency:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: mock shortcut setting in expanded panel

* remove unused com_ui_skill_toggle_active i18n key

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

* fix: honor shortcut switch in composer

* fix: defer file loading until dialog opens

* fix: preserve memory API errors

* chore: restore automated locale entries

* fix: honor shortcut switch during generation

* refactor: share field message primitive

* fix: reserve helper height for wrapping field messages

* fix: wrap skill detail actions at narrow widths

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

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

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

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

* fix: address review findings on the pinned chats section

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

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

Second review pass on the independent pinned query.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: keep pins visible after a failed refetch

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

* test: type the pinned conversation fixtures correctly

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

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

* style: sort the sidebar imports to the repo order

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

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

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

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

* fix: order fallback pins by their timestamp

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

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

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

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

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

* feat: Edit a Project Name and Description

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

* feat: Delete a Project from the Workspace

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

* feat: Add Edit and Delete Actions to Project Cards

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

* feat: Match Project Descriptions When Searching Projects

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

* feat: Let Consumers Place and Size the ControlCombobox Popover

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

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

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

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

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

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

* style: Move the Project Chip Into the Composer

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

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

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

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

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

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

* feat: Add an Overflow Menu to Project Workspace Chats

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

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

* feat: Rework the Projects Sidebar Section

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

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

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

* style: Widen the Projects Dashboard and Workspace Layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: Stop Project Names and Descriptions Being Silently Truncated

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

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

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

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

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

* chore: Satisfy the Static Checks for the Projects Rework

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

* fix: Highlight Only the Route Project in the Sidebar

A leftover conversation project was still lighting a second row after
opening another project's workspace. Prefer the workspace route, and
only fall back to the conversation project outside that view.
2026-08-16 11:06:18 -04:00