LibreChat/AGENTS.md
Danny Avila c3beae1e18
🔌 refactor: Give the Subagent Surface a Host Seam and Its Own Jotai State (#15455)
* 📐 docs: Record Who Owns Which Piece of Client State

* 🔌 refactor: Give the Subagent Surface a Host Seam and Its Own Jotai State

The subagent chat surface reached up into seven application atoms and could
not be lifted anywhere, and the six atoms it owns were still Recoil while its
sibling stores had already moved to Jotai.

Feature-owned state converts to Jotai and stays where it is: the selection,
per-task control receipts, live progress and the parent-stream fence. The
control atom keeps its session-storage retry — restored per store rather than
per family member, so a reload still sees what the last one persisted.

App-global state now arrives through one `ChatSurface` value the host supplies:
`enterToSend`, `maximizeChatSpace`, `showScrollButton`, the reader's composer
bindings, a `claimForeground` command for the slot the panel and the artifacts
panel share, and `handOffComposerText` for a continuation's unsent words. Two
hosts mount the same adapter — the conversation view and the shared-link view.

Opening the panel was three copies of "clear the artifacts slot, then select";
that is now `useOpenSubagentPanel`, and the three call sites say what they mean.

Nothing under `Chat/Subagents/**` imports `~/store` or `recoil` any more, and
its whole suite runs with no `RecoilRoot` at all.

* 🧹 refactor: Move Subagent State Into the Feature and Free Its Drained Atoms

Two review findings, both fair.

The selection atom still lived in `~/store`, which handed the supposedly
liftable surface an implicit dependency on the application store alongside its
declared host interface — and contradicted the ownership rule this branch
added to CLAUDE.md two commits ago. The module moves to
`components/Chat/Subagents/state.ts`; the hosts that write it (the SSE step
handler, the message renderers) now import from the feature, which is the
direction that survives an extraction.

The conversation-switch drain blanked its two atom-family members but left
them cached. Every invocation key is unique, so a long-lived tab accumulated
two atom configurations per subagent call it ever saw — exactly what the
comment at that boundary says it exists to prevent. `removeSubagentProgressAtoms`
releases them after the values are cleared, mirroring `removeUsageAtoms`. The
new test fails without the release.

* 🔎 fix: Let a Subagent Card Render Where There Is No Panel to Open

Search results render the same card through `Part`, and that route is not
beneath either chat surface — so a search hit containing a subagent tool call
threw on `useChatSurface()` before it could render at all.

Rendering the card and opening a panel are different requirements. The host
stays required for the panel, the activity view and the event group, all of
which only ever mount inside one. `useOpenSubagentPanel` reads the context
optionally instead and answers `null` where there is no host, which is also
the honest answer for search: there is no panel on that route to open, so the
card renders with its details control disabled.

The new test renders the card with no surface at all, and throws with the
reported error when the hook is put back the way it was.

* 🧮 fix: Enrol Read-Created Progress Atoms in the Drain Registry

The read is what creates a family member, and only the streaming paths were
registering their keys. A card rendering a conversation that finished
streaming long ago — or a search result that never streams at all — created
one the navigation drain could not see, so browsing history accumulated an
atom configuration per card for the life of the tab.

`useSubagentProgress` does both halves: it reads the invocation's progress and
enrols its key, so every route into the family shares the one cleanup
boundary. The panel and the parent card both read through it now.

* 🧽 fix: Free a Read-Created Progress Atom at Its Own Card's Unmount

Enrolling read-created keys in the drain registry only helps where something
runs the drain, and `/search` is a standalone route that never mounts the chat
event handlers. Scrolling a virtualised result list therefore kept minting
members nothing would ever free.

`useSubagentProgress` cleans up after itself instead: on unmount it frees the
family members when the read is the only reason they exist. Two guards keep
that from taking live history with it — a key the stream registered belongs to
the drain, and a member holding folded activity is the record of what the child
did, so reopening its card still shows it.

* 🔗 fix: Free a Progress Atom Only After Its Last Reader Lets Go

Opening a foreground panel before the first event gives one still-empty member
two readers: the card that spawned the child and the panel showing it. Closing
the panel freed it while the card was still subscribed — and a subscriber does
not re-render just because its atom left the family, so the first SSE update
landed on the replacement member and the card's ticker stayed dead.

`useSubagentProgress` counts its mounted readers and frees only when the last
one lets go, alongside the two claims that already blocked it: a key the stream
registered, and a member holding folded activity.
2026-09-01 11:15:42 -04:00

2.8 KiB

See CLAUDE.md.

Branching and pull requests

Branch off dev and target dev with every pull request; gh pr create defaults to main, so pass --base dev explicitly. main is the released branch, kept as a fast-forward of dev and synced as-is — never open a backport pull request to main, because anything merged to dev reaches it at the next sync. Pull requests opened against main are retargeted automatically. Fixes #N does not close the issue on a dev merge — GitHub honors closing keywords only on the default branch, so close linked issues by hand. Worktrees share one stash stack, so never use a bare git stash pop. See the detailed policy in CLAUDE.md under "Branching and Pull Requests".

Verification

A green build is not a typecheck: packages/api, packages/client and packages/data-schemas build with tsdown, which emits without checking types. Run npx tsc --noEmit in the workspace you changed. packages/client excludes *.spec.ts(x) and *.test.ts(x) from typechecking entirely. npm run sort-imports with no arguments rewrites every source root — pass the paths you touched. See CLAUDE.md under "Typechecking" and "Formatting".

Frontend theming and styling

For frontend work, compose existing @librechat/client primitives and variants before adding feature-local styles. Use semantic theme/Tailwind roles for color and shared appearance; do not introduce raw palette utilities, hard-coded colors, or arbitrary theme CSS. If the system cannot express a reusable design need, deepen the shared primitive or versioned theme-token registry instead of copying classes into a feature. Keep genuine layout and behavior local, and document why any new custom CSS cannot be expressed by the shared system. See the detailed policy in CLAUDE.md under “Theming and styling.”

Backend auth cache

When adding or changing code that mutates user documents, invalidate the auth user document cache for affected users, including bulk role and user mutations. See the detailed policy in CLAUDE.md under “Auth cache invalidation”.

Client state ownership

The client is migrating from Recoil to Jotai — convert the areas you touch, not the whole store. Split by ownership: state a feature both writes and reads is feature-owned, so convert it to Jotai and keep it inside the feature; app-global preferences and shell state a feature merely consumes (maximizeChatSpace, showScrollButton, enterToSend, artifact visibility) must be passed in through props or a small host-supplied context rather than reached for through ~/store. Passing them in is what lets a feature move to its own workspace later without a rewrite, and it keeps the Jotai conversion scoped to the state a feature owns. See the detailed policy in CLAUDE.md under “Client State Ownership”.