* 📐 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.
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”.