mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +00:00
🔌 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.
This commit is contained in:
parent
a34d5dbfc7
commit
c3beae1e18
32 changed files with 1189 additions and 655 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -33,3 +33,14 @@ why any new custom CSS cannot be expressed by the shared system. See the detaile
|
|||
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”.
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -178,6 +178,22 @@ Multi-line imports count total character length across all lines. Consolidate va
|
|||
- React Query (`@tanstack/react-query`) for all API interactions; proper query invalidation on mutations.
|
||||
- QueryKeys and MutationKeys in `packages/data-provider/src/keys.ts`.
|
||||
|
||||
### Client State Ownership
|
||||
|
||||
The client is migrating from Recoil to Jotai. Convert the areas you touch rather than
|
||||
migrating wholesale, and split the work by who owns the state:
|
||||
|
||||
- **Feature-owned state** — atoms a single feature both writes and reads. Convert these to
|
||||
Jotai as you touch them, and keep them inside the feature.
|
||||
- **App-global state** — preferences and shell state a feature merely consumes
|
||||
(`maximizeChatSpace`, `showScrollButton`, `enterToSend`, artifact visibility). A feature
|
||||
that could plausibly be extracted must not reach into `~/store` for these; accept them
|
||||
through props or a small context the host supplies.
|
||||
|
||||
Passing app-global state in — rather than reaching for it — 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 actually owns instead of dragging the global migration forward early.
|
||||
|
||||
### Data-Provider Integration
|
||||
|
||||
- Endpoints: `packages/data-provider/src/api-endpoints.ts`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { ChevronRight, Users } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import type {
|
||||
PartMetadata,
|
||||
TAttachment,
|
||||
|
|
@ -9,12 +9,13 @@ import type {
|
|||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import type { SubagentTickerLine } from '~/utils/subagentContent';
|
||||
import store, {
|
||||
import {
|
||||
activeSubagentPanel,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store';
|
||||
useSubagentProgress,
|
||||
} from '~/components/Chat/Subagents/state';
|
||||
import { adaptLivePersistedActivity } from '~/components/Chat/Subagents/adapters';
|
||||
import { useOpenSubagentPanel } from '~/components/Chat/Subagents/surface';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { useShareContext } from '~/Providers/ShareContext';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
|
|
@ -40,7 +41,7 @@ interface SubagentCallProps {
|
|||
output?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
/** Aggregated content parts the backend attached to the tool_call at
|
||||
* message-save time. Takes precedence over the in-memory Recoil atom
|
||||
* message-save time. Takes precedence over the in-memory atom
|
||||
* so a page refresh shows the same history the user saw live. Older
|
||||
* runs recorded before the persistence path landed will not have this
|
||||
* field; those fall back to the atom (or the raw `output` string). */
|
||||
|
|
@ -151,7 +152,7 @@ function useThrottledValue<T>(value: T, intervalMs: number, enabled: boolean): T
|
|||
* artifacts-style panel that renders the child's aggregated activity through
|
||||
* the shared child-activity module, so every subagent mode uses one deep view.
|
||||
*
|
||||
* Progress is sourced from the `subagentProgressByToolCallId` Recoil atom
|
||||
* Progress is sourced from the `subagentProgressByToolCallId` atom
|
||||
* family, populated by `useStepHandler` as `ON_SUBAGENT_UPDATE` SSE
|
||||
* envelopes arrive. The atom is keyed by the parent message and
|
||||
* `tool_call_id`, since providers may reuse tool IDs across turns.
|
||||
|
|
@ -172,12 +173,9 @@ export default function SubagentCall({
|
|||
const parentMessageContext = useContext(MessageContext);
|
||||
const parentMessageId = parentMessageContext.messageId?.trim() ?? '';
|
||||
const partIndex = parentMessageContext.partIndex ?? 0;
|
||||
const progress = useRecoilValue(
|
||||
subagentProgressByToolCallId(subagentProgressKey(parentMessageId, toolCallId, partIndex)),
|
||||
);
|
||||
const setSelectedSubagent = useSetRecoilState(activeSubagentPanel);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const progress = useSubagentProgress(subagentProgressKey(parentMessageId, toolCallId, partIndex));
|
||||
const setSelectedSubagent = useSetAtom(activeSubagentPanel);
|
||||
const openPanel = useOpenSubagentPanel();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const backgroundHandle = useMemo(
|
||||
() => parseSubagentBackgroundHandle(output, args),
|
||||
|
|
@ -301,10 +299,14 @@ export default function SubagentCall({
|
|||
const canOpenLiveForeground =
|
||||
isSharedConvo !== true && backgroundHandle == null && hasParentContext;
|
||||
return (
|
||||
hasParentContext && (canOpenDurablePanel || canOpenLiveForeground || hasRenderableFallback)
|
||||
/** No host, no panel to open — a search result renders this same card. */
|
||||
openPanel != null &&
|
||||
hasParentContext &&
|
||||
(canOpenDurablePanel || canOpenLiveForeground || hasRenderableFallback)
|
||||
);
|
||||
}, [
|
||||
backgroundHandle,
|
||||
openPanel,
|
||||
canOpenDurablePanel,
|
||||
initialProgress,
|
||||
isSharedConvo,
|
||||
|
|
@ -369,17 +371,9 @@ export default function SubagentCall({
|
|||
}, [panelSelection, parentMessageId, partIndex, setSelectedSubagent, toolCallId]);
|
||||
|
||||
const openDetails = useCallback(() => {
|
||||
if (!canOpenDetails) return;
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
setSelectedSubagent(panelSelection);
|
||||
}, [
|
||||
canOpenDetails,
|
||||
panelSelection,
|
||||
resetCurrentArtifactId,
|
||||
setArtifactsVisible,
|
||||
setSelectedSubagent,
|
||||
]);
|
||||
if (!canOpenDetails || openPanel == null) return;
|
||||
openPanel(panelSelection);
|
||||
}, [canOpenDetails, openPanel, panelSelection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { RecoilRoot, useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import type { SubagentUpdateEvent } from 'librechat-data-provider';
|
||||
import type {
|
||||
|
|
@ -8,7 +9,7 @@ import type {
|
|||
SubagentContentPart,
|
||||
SubagentTickerState,
|
||||
} from '~/utils/subagentContent';
|
||||
import type { ActiveSubagentPanel, SubagentProgress } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel, SubagentProgress } from '~/components/Chat/Subagents/state';
|
||||
import {
|
||||
foldSubagentEvent,
|
||||
foldSubagentEventIntoTicker,
|
||||
|
|
@ -19,9 +20,10 @@ import {
|
|||
activeSubagentPanel,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store/subagents';
|
||||
} from '~/components/Chat/Subagents/state';
|
||||
import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { ChatSurfaceHarness } from 'test/harness';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
|
|
@ -108,42 +110,41 @@ function renderWithState(args: {
|
|||
const setter = { current: null as null | ((next: SubagentProgress | null) => void) };
|
||||
let selection: ActiveSubagentPanel | null = null;
|
||||
const SeedHelper = () => {
|
||||
setter.current = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(next: SubagentProgress | null) =>
|
||||
set(
|
||||
subagentProgressByToolCallId(subagentProgressKey('parent-message', args.toolCallId, 0)),
|
||||
next,
|
||||
),
|
||||
[],
|
||||
);
|
||||
const store = useStore();
|
||||
setter.current = (next: SubagentProgress | null) =>
|
||||
store.set(
|
||||
subagentProgressByToolCallId(subagentProgressKey('parent-message', args.toolCallId, 0)),
|
||||
next,
|
||||
);
|
||||
return null;
|
||||
};
|
||||
const SelectionObserver = () => {
|
||||
selection = useRecoilValue(activeSubagentPanel);
|
||||
selection = useAtomValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
const rendered = render(
|
||||
<MemoryRouter>
|
||||
<RecoilRoot>
|
||||
<SeedHelper />
|
||||
<SelectionObserver />
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
messageId: 'parent-message',
|
||||
conversationId: 'parent-conversation',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId={args.toolCallId}
|
||||
initialProgress={args.initialProgress}
|
||||
isSubmitting={args.isSubmitting ?? false}
|
||||
args={args.toolArgs ?? { subagent_type: 'self', description: 'compute' }}
|
||||
output={args.output}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<SeedHelper />
|
||||
<SelectionObserver />
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
messageId: 'parent-message',
|
||||
conversationId: 'parent-conversation',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId={args.toolCallId}
|
||||
initialProgress={args.initialProgress}
|
||||
isSubmitting={args.isSubmitting ?? false}
|
||||
args={args.toolArgs ?? { subagent_type: 'self', description: 'compute' }}
|
||||
output={args.output}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
act(() => setter.current?.(args.progress ?? null));
|
||||
|
|
@ -257,7 +258,7 @@ describe('SubagentCall', () => {
|
|||
it('opens a foreground legacy invocation in the shared panel with persisted activity', () => {
|
||||
let selection: ActiveSubagentPanel | null = null;
|
||||
const Observer = () => {
|
||||
selection = useRecoilValue(activeSubagentPanel);
|
||||
selection = useAtomValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
const persistedContent = [
|
||||
|
|
@ -270,24 +271,26 @@ describe('SubagentCall', () => {
|
|||
] as Parameters<typeof SubagentCall>[0]['persistedContent'];
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
conversationId: 'parent-conversation',
|
||||
messageId: 'parent',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="foreground-call"
|
||||
initialProgress={1}
|
||||
args={{ subagent_type: 'self', description: 'Compute the answer.' }}
|
||||
output="legacy fallback"
|
||||
persistedContent={persistedContent}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
conversationId: 'parent-conversation',
|
||||
messageId: 'parent',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="foreground-call"
|
||||
initialProgress={1}
|
||||
args={{ subagent_type: 'self', description: 'Compute the answer.' }}
|
||||
output="legacy fallback"
|
||||
persistedContent={persistedContent}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
|
@ -342,18 +345,20 @@ describe('SubagentCall', () => {
|
|||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RecoilRoot>
|
||||
<MessageContext.Provider
|
||||
value={{ messageId: 'nested-message', conversationId: null, isExpanded: true }}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="nested-detached-call"
|
||||
initialProgress={1}
|
||||
args={{ subagent_type: 'self', run_in_background: true }}
|
||||
output={output}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<MessageContext.Provider
|
||||
value={{ messageId: 'nested-message', conversationId: null, isExpanded: true }}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="nested-detached-call"
|
||||
initialProgress={1}
|
||||
args={{ subagent_type: 'self', run_in_background: true }}
|
||||
output={output}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
|
@ -361,17 +366,47 @@ describe('SubagentCall', () => {
|
|||
});
|
||||
|
||||
it('disables a nested fallback that cannot remain attached to the conversation host', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<MessageContext.Provider
|
||||
value={{ messageId: 'nested-message', conversationId: null, isExpanded: true }}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="nested-foreground-call"
|
||||
initialProgress={1}
|
||||
args={{ subagent_type: 'self' }}
|
||||
output="Nested result"
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Ran agent' })).toBeDisabled();
|
||||
});
|
||||
|
||||
/** Search results render this same card through `Part`, and that route is not
|
||||
* a chat surface — there is no panel on it to open. The card still has to
|
||||
* render; it just cannot be opened from there. */
|
||||
it('renders outside a chat surface with its details control disabled', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RecoilRoot>
|
||||
<MessageContext.Provider
|
||||
value={{ messageId: 'nested-message', conversationId: null, isExpanded: true }}
|
||||
value={{
|
||||
conversationId: 'parent-conversation',
|
||||
messageId: 'parent',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="nested-foreground-call"
|
||||
toolCallId="hostless-call"
|
||||
initialProgress={1}
|
||||
args={{ subagent_type: 'self' }}
|
||||
output="Nested result"
|
||||
args={{ subagent_type: 'self', description: 'Compute the answer.' }}
|
||||
output="A result worth reading."
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</RecoilRoot>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Button } from '@librechat/client';
|
||||
import { ChevronDown, Users } from 'lucide-react';
|
||||
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import type { ActiveSubagentPanel } from '~/components/Chat/Subagents/state';
|
||||
import type { WakeupDisplay, WakeupTask } from './Parts/wakeup';
|
||||
import type { ActiveSubagentPanel } from '~/store/subagents';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { subagentStatusIcon, subagentStatusLabelKey } from '~/components/Chat/Subagents/status';
|
||||
import { useParentSubagents } from '~/components/Chat/Subagents/ParentSubagentsProvider';
|
||||
import { durableSubagentSelection } from '~/components/Chat/Subagents/eventSelection';
|
||||
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import { useOpenSubagentPanel } from '~/components/Chat/Subagents/surface';
|
||||
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
|
||||
import { useShareContext } from '~/Providers/ShareContext';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { cn, getToolDisplayLabel } from '~/utils';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { StackedToolIcons } from './ToolOutput';
|
||||
|
|
@ -41,9 +41,7 @@ function WakeupTaskCard({
|
|||
const { isSharedConvo } = useShareContext();
|
||||
const { messageId } = useMessageContext();
|
||||
const { byThreadId } = useParentSubagents();
|
||||
const setSelection = useSetRecoilState(activeSubagentPanel);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const openPanel = useOpenSubagentPanel();
|
||||
const child = task.threadId == null ? undefined : byThreadId.get(task.threadId);
|
||||
const selection = useMemo<ActiveSubagentPanel | null>(() => {
|
||||
/** Share pages have no authenticated durable-thread panel; a conversation
|
||||
|
|
@ -83,11 +81,9 @@ function WakeupTaskCard({
|
|||
const hasResult = task.result.trim() !== '';
|
||||
|
||||
const openActivity = useCallback(() => {
|
||||
if (selection == null) return;
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
setSelection(selection);
|
||||
}, [resetCurrentArtifactId, selection, setArtifactsVisible, setSelection]);
|
||||
if (selection == null || openPanel == null) return;
|
||||
openPanel(selection);
|
||||
}, [openPanel, selection]);
|
||||
|
||||
return (
|
||||
<div className="my-1.5 rounded-lg border border-border-light bg-surface-secondary/40 p-3">
|
||||
|
|
@ -99,7 +95,7 @@ function WakeupTaskCard({
|
|||
/>
|
||||
{title !== '' && <span className="min-w-0 truncate font-medium">{title}</span>}
|
||||
<span className="shrink-0">{localize(subagentStatusLabelKey(status))}</span>
|
||||
{selection != null && (
|
||||
{selection != null && openPanel != null && (
|
||||
/** The trigger identity attributes let the panel's close handler
|
||||
* return keyboard focus to this button. */
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ParentSubagentSummary } from 'librechat-data-provider';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { activeSubagentPanel } from '~/components/Chat/Subagents/state';
|
||||
import { ChatSurfaceHarness } from 'test/harness';
|
||||
import Wakeup from '../Wakeup';
|
||||
|
||||
/** The hooks barrel drags the full data-provider graph into jsdom, so only
|
||||
|
|
@ -67,7 +69,7 @@ jest.mock('~/components/Chat/Subagents/ParentSubagentsProvider', () => ({
|
|||
}));
|
||||
|
||||
function SelectionProbe() {
|
||||
const selection = useRecoilValue(activeSubagentPanel);
|
||||
const selection = useAtomValue(activeSubagentPanel);
|
||||
return <div data-testid="selection">{selection == null ? '' : selection.durable?.taskId}</div>;
|
||||
}
|
||||
|
||||
|
|
@ -87,10 +89,12 @@ const subagentDisplay = {
|
|||
describe('Wakeup', () => {
|
||||
it('renders a collapsible subagent completion card with the result and panel affordance', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Wakeup display={subagentDisplay} conversationId="conversation-1" />
|
||||
<SelectionProbe />
|
||||
</RecoilRoot>,
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Wakeup display={subagentDisplay} conversationId="conversation-1" />
|
||||
<SelectionProbe />
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
const header = screen.getByRole('button', { name: 'com_ui_wakeup_subagent_completed' });
|
||||
|
|
@ -109,24 +113,26 @@ describe('Wakeup', () => {
|
|||
|
||||
it('keeps the panel affordance for a thread omitted from the bounded index', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Wakeup
|
||||
display={{
|
||||
kind: 'subagent',
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-9',
|
||||
status: 'completed',
|
||||
result: 'ok',
|
||||
threadId: 'thread-9',
|
||||
subagentType: 'self',
|
||||
},
|
||||
],
|
||||
}}
|
||||
conversationId="conversation-1"
|
||||
/>
|
||||
<SelectionProbe />
|
||||
</RecoilRoot>,
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Wakeup
|
||||
display={{
|
||||
kind: 'subagent',
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-9',
|
||||
status: 'completed',
|
||||
result: 'ok',
|
||||
threadId: 'thread-9',
|
||||
subagentType: 'self',
|
||||
},
|
||||
],
|
||||
}}
|
||||
conversationId="conversation-1"
|
||||
/>
|
||||
<SelectionProbe />
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_wakeup_subagent_completed' }));
|
||||
|
|
@ -136,30 +142,32 @@ describe('Wakeup', () => {
|
|||
|
||||
it('renders a failed background tool batch with per-task statuses and no panel affordance', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Wakeup
|
||||
display={{
|
||||
kind: 'background_tool',
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'bg-1',
|
||||
status: 'completed',
|
||||
result: 'ok',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'web_search',
|
||||
},
|
||||
{
|
||||
taskId: 'bg-2',
|
||||
status: 'error',
|
||||
result: 'boom',
|
||||
toolCallId: 'call-2',
|
||||
toolName: 'execute_code',
|
||||
},
|
||||
],
|
||||
}}
|
||||
conversationId="conversation-1"
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Wakeup
|
||||
display={{
|
||||
kind: 'background_tool',
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'bg-1',
|
||||
status: 'completed',
|
||||
result: 'ok',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'web_search',
|
||||
},
|
||||
{
|
||||
taskId: 'bg-2',
|
||||
status: 'error',
|
||||
result: 'boom',
|
||||
toolCallId: 'call-2',
|
||||
toolName: 'execute_code',
|
||||
},
|
||||
],
|
||||
}}
|
||||
conversationId="conversation-1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
const header = screen.getByRole('button', { name: 'com_ui_wakeup_tasks_finished:2' });
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { RecoilRoot, useSetRecoilState } from 'recoil';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { Artifact } from '~/common';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { activeSubagentPanel } from '~/components/Chat/Subagents/state';
|
||||
import { ChatSurfaceHarness } from 'test/harness';
|
||||
import Presentation from './Presentation';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -96,7 +98,7 @@ const OpenArtifactPanel = () => {
|
|||
|
||||
const OpenSubagentPanel = () => {
|
||||
const setConversation = useSetRecoilState(store.conversationByIndex(0));
|
||||
const setSelection = useSetRecoilState(activeSubagentPanel);
|
||||
const setSelection = useSetAtom(activeSubagentPanel);
|
||||
const open = () => {
|
||||
setConversation({ conversationId: 'parent-conversation' } as TConversation);
|
||||
setSelection({
|
||||
|
|
@ -146,11 +148,13 @@ describe('Presentation Artifact loading', () => {
|
|||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Presentation>
|
||||
<OpenArtifactPanel />
|
||||
</Presentation>
|
||||
</RecoilRoot>,
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Presentation>
|
||||
<OpenArtifactPanel />
|
||||
</Presentation>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(testGlobal.presentationArtifactModuleEvaluations ?? 0).toBe(0);
|
||||
|
|
@ -164,11 +168,13 @@ describe('Presentation Artifact loading', () => {
|
|||
|
||||
it('loads the parent child index only for Agent conversations', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Presentation>
|
||||
<SelectAgentConversation />
|
||||
</Presentation>
|
||||
</RecoilRoot>,
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Presentation>
|
||||
<SelectAgentConversation />
|
||||
</Presentation>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith('', { enabled: false });
|
||||
|
|
@ -180,12 +186,14 @@ describe('Presentation Artifact loading', () => {
|
|||
|
||||
it('uses one panel slot and lets an opened artifact replace child activity', async () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Presentation>
|
||||
<OpenSubagentPanel />
|
||||
<OpenArtifactPanel />
|
||||
</Presentation>
|
||||
</RecoilRoot>,
|
||||
<ChatSurfaceHarness>
|
||||
<RecoilRoot>
|
||||
<Presentation>
|
||||
<OpenSubagentPanel />
|
||||
<OpenArtifactPanel />
|
||||
</Presentation>
|
||||
</RecoilRoot>
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: mockOpenChildLabel }));
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { lazy, Suspense, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { EModelEndpoint, FileSources, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import useResetArtifactsOnConversationChange from '~/hooks/Artifacts/useResetArtifactsOnConversationChange';
|
||||
import { ParentSubagentsProvider } from '~/components/Chat/Subagents/ParentSubagentsProvider';
|
||||
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
|
||||
import { activeSubagentPanel } from '~/components/Chat/Subagents/state';
|
||||
import { EditorProvider, ArtifactsProvider } from '~/Providers';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import AppChatSurface from '~/components/Chat/Surface';
|
||||
import { useSetFilesToDelete } from '~/hooks';
|
||||
import { failedFileIdsFrom } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
|
@ -30,8 +32,9 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
const conversationId = useRecoilValue(store.conversationIdByIndex(0));
|
||||
const conversationEndpoint = useRecoilValue(store.effectiveEndpointByIndex(0));
|
||||
const conversationAgentId = useRecoilValue(store.conversationAgentIdByIndex(0));
|
||||
const selectedSubagent = useRecoilValue(activeSubagentPanel);
|
||||
const resetSelectedSubagent = useResetRecoilState(activeSubagentPanel);
|
||||
const selectedSubagent = useAtomValue(activeSubagentPanel);
|
||||
const setSelectedSubagent = useSetAtom(activeSubagentPanel);
|
||||
const resetSelectedSubagent = useCallback(() => setSelectedSubagent(null), [setSelectedSubagent]);
|
||||
const previousConversationIdRef = useRef<string | null>(null);
|
||||
|
||||
useResetArtifactsOnConversationChange();
|
||||
|
|
@ -138,16 +141,18 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
|
||||
return (
|
||||
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
||||
<ParentSubagentsProvider
|
||||
conversationId={conversationId ?? ''}
|
||||
enabled={conversationEndpoint === EModelEndpoint.agents && conversationAgentId != null}
|
||||
>
|
||||
<SidePanelGroup panel={panelElement}>
|
||||
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||||
{children}
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
</ParentSubagentsProvider>
|
||||
<AppChatSurface>
|
||||
<ParentSubagentsProvider
|
||||
conversationId={conversationId ?? ''}
|
||||
enabled={conversationEndpoint === EModelEndpoint.agents && conversationAgentId != null}
|
||||
>
|
||||
<SidePanelGroup panel={panelElement}>
|
||||
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||||
{children}
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
</ParentSubagentsProvider>
|
||||
</AppChatSurface>
|
||||
</DragDropWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ParentSubagentSummary } from 'librechat-data-provider';
|
||||
import type { ActiveSubagentPanel } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel } from './state';
|
||||
import EventSubagentActivityGroup from './EventSubagentActivityGroup';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { ChatSurfaceHarness, testChatSurface } from 'test/harness';
|
||||
import { activeSubagentPanel } from './state';
|
||||
|
||||
const mockRefresh = jest.fn().mockResolvedValue(undefined);
|
||||
const mockChild: ParentSubagentSummary = {
|
||||
|
|
@ -81,17 +82,18 @@ describe('EventSubagentActivityGroup', () => {
|
|||
it('opens the durable event child under its owning parent message', () => {
|
||||
let selection: ActiveSubagentPanel | null = null;
|
||||
const Observer = () => {
|
||||
selection = useRecoilValue(activeSubagentPanel);
|
||||
selection = useAtomValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
const claimForeground = jest.fn();
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness surface={testChatSurface({ claimForeground })}>
|
||||
<Observer />
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(
|
||||
|
|
@ -102,6 +104,9 @@ describe('EventSubagentActivityGroup', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ }));
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
/** Whatever else holds the slot has to give it up, or the reader clicks
|
||||
* through to a panel the host never brings forward. */
|
||||
expect(claimForeground).toHaveBeenCalledTimes(1);
|
||||
expect(selection).toEqual(
|
||||
expect.objectContaining({
|
||||
host: 'conversation',
|
||||
|
|
@ -120,12 +125,12 @@ describe('EventSubagentActivityGroup', () => {
|
|||
|
||||
it('themes the group root so raw child-row buttons never inherit the unthemed body color', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('region', { name: 'com_ui_subagent_activity' })).toHaveClass(
|
||||
|
|
@ -135,13 +140,13 @@ describe('EventSubagentActivityGroup', () => {
|
|||
|
||||
it('matches the width of a parallel assistant response', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message']}
|
||||
hasParallelContent
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(
|
||||
|
|
@ -152,18 +157,18 @@ describe('EventSubagentActivityGroup', () => {
|
|||
it('retains a merged anchor that has no children yet', () => {
|
||||
let selection: ActiveSubagentPanel | null = null;
|
||||
const Observer = () => {
|
||||
selection = useRecoilValue(activeSubagentPanel);
|
||||
selection = useAtomValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<Observer />
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message', 'empty-assistant-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ }));
|
||||
|
|
@ -194,18 +199,18 @@ describe('EventSubagentActivityGroup', () => {
|
|||
]);
|
||||
let selection: ActiveSubagentPanel | null = null;
|
||||
const Observer = () => {
|
||||
selection = useRecoilValue(activeSubagentPanel);
|
||||
selection = useAtomValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<Observer />
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message', 'assistant-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
const summary = screen.getByRole('button', { name: /com_ui_subagent_activity/ });
|
||||
|
|
@ -224,12 +229,12 @@ describe('EventSubagentActivityGroup', () => {
|
|||
mockChildrenByMessage = new Map([['parent-message', [mockChild, mockCompletedChild]]]);
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ }));
|
||||
|
|
@ -263,22 +268,22 @@ describe('EventSubagentActivityGroup', () => {
|
|||
}),
|
||||
);
|
||||
const Observer = () => {
|
||||
selection = useRecoilValue(activeSubagentPanel);
|
||||
selection = useAtomValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
const ClosePanel = () => {
|
||||
const setSelection = useSetRecoilState(activeSubagentPanel);
|
||||
const setSelection = useSetAtom(activeSubagentPanel);
|
||||
return <button type="button" data-testid="close-panel" onClick={() => setSelection(null)} />;
|
||||
};
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<Observer />
|
||||
<ClosePanel />
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ }));
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Button, cn } from '@librechat/client';
|
||||
import { Bot, ChevronDown } from 'lucide-react';
|
||||
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import type { ParentSubagentSummary } from 'librechat-data-provider';
|
||||
import { isLiveSubagentStatus, subagentStatusDotClass, subagentStatusLabelKey } from './status';
|
||||
import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow';
|
||||
import { useChatSurface, useOpenSubagentPanel } from './surface';
|
||||
import { useParentSubagents } from './ParentSubagentsProvider';
|
||||
import { eventSubagentSelection } from './eventSelection';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { activeSubagentPanel } from './state';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
const STATUS_COUNT_LABEL_KEYS = {
|
||||
dispatched: {
|
||||
|
|
@ -60,7 +60,7 @@ export default function EventSubagentActivityGroup({
|
|||
return true;
|
||||
});
|
||||
}, [byMessageId, parentMessageIds]);
|
||||
const fullWidth = useRecoilValue(store.maximizeChatSpace);
|
||||
const { maximizeChatSpace: fullWidth } = useChatSurface();
|
||||
const siblingParentMessageIds = useMemo(
|
||||
() => Array.from(new Set(parentMessageIds)),
|
||||
[parentMessageIds],
|
||||
|
|
@ -94,9 +94,8 @@ function EventSubagentRows({
|
|||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const { refresh } = useParentSubagents();
|
||||
const setSelected = useSetRecoilState(activeSubagentPanel);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const setSelected = useSetAtom(activeSubagentPanel);
|
||||
const openPanel = useOpenSubagentPanel();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const counts = useMemo(() => {
|
||||
|
|
@ -118,10 +117,8 @@ function EventSubagentRows({
|
|||
const openChild = useCallback(
|
||||
(child: ParentSubagentSummary) => {
|
||||
const selection = eventSubagentSelection(conversationId, child, siblingParentMessageIds);
|
||||
if (selection == null) return;
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
setSelected(selection);
|
||||
if (selection == null || openPanel == null) return;
|
||||
openPanel(selection);
|
||||
void refresh().then((index) => {
|
||||
const fresh = index?.children.find((candidate) => candidate.threadId === child.threadId);
|
||||
if (fresh == null || fresh.latestTaskId === child.latestTaskId) return;
|
||||
|
|
@ -143,14 +140,7 @@ function EventSubagentRows({
|
|||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
conversationId,
|
||||
refresh,
|
||||
resetCurrentArtifactId,
|
||||
setArtifactsVisible,
|
||||
setSelected,
|
||||
siblingParentMessageIds,
|
||||
],
|
||||
[conversationId, openPanel, refresh, setSelected, siblingParentMessageIds],
|
||||
);
|
||||
return (
|
||||
<section
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
|
|
@ -7,6 +6,7 @@ import SubagentCall from '~/components/Chat/Messages/Content/Parts/SubagentCall'
|
|||
import SharedSubagentActivityDialog from './SharedSubagentActivityDialog';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { ShareContext } from '~/Providers/ShareContext';
|
||||
import { ChatSurfaceHarness } from 'test/harness';
|
||||
|
||||
const mockUseSubagentThreadQuery = jest.fn();
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ function renderSharedCall(input: {
|
|||
detached?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<ShareContext.Provider value={{ isSharedConvo: true, shareId: 'share-1' }}>
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
|
|
@ -115,7 +115,7 @@ function renderSharedCall(input: {
|
|||
<SharedSubagentActivityDialog shareId="share-1" />
|
||||
</MessageContext.Provider>
|
||||
</ShareContext.Provider>
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { useAtom } from 'jotai';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
|
||||
import { SubagentActivityScrollSurface } from './SubagentActivity';
|
||||
import SubagentConversation from './SubagentConversation';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { adaptLivePersistedActivity } from './adapters';
|
||||
import { activeSubagentPanel } from './state';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/** Public-share fallback for subagent activity already embedded in the shared message payload. */
|
||||
export default function SharedSubagentActivityDialog({ shareId }: { shareId?: string }) {
|
||||
const localize = useLocalize();
|
||||
const selected = useRecoilValue(activeSubagentPanel);
|
||||
const resetSelection = useResetRecoilState(activeSubagentPanel);
|
||||
const [selected, setSelected] = useAtom(activeSubagentPanel);
|
||||
const resetSelection = useCallback(() => setSelected(null), [setSelected]);
|
||||
const selection = selected?.host === 'share' && selected.shareId === shareId ? selected : null;
|
||||
const restoreSelectionRef = useRef(selection);
|
||||
if (selection != null) restoreSelectionRef.current = selection;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { ChildActivity } from './adapters';
|
||||
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
|
||||
import { ChatSurfaceHarness } from 'test/harness';
|
||||
|
||||
const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: RecoilRoot });
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ChatSurfaceHarness>{children}</ChatSurfaceHarness>
|
||||
);
|
||||
const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: Wrapper });
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Button } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
|
|
@ -18,9 +17,9 @@ import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
|||
import Container from '~/components/Chat/Messages/Content/Container';
|
||||
import { EmptyText } from '~/components/Chat/Messages/Content/Parts';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import { useChatSurface } from './surface';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const AT_BOTTOM_THRESHOLD_PX = 120;
|
||||
const CONTROL_ACTION_LABELS = {
|
||||
|
|
@ -140,7 +139,7 @@ export function SubagentActivityScrollSurface({
|
|||
const scrollButtonRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [isSettled, setIsSettled] = useState(false);
|
||||
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
||||
const { showScrollButton: scrollButtonPreference } = useChatSurface();
|
||||
|
||||
useEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ChildConversationTurn } from './adapters';
|
||||
import SubagentConversation from './SubagentConversation';
|
||||
import { ChatSurfaceHarness } from 'test/harness';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
|
|
@ -108,9 +108,9 @@ const turns: ChildConversationTurn[] = [
|
|||
describe('SubagentConversation', () => {
|
||||
it('renders host triggers and child activity through the main chat row and content modules', () => {
|
||||
const { container } = render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<SubagentConversation turns={turns} />
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2);
|
||||
|
|
@ -154,9 +154,9 @@ describe('SubagentConversation', () => {
|
|||
{ ...turns[0], activity: { ...turns[0].activity, activityTruncated: true } },
|
||||
];
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<SubagentConversation turns={shortened} onLoadTurnDetails={loadDetails} />
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_show_full_activity' }));
|
||||
|
|
@ -179,9 +179,9 @@ describe('SubagentConversation', () => {
|
|||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ChatSurfaceHarness>
|
||||
<SubagentConversation turns={[turns[1], secondEvent]} />
|
||||
</RecoilRoot>,
|
||||
</ChatSurfaceHarness>,
|
||||
);
|
||||
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ChevronDown, CornerDownRight, Radio } from 'lucide-react';
|
||||
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client';
|
||||
|
|
@ -15,9 +14,9 @@ import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
|
|||
import { ElapsedTimer } from '~/components/Chat/Messages/Elapsed';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useChatSurface } from './surface';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const TRIGGER_LABELS = {
|
||||
parent_dispatch: 'com_ui_subagent_trigger_parent_dispatch',
|
||||
|
|
@ -277,7 +276,7 @@ export default function SubagentConversation({
|
|||
detailStateByTask?: ReadonlyMap<string, 'idle' | 'loading' | 'unavailable' | 'error'>;
|
||||
onLoadTurnDetails?: (taskId: string) => void;
|
||||
}) {
|
||||
const fullWidth = useRecoilValue(store.maximizeChatSpace);
|
||||
const { maximizeChatSpace: fullWidth } = useChatSurface();
|
||||
return (
|
||||
<div className="flex flex-col gap-6 py-4" data-subagent-conversation>
|
||||
{turns.map((turn) => (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { useAtom, useSetAtom, useStore } from 'jotai';
|
||||
import { Clock, OctagonPause, X, Zap } from 'lucide-react';
|
||||
import { dataService, ForkOptions } from 'librechat-data-provider';
|
||||
import {
|
||||
|
|
@ -10,13 +11,6 @@ import {
|
|||
useMediaQuery,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
useRecoilCallback,
|
||||
useRecoilState,
|
||||
useRecoilValue,
|
||||
useResetRecoilState,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import type {
|
||||
ParentSubagentTaskSummary,
|
||||
SubagentControlAction,
|
||||
|
|
@ -25,7 +19,7 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { ComposerKeyVerdict, ComposerStopProps, SendAction } from '@librechat/client';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
|
||||
import type { ActiveSubagentPanel, SubagentControlUiState } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel, SubagentControlUiState } from './state';
|
||||
import type { ComposerKeyAction } from '~/utils/shortcuts';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import {
|
||||
|
|
@ -46,22 +40,21 @@ import {
|
|||
activeSubagentPanel,
|
||||
subagentControlStateByTask,
|
||||
subagentControlStateKey,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store/subagents';
|
||||
useSubagentProgress,
|
||||
} from './state';
|
||||
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
|
||||
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
|
||||
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
|
||||
import { isMacPlatform, resolveComposerKeyDown } from '~/utils/shortcuts';
|
||||
import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import useComposerBindings from '~/hooks/Input/useComposerBindings';
|
||||
import { useParentSubagents } from './ParentSubagentsProvider';
|
||||
import SubagentConversation from './SubagentConversation';
|
||||
import { eventSubagentSelection } from './eventSelection';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { isLiveSubagentStatus } from './status';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
import store from '~/store';
|
||||
import { useChatSurface } from './surface';
|
||||
|
||||
const EVENT_TASK_PAGE_SIZE = 3;
|
||||
const TERMINAL_CONTROL_REASONS = new Set([
|
||||
|
|
@ -116,6 +109,7 @@ const failedControlLocaleKey = (reason?: string) => {
|
|||
|
||||
export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
|
||||
const localize = useLocalize();
|
||||
const panelStore = useStore();
|
||||
const { showToast } = useToastContext();
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -126,8 +120,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
* `aside` the trap knows, so Tab never reaches it. Either way those readers
|
||||
* get the same actions as controls of their own, inside the panel. */
|
||||
const coarsePointer = useMediaQuery('(hover: none)');
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
|
||||
const {
|
||||
enterToSend,
|
||||
handOffComposerText,
|
||||
composerBindings: { shortcutsEnabled, submitOverride, yieldedChords },
|
||||
} = useChatSurface();
|
||||
const [actorPickerOpen, setActorPickerOpen] = useState(false);
|
||||
/** A continuation is out. Declared here because the selection-advance effect
|
||||
* below has to defer to it, well before the mutation itself exists. */
|
||||
|
|
@ -136,17 +133,15 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
/** Unsent words in the composer are enough on their own: whatever put them
|
||||
* there, an advance would change the control identity and wipe them. */
|
||||
const selectionHeldForDraft = continuationPending || controlMessage.trim() !== '';
|
||||
const resetSelection = useResetRecoilState(activeSubagentPanel);
|
||||
const setSelection = useSetRecoilState(activeSubagentPanel);
|
||||
const setSelection = useSetAtom(activeSubagentPanel);
|
||||
const resetSelection = useCallback(() => setSelection(null), [setSelection]);
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const { byMessageId, byThreadId, refresh } = useParentSubagents();
|
||||
const progress = useRecoilValue(
|
||||
subagentProgressByToolCallId(
|
||||
subagentProgressKey(
|
||||
selection.parentMessageId,
|
||||
selection.event?.progressKey ?? selection.toolCallId,
|
||||
selection.partIndex,
|
||||
),
|
||||
const progress = useSubagentProgress(
|
||||
subagentProgressKey(
|
||||
selection.parentMessageId,
|
||||
selection.event?.progressKey ?? selection.toolCallId,
|
||||
selection.partIndex,
|
||||
),
|
||||
);
|
||||
const foregroundTitle =
|
||||
|
|
@ -156,14 +151,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const threadId = selection.durable?.threadId ?? '';
|
||||
const taskId = selection.durable?.taskId ?? '';
|
||||
const controlIdentity = subagentControlStateKey(selection.parentConversationId, threadId, taskId);
|
||||
const [controlState, setControlState] = useRecoilState(
|
||||
subagentControlStateByTask(controlIdentity),
|
||||
);
|
||||
const setControlStateForIdentity = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(identity: string, state: SubagentControlUiState | null) =>
|
||||
set(subagentControlStateByTask(identity), state),
|
||||
[],
|
||||
const [controlState, setControlState] = useAtom(subagentControlStateByTask(controlIdentity));
|
||||
const setControlStateForIdentity = useCallback(
|
||||
(identity: string, state: SubagentControlUiState | null) =>
|
||||
panelStore.set(subagentControlStateByTask(identity), state),
|
||||
[panelStore],
|
||||
);
|
||||
const transientControl = controlState?.receipt ?? null;
|
||||
const retryControl = controlState?.retry ?? null;
|
||||
|
|
@ -302,12 +294,6 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
* that made the request. Switch actors mid-flight and the words in the field
|
||||
* are the new actor's, so this falls back to what was there at submission. */
|
||||
const continuationRef = useRef<{ identity: string; text: string } | null>(null);
|
||||
const handOffComposerText = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(conversationId: string, text: string) =>
|
||||
set(store.pendingComposerTextByConvoId(conversationId), text),
|
||||
[],
|
||||
);
|
||||
const continueChat = useForkConvoMutation({
|
||||
onSuccess: (result) => {
|
||||
const continuedConversationId = result.conversation?.conversationId;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import type {
|
|||
SubagentThreadView,
|
||||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import type { SubagentProgress } from '~/store/subagents';
|
||||
import { REDACTED_REASONING_MARKER } from '~/store/subagents';
|
||||
import type { SubagentProgress } from './state';
|
||||
import { REDACTED_REASONING_MARKER } from './state';
|
||||
|
||||
export type ChildActivityItem =
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ParentSubagentSummary } from 'librechat-data-provider';
|
||||
import type { ActiveSubagentPanel } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel } from './state';
|
||||
|
||||
export const eventTaskProgressKey = (threadId: string, taskId: string) =>
|
||||
`event-task:${threadId}:${taskId}`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import React from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai';
|
||||
import type { SubagentUpdateEvent } from 'librechat-data-provider';
|
||||
import { closeParentSubagentProgress, reduceSubagentProgress } from './subagents';
|
||||
import {
|
||||
closeParentSubagentProgress,
|
||||
reduceSubagentProgress,
|
||||
registerSubagentProgressKey,
|
||||
removeSubagentProgressAtoms,
|
||||
subagentParentStreamOpenByToolCallId,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
takeRegisteredSubagentProgressKeys,
|
||||
useSubagentProgress,
|
||||
} from './state';
|
||||
|
||||
const update = (overrides: Partial<SubagentUpdateEvent> = {}): SubagentUpdateEvent => ({
|
||||
runId: 'root-run',
|
||||
|
|
@ -388,3 +401,87 @@ describe('reduceSubagentProgress', () => {
|
|||
expect(continued?.tickerState.lines.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSubagentProgressAtoms', () => {
|
||||
it('releases both family members held for one invocation', () => {
|
||||
const key = subagentProgressKey('parent-message', 'tool-call', 0);
|
||||
const progress = subagentProgressByToolCallId(key);
|
||||
const streamOpen = subagentParentStreamOpenByToolCallId(key);
|
||||
expect(subagentProgressByToolCallId(key)).toBe(progress);
|
||||
expect(subagentParentStreamOpenByToolCallId(key)).toBe(streamOpen);
|
||||
|
||||
removeSubagentProgressAtoms(key);
|
||||
|
||||
expect(subagentProgressByToolCallId(key)).not.toBe(progress);
|
||||
expect(subagentParentStreamOpenByToolCallId(key)).not.toBe(streamOpen);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSubagentProgress', () => {
|
||||
beforeEach(() => {
|
||||
takeRegisteredSubagentProgressKeys();
|
||||
});
|
||||
|
||||
/** Only the chat route owns the stream drain, so a card rendered anywhere
|
||||
* else has to clean up after itself. */
|
||||
it('frees a member nothing but the read created', () => {
|
||||
const key = subagentProgressKey('search-result', 'search-call', 0);
|
||||
const { result, unmount } = renderHook(() => useSubagentProgress(key));
|
||||
const held = subagentProgressByToolCallId(key);
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(subagentProgressByToolCallId(key)).not.toBe(held);
|
||||
});
|
||||
|
||||
/** A panel opened before the first event reads the same still-empty member as
|
||||
* the card that spawned it. Freeing it when the panel closes leaves the card
|
||||
* subscribed to a member the next SSE write will not land on. */
|
||||
it('keeps a member a second reader still holds', () => {
|
||||
const key = subagentProgressKey('shared-message', 'shared-call', 0);
|
||||
const card = renderHook(() => useSubagentProgress(key));
|
||||
const panel = renderHook(() => useSubagentProgress(key));
|
||||
const held = subagentProgressByToolCallId(key);
|
||||
|
||||
panel.unmount();
|
||||
expect(subagentProgressByToolCallId(key)).toBe(held);
|
||||
|
||||
card.unmount();
|
||||
expect(subagentProgressByToolCallId(key)).not.toBe(held);
|
||||
});
|
||||
|
||||
/** The drain owns a streaming key; freeing it here would race that boundary. */
|
||||
it('leaves a key the stream registered to the drain', () => {
|
||||
const key = subagentProgressKey('live-message', 'live-call', 0);
|
||||
registerSubagentProgressKey(key);
|
||||
const { unmount } = renderHook(() => useSubagentProgress(key));
|
||||
const held = subagentProgressByToolCallId(key);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(subagentProgressByToolCallId(key)).toBe(held);
|
||||
});
|
||||
|
||||
/** Folded activity is the record of what the child did: a reader going away
|
||||
* must not take it with them, or reopening the card shows nothing. */
|
||||
it('keeps a member holding activity', () => {
|
||||
const key = subagentProgressKey('finished-message', 'finished-call', 0);
|
||||
const store = createStore();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(JotaiProvider, { store }, children);
|
||||
const { unmount } = renderHook(() => useSubagentProgress(key), { wrapper });
|
||||
const held = subagentProgressByToolCallId(key);
|
||||
act(() =>
|
||||
store.set(
|
||||
subagentProgressByToolCallId(key),
|
||||
reduceSubagentProgress(null, [update({ phase: 'stop' })]),
|
||||
),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(subagentProgressByToolCallId(key)).toBe(held);
|
||||
expect(store.get(held)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { atom, atomFamily } from 'recoil';
|
||||
import { useEffect } from 'react';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { atom, useAtomValue, useStore } from 'jotai';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
PartMetadata,
|
||||
|
|
@ -8,7 +10,8 @@ import type {
|
|||
TMessageContentParts,
|
||||
SubagentUpdateEvent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AtomEffect } from 'recoil';
|
||||
import type { Getter, PrimitiveAtom, WritableAtom } from 'jotai';
|
||||
import type { SetStateAction } from 'react';
|
||||
import type {
|
||||
SubagentAggregatorState,
|
||||
SubagentContentPart,
|
||||
|
|
@ -284,10 +287,7 @@ export type ActiveSubagentPanel = {
|
|||
};
|
||||
};
|
||||
|
||||
export const activeSubagentPanel = atom<ActiveSubagentPanel | null>({
|
||||
key: 'activeSubagentPanel',
|
||||
default: null,
|
||||
});
|
||||
export const activeSubagentPanel = atom<ActiveSubagentPanel | null>(null);
|
||||
|
||||
export type SubagentControlUiReceipt = Omit<SubagentControlReceipt, 'status'> & {
|
||||
status: SubagentControlReceipt['status'] | 'submitted';
|
||||
|
|
@ -367,42 +367,70 @@ const storedControlState = (value: unknown): SubagentControlUiState | null => {
|
|||
};
|
||||
};
|
||||
|
||||
const subagentControlStorageEffect =
|
||||
(identity: string): AtomEffect<SubagentControlUiState | null> =>
|
||||
({ setSelf, onSet }) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const storageKey = `${SUBAGENT_CONTROL_STORAGE_PREFIX}${encodeURIComponent(identity)}`;
|
||||
const controlStorageKey = (identity: string): string =>
|
||||
`${SUBAGENT_CONTROL_STORAGE_PREFIX}${encodeURIComponent(identity)}`;
|
||||
|
||||
const restoreControlState = (identity: string): SubagentControlUiState | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const storageKey = controlStorageKey(identity);
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey);
|
||||
if (raw == null) return null;
|
||||
const restored = storedControlState(JSON.parse(raw));
|
||||
if (restored == null) window.sessionStorage.removeItem(storageKey);
|
||||
return restored;
|
||||
} catch {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey);
|
||||
if (raw != null) {
|
||||
const restored = storedControlState(JSON.parse(raw));
|
||||
if (restored == null) window.sessionStorage.removeItem(storageKey);
|
||||
else setSelf(restored);
|
||||
}
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
try {
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Some privacy modes deny session storage entirely.
|
||||
}
|
||||
// Some privacy modes deny session storage entirely.
|
||||
}
|
||||
onSet((next, _previous, isReset) => {
|
||||
try {
|
||||
if (isReset || next?.retry == null) window.sessionStorage.removeItem(storageKey);
|
||||
else window.sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// Storage is best-effort; the in-memory receipt still protects this mounted session.
|
||||
}
|
||||
});
|
||||
};
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const persistControlState = (identity: string, next: SubagentControlUiState | null): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const storageKey = controlStorageKey(identity);
|
||||
try {
|
||||
/** Only an ambiguous retry has to outlive the tab's memory; anything else
|
||||
* is reconstructed from the durable receipts on the next read. */
|
||||
if (next?.retry == null) window.sessionStorage.removeItem(storageKey);
|
||||
else window.sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// Storage is best-effort; the in-memory receipt still protects this mounted session.
|
||||
}
|
||||
};
|
||||
|
||||
/** Parent-owned control state survives closing the activity panel or selecting
|
||||
* another child. Ambiguous retries also survive a full page reload in this tab;
|
||||
* durable receipts clear both copies after authoritative reconciliation. */
|
||||
export const subagentControlStateByTask = atomFamily<SubagentControlUiState | null, string>({
|
||||
key: 'subagentControlStateByTask',
|
||||
default: null,
|
||||
effects_UNSTABLE: (identity) => [subagentControlStorageEffect(identity)],
|
||||
const UNWRITTEN = Symbol('unwritten');
|
||||
|
||||
export const subagentControlStateByTask = atomFamily<
|
||||
string,
|
||||
WritableAtom<SubagentControlUiState | null, [SetStateAction<SubagentControlUiState | null>], void>
|
||||
>((identity: string) => {
|
||||
/** Restored per store rather than per family member: a member is created
|
||||
* once for the tab, and a reload has to see what the last one persisted. */
|
||||
const restored = atom(() => restoreControlState(identity));
|
||||
const held: PrimitiveAtom<SubagentControlUiState | null | typeof UNWRITTEN> = atom(
|
||||
UNWRITTEN as SubagentControlUiState | null | typeof UNWRITTEN,
|
||||
);
|
||||
const read = (get: Getter): SubagentControlUiState | null => {
|
||||
const current = get(held);
|
||||
return current === UNWRITTEN ? get(restored) : current;
|
||||
};
|
||||
return atom(read, (get, set, update: SetStateAction<SubagentControlUiState | null>) => {
|
||||
const next =
|
||||
typeof update === 'function'
|
||||
? (update as (previous: SubagentControlUiState | null) => SubagentControlUiState | null)(
|
||||
read(get),
|
||||
)
|
||||
: update;
|
||||
set(held, next);
|
||||
persistControlState(identity, next);
|
||||
});
|
||||
});
|
||||
|
||||
/** Stable identity for one subagent invocation in the parent conversation. */
|
||||
|
|
@ -413,21 +441,19 @@ export const subagentProgressKey = (
|
|||
) => `${parentMessageId}\u0000${toolCallId}\u0000${partIndex}`;
|
||||
|
||||
/** Progress state keyed by one concrete tool-call content-part occurrence. */
|
||||
export const subagentProgressByToolCallId = atomFamily<SubagentProgress | null, string>({
|
||||
key: 'subagentProgressByToolCallId',
|
||||
default: null,
|
||||
});
|
||||
export const subagentProgressByToolCallId = atomFamily((_key: string) =>
|
||||
atom<SubagentProgress | null>(null),
|
||||
);
|
||||
|
||||
/** Parent delivery remains authoritative until its ordered SSE close boundary. */
|
||||
export const subagentParentStreamOpenByToolCallId = atomFamily<boolean, string>({
|
||||
key: 'subagentParentStreamOpenByToolCallId',
|
||||
default: false,
|
||||
});
|
||||
export const subagentParentStreamOpenByToolCallId = atomFamily((_key: string) => atom(false));
|
||||
|
||||
/**
|
||||
* Invocation atoms populated by either the parent generation stream or the selected detached
|
||||
* task stream. The conversation host drains this registry on navigation so both transports share
|
||||
* one cleanup boundary instead of leaking detached-only atom-family members for the app lifetime.
|
||||
* Members a card created by reading are not enrolled here — they are freed at that card's unmount
|
||||
* instead, since the routes that render one do not all own this drain.
|
||||
*/
|
||||
const registeredSubagentProgressKeys = new Set<string>();
|
||||
|
||||
|
|
@ -445,6 +471,58 @@ export function listRegisteredSubagentProgressKeys(): string[] {
|
|||
return [...registeredSubagentProgressKeys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the family members held for one invocation. An `atomFamily` caches a
|
||||
* member per key for the life of the tab, and every invocation key is unique,
|
||||
* so the drain boundary has to release them or a long session accumulates two
|
||||
* atom configurations per subagent call it ever saw. Callers clear the values
|
||||
* first: `remove` drops the cached member without telling anything subscribed
|
||||
* to it.
|
||||
*/
|
||||
export function removeSubagentProgressAtoms(invocationKey: string): void {
|
||||
subagentProgressByToolCallId.remove(invocationKey);
|
||||
subagentParentStreamOpenByToolCallId.remove(invocationKey);
|
||||
}
|
||||
|
||||
/** How many mounted readers hold each invocation's member. Removing one while
|
||||
* a reader still has it is not merely wasteful: that reader stays subscribed
|
||||
* to the removed atom, the next write lands on the member the family makes to
|
||||
* replace it, and the two never meet again. */
|
||||
const subagentProgressReaders = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Reads one invocation's live progress, and frees the family member once the
|
||||
* last reader lets go and nothing else has a claim on it.
|
||||
*
|
||||
* The read itself creates the member, and only the chat route owns the stream
|
||||
* drain — a card rendered by a search result or by a conversation that finished
|
||||
* streaming long ago would otherwise hold one for the life of the tab. Three
|
||||
* things have to be true before one is freed: no reader is left, no stream
|
||||
* registered the key (that member belongs to the drain), and it holds no folded
|
||||
* activity (that member is the record of what the child did).
|
||||
*/
|
||||
export function useSubagentProgress(invocationKey: string): SubagentProgress | null {
|
||||
const store = useStore();
|
||||
useEffect(() => {
|
||||
subagentProgressReaders.set(
|
||||
invocationKey,
|
||||
(subagentProgressReaders.get(invocationKey) ?? 0) + 1,
|
||||
);
|
||||
return () => {
|
||||
const remaining = (subagentProgressReaders.get(invocationKey) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
subagentProgressReaders.set(invocationKey, remaining);
|
||||
return;
|
||||
}
|
||||
subagentProgressReaders.delete(invocationKey);
|
||||
if (registeredSubagentProgressKeys.has(invocationKey)) return;
|
||||
if (store.get(subagentProgressByToolCallId(invocationKey)) != null) return;
|
||||
removeSubagentProgressAtoms(invocationKey);
|
||||
};
|
||||
}, [invocationKey, store]);
|
||||
return useAtomValue(subagentProgressByToolCallId(invocationKey));
|
||||
}
|
||||
|
||||
const validActivitySequence = (value: number | undefined): value is number =>
|
||||
Number.isSafeInteger(value) && value != null && value >= 0;
|
||||
|
||||
74
client/src/components/Chat/Subagents/surface.tsx
Normal file
74
client/src/components/Chat/Subagents/surface.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { createContext, useContext, useMemo } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ComposerBindings } from '~/hooks/Input/useComposerBindings';
|
||||
import type { ActiveSubagentPanel } from './state';
|
||||
import { activeSubagentPanel } from './state';
|
||||
|
||||
/**
|
||||
* Everything this feature needs from the application hosting it, and nothing
|
||||
* it owns itself. Reading these through the host instead of the app store is
|
||||
* what keeps the feature liftable: a second host already exists (the shared
|
||||
* conversation view), and a host outside this repository would satisfy the
|
||||
* same six members without the feature learning where they come from.
|
||||
*/
|
||||
export interface ChatSurface {
|
||||
/** Enter submits a composer; without it, Enter inserts a newline. */
|
||||
enterToSend: boolean;
|
||||
/** Message rows stretch to the container instead of the reading column. */
|
||||
maximizeChatSpace: boolean;
|
||||
/** The reader wants a jump-to-bottom control on scrollable transcripts. */
|
||||
showScrollButton: boolean;
|
||||
/** The reader's effective composer shortcut bindings. */
|
||||
composerBindings: ComposerBindings;
|
||||
/** Take the shell's foreground slot — the host closes whatever else holds it. */
|
||||
claimForeground: () => void;
|
||||
/** Carry unsent composer words into a conversation the host is about to open. */
|
||||
handOffComposerText: (conversationId: string, text: string) => void;
|
||||
}
|
||||
|
||||
const ChatSurfaceContext = createContext<ChatSurface | null>(null);
|
||||
|
||||
export function ChatSurfaceProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ChatSurface;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ChatSurfaceContext.Provider value={value}>{children}</ChatSurfaceContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChatSurface(): ChatSurface {
|
||||
const surface = useContext(ChatSurfaceContext);
|
||||
if (surface == null) {
|
||||
throw new Error('useChatSurface must be used within a ChatSurfaceProvider');
|
||||
}
|
||||
return surface;
|
||||
}
|
||||
|
||||
/** The host, where one exists. A subagent card is rendered by every message
|
||||
* renderer, and search results are not a chat surface. */
|
||||
export function useOptionalChatSurface(): ChatSurface | null {
|
||||
return useContext(ChatSurfaceContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selecting a child for the panel and taking the foreground are one action:
|
||||
* the panel and whatever else occupies that slot cannot both be open, so a
|
||||
* selection made without the yield leaves the reader looking at the other one.
|
||||
*
|
||||
* `null` where there is no host — a search result renders the same card, but
|
||||
* that route has no panel to open, so the card is not openable there.
|
||||
*/
|
||||
export function useOpenSubagentPanel(): ((selection: ActiveSubagentPanel) => void) | null {
|
||||
const claimForeground = useOptionalChatSurface()?.claimForeground;
|
||||
const setSelection = useSetAtom(activeSubagentPanel);
|
||||
return useMemo(() => {
|
||||
if (claimForeground == null) return null;
|
||||
return (selection: ActiveSubagentPanel) => {
|
||||
claimForeground();
|
||||
setSelection(selection);
|
||||
};
|
||||
}, [claimForeground, setSelection]);
|
||||
}
|
||||
53
client/src/components/Chat/Surface.tsx
Normal file
53
client/src/components/Chat/Surface.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRecoilCallback, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ChatSurface } from '~/components/Chat/Subagents/surface';
|
||||
import { ChatSurfaceProvider } from '~/components/Chat/Subagents/surface';
|
||||
import useComposerBindings from '~/hooks/Input/useComposerBindings';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* The application's adapter for {@link ChatSurface}. Every preference and
|
||||
* shell command the chat surface consumes is resolved here, in host territory,
|
||||
* so the feature below never reaches into the app store for them.
|
||||
*/
|
||||
export default function AppChatSurface({ children }: { children: ReactNode }) {
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const showScrollButton = useRecoilValue(store.showScrollButton);
|
||||
const composerBindings = useComposerBindings();
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const handOffComposerText = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(conversationId: string, text: string) =>
|
||||
set(store.pendingComposerTextByConvoId(conversationId), text),
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<ChatSurface>(
|
||||
() => ({
|
||||
enterToSend,
|
||||
maximizeChatSpace,
|
||||
showScrollButton,
|
||||
composerBindings,
|
||||
/** The artifacts panel and the subagent panel share one slot. */
|
||||
claimForeground: () => {
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
},
|
||||
handOffComposerText,
|
||||
}),
|
||||
[
|
||||
composerBindings,
|
||||
enterToSend,
|
||||
handOffComposerText,
|
||||
maximizeChatSpace,
|
||||
resetCurrentArtifactId,
|
||||
setArtifactsVisible,
|
||||
showScrollButton,
|
||||
],
|
||||
);
|
||||
|
||||
return <ChatSurfaceProvider value={value}>{children}</ChatSurfaceProvider>;
|
||||
}
|
||||
84
client/src/components/Chat/__tests__/Surface.test.tsx
Normal file
84
client/src/components/Chat/__tests__/Surface.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import type { ChatSurface } from '~/components/Chat/Subagents/surface';
|
||||
import { useChatSurface } from '~/components/Chat/Subagents/surface';
|
||||
import AppChatSurface from '../Surface';
|
||||
import store from '~/store';
|
||||
|
||||
/** The feature reads its host through {@link ChatSurface}; this is the other
|
||||
* half — that the application's adapter answers with the reader's real
|
||||
* preferences and drives the real shell when the feature asks. */
|
||||
describe('AppChatSurface', () => {
|
||||
const renderSurface = () => {
|
||||
const seen = { current: null as ChatSurface | null };
|
||||
const app = {
|
||||
artifactsVisible: true as boolean,
|
||||
currentArtifactId: null as string | null,
|
||||
handedOff: undefined as string | undefined,
|
||||
};
|
||||
|
||||
const Consumer = () => {
|
||||
seen.current = useChatSurface();
|
||||
return null;
|
||||
};
|
||||
const AppObserver = () => {
|
||||
app.artifactsVisible = useRecoilValue(store.artifactsVisibility);
|
||||
app.currentArtifactId = useRecoilValue(store.currentArtifactId);
|
||||
app.handedOff = useRecoilValue(store.pendingComposerTextByConvoId('continued-chat'));
|
||||
return null;
|
||||
};
|
||||
const Preferences = () => {
|
||||
const setEnterToSend = useSetRecoilState(store.enterToSend);
|
||||
const setMaximize = useSetRecoilState(store.maximizeChatSpace);
|
||||
const setScrollButton = useSetRecoilState(store.showScrollButton);
|
||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
React.useEffect(() => {
|
||||
setEnterToSend(false);
|
||||
setMaximize(true);
|
||||
setScrollButton(false);
|
||||
setCurrentArtifactId('artifact-1');
|
||||
}, [setCurrentArtifactId, setEnterToSend, setMaximize, setScrollButton]);
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<AppObserver />
|
||||
<Preferences />
|
||||
<AppChatSurface>
|
||||
<Consumer />
|
||||
</AppChatSurface>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
return { seen, app };
|
||||
};
|
||||
|
||||
it('answers with the reader’s own preferences', () => {
|
||||
const { seen } = renderSurface();
|
||||
expect(seen.current).toMatchObject({
|
||||
enterToSend: false,
|
||||
maximizeChatSpace: true,
|
||||
showScrollButton: false,
|
||||
composerBindings: { shortcutsEnabled: expect.any(Boolean) },
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the slot the panel is about to take', () => {
|
||||
const { seen, app } = renderSurface();
|
||||
expect(app.currentArtifactId).toBe('artifact-1');
|
||||
|
||||
act(() => seen.current?.claimForeground());
|
||||
|
||||
expect(app.artifactsVisible).toBe(false);
|
||||
expect(app.currentArtifactId).toBeNull();
|
||||
});
|
||||
|
||||
it('hands unsent words to the conversation the host is opening', () => {
|
||||
const { seen, app } = renderSurface();
|
||||
|
||||
act(() => seen.current?.handOffComposerText('continued-chat', 'Take this further.'));
|
||||
|
||||
expect(app.handedOff).toBe('Take this further.');
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ import { ShareMessagesProvider } from './ShareMessagesProvider';
|
|||
import { useForkSharedConvoMutation } from '~/data-provider';
|
||||
import { useGetSharedStartupConfig } from '~/data-provider';
|
||||
import { ShareArtifactsContainer } from './ShareArtifacts';
|
||||
import AppChatSurface from '../Chat/Surface';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import MessagesView from './MessagesView';
|
||||
import Footer from '../Chat/Footer';
|
||||
|
|
@ -243,12 +244,14 @@ function SharedView() {
|
|||
|
||||
return (
|
||||
<ShareContext.Provider value={{ isSharedConvo: true, shareId }}>
|
||||
<div className="relative flex h-screen w-full overflow-hidden dark:bg-surface-secondary">
|
||||
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
|
||||
{artifactsContainer}
|
||||
</main>
|
||||
</div>
|
||||
<SharedSubagentActivityDialog shareId={shareId} />
|
||||
<AppChatSurface>
|
||||
<div className="relative flex h-screen w-full overflow-hidden dark:bg-surface-secondary">
|
||||
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
|
||||
{artifactsContainer}
|
||||
</main>
|
||||
</div>
|
||||
<SharedSubagentActivityDialog shareId={shareId} />
|
||||
</AppChatSurface>
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import React from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { ContentTypes, QueryKeys, StepEvents } from 'librechat-data-provider';
|
||||
import type { ActiveSubagentPanel } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel } from '~/components/Chat/Subagents/state';
|
||||
import {
|
||||
subagentParentStreamOpenByToolCallId,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
takeRegisteredSubagentProgressKeys,
|
||||
} from '~/store/subagents';
|
||||
} from '~/components/Chat/Subagents/state';
|
||||
import useSubagentActivityStream from './useSubagentActivityStream';
|
||||
import { IsolatedAtomStore } from 'test/harness';
|
||||
|
||||
type Listener = (event: MessageEvent) => void;
|
||||
type MockStream = {
|
||||
|
|
@ -65,7 +66,7 @@ const selection: ActiveSubagentPanel = {
|
|||
};
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
<IsolatedAtomStore>{children}</IsolatedAtomStore>
|
||||
);
|
||||
|
||||
describe('useSubagentActivityStream', () => {
|
||||
|
|
@ -79,7 +80,7 @@ describe('useSubagentActivityStream', () => {
|
|||
const { result, unmount } = renderHook(
|
||||
() => {
|
||||
useSubagentActivityStream(selection);
|
||||
return useRecoilValue(
|
||||
return useAtomValue(
|
||||
subagentProgressByToolCallId(
|
||||
subagentProgressKey(
|
||||
selection.parentMessageId,
|
||||
|
|
@ -145,7 +146,7 @@ describe('useSubagentActivityStream', () => {
|
|||
const { result } = renderHook(
|
||||
() => {
|
||||
useSubagentActivityStream(selection);
|
||||
return useRecoilValue(
|
||||
return useAtomValue(
|
||||
subagentProgressByToolCallId(
|
||||
subagentProgressKey(
|
||||
selection.parentMessageId,
|
||||
|
|
@ -190,9 +191,9 @@ describe('useSubagentActivityStream', () => {
|
|||
() => {
|
||||
useSubagentActivityStream(activeSelection);
|
||||
return {
|
||||
progress: useRecoilValue(subagentProgressByToolCallId(key)),
|
||||
parentOpen: useRecoilValue(subagentParentStreamOpenByToolCallId(key)),
|
||||
closeParent: useSetRecoilState(subagentParentStreamOpenByToolCallId(key)),
|
||||
progress: useAtomValue(subagentProgressByToolCallId(key)),
|
||||
parentOpen: useAtomValue(subagentParentStreamOpenByToolCallId(key)),
|
||||
closeParent: useSetAtom(subagentParentStreamOpenByToolCallId(key)),
|
||||
};
|
||||
},
|
||||
{ wrapper },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { SSE } from 'sse.js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { QueryKeys, StepEvents, apiBaseUrl } from 'librechat-data-provider';
|
||||
import type { SubagentUpdateEvent } from 'librechat-data-provider';
|
||||
import type { ActiveSubagentPanel } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel } from '~/components/Chat/Subagents/state';
|
||||
import {
|
||||
closeParentSubagentProgress,
|
||||
reduceSubagentProgress,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
subagentParentStreamOpenByToolCallId,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store/subagents';
|
||||
} from '~/components/Chat/Subagents/state';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
|
||||
type ActivityEnvelope = {
|
||||
|
|
@ -51,9 +51,9 @@ export default function useSubagentActivityStream(
|
|||
selection.event?.progressKey ?? selection.toolCallId,
|
||||
selection.partIndex,
|
||||
);
|
||||
const setProgress = useSetRecoilState(subagentProgressByToolCallId(key));
|
||||
const parentStreamOpen = useRecoilValue(subagentParentStreamOpenByToolCallId(key));
|
||||
const setParentStreamOpen = useSetRecoilState(subagentParentStreamOpenByToolCallId(key));
|
||||
const setProgress = useSetAtom(subagentProgressByToolCallId(key));
|
||||
const parentStreamOpen = useAtomValue(subagentParentStreamOpenByToolCallId(key));
|
||||
const setParentStreamOpen = useSetAtom(subagentParentStreamOpenByToolCallId(key));
|
||||
const parentStreamOpenRef = useRef(parentStreamOpen);
|
||||
const durable = selection.durable;
|
||||
const threadId = durable?.threadId;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { RecoilRoot, useRecoilCallback } from 'recoil';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
|
|
@ -19,10 +21,14 @@ import type {
|
|||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import type { PtcTrace, PtcTraceEntry } from '~/store/ptc';
|
||||
import {
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/components/Chat/Subagents/state';
|
||||
import { ptcTraceByToolCallId, ptcTraceKey, PTC_TRACE_MAX_ENTRIES } from '~/store/ptc';
|
||||
import { subagentProgressByToolCallId, subagentProgressKey } from '~/store/subagents';
|
||||
import { resolveAskUserQuestionPart } from '~/utils/approval';
|
||||
import useStepHandler from '~/hooks/SSE/useStepHandler';
|
||||
import { IsolatedAtomStore } from 'test/harness';
|
||||
|
||||
/** `Constants` is a heterogeneous enum (`string | number`); annotate as
|
||||
* `string` so the member is usable where a `string` field is expected. */
|
||||
|
|
@ -2987,41 +2993,37 @@ describe('useStepHandler', () => {
|
|||
|
||||
describe('on_subagent_update event', () => {
|
||||
/**
|
||||
* These tests exercise the real Recoil `atomFamily` via a `RecoilRoot`
|
||||
* wrapper and a `useRecoilCallback`-powered reader mounted alongside
|
||||
* the hook under test. No mocks of the store module — only the same
|
||||
* `setMessages`/`getMessages` spies the rest of this file uses.
|
||||
* These tests exercise the real `atomFamily` through an isolated store and
|
||||
* a reader mounted alongside the hook under test. No mocks of the store
|
||||
* module — only the same `setMessages`/`getMessages` spies the rest of
|
||||
* this file uses.
|
||||
*/
|
||||
const subagentStoreWrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(RecoilRoot, null, React.createElement(IsolatedAtomStore, null, children));
|
||||
const renderStepHandlerWithReader = (): {
|
||||
result: ReturnType<typeof renderHook>['result'];
|
||||
getProgress: (toolCallId: string, parentMessageId?: string, partIndex?: number) => unknown;
|
||||
} => {
|
||||
/** Composite hook: the step handler under test + a `useRecoilCallback`
|
||||
* reader that shares the same `RecoilRoot` store. Reading via a
|
||||
* top-level `snapshot_UNSTABLE()` returns a different root, so the
|
||||
* writes done by the step handler wouldn't be visible. */
|
||||
/** Composite hook: the step handler under test + a reader bound to the
|
||||
* same store it writes through. Reading from the default store instead
|
||||
* would answer for a different one, so its writes wouldn't be visible. */
|
||||
const hookResult = renderHook(
|
||||
() => {
|
||||
const stepHandler = useStepHandler(createHookParams());
|
||||
const read = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
(
|
||||
toolCallId: string,
|
||||
parentMessageId: string = 'response-msg-1',
|
||||
partIndex: number = 0,
|
||||
): unknown =>
|
||||
snapshot
|
||||
.getLoadable(
|
||||
subagentProgressByToolCallId(
|
||||
subagentProgressKey(parentMessageId, toolCallId, partIndex),
|
||||
),
|
||||
)
|
||||
.valueOrThrow(),
|
||||
[],
|
||||
);
|
||||
const jotaiStore = useStore();
|
||||
const read = (
|
||||
toolCallId: string,
|
||||
parentMessageId: string = 'response-msg-1',
|
||||
partIndex: number = 0,
|
||||
): unknown =>
|
||||
jotaiStore.get(
|
||||
subagentProgressByToolCallId(
|
||||
subagentProgressKey(parentMessageId, toolCallId, partIndex),
|
||||
),
|
||||
);
|
||||
return { ...stepHandler, read };
|
||||
},
|
||||
{ wrapper: RecoilRoot },
|
||||
{ wrapper: subagentStoreWrapper },
|
||||
);
|
||||
|
||||
const getProgress = (
|
||||
|
|
@ -3129,6 +3131,32 @@ describe('useStepHandler', () => {
|
|||
expect(bucket.subagentType).toBe('self');
|
||||
});
|
||||
|
||||
/** An `atomFamily` caches a member per key for the life of the tab, and every
|
||||
* invocation key is unique. The drain boundary exists to keep that bounded,
|
||||
* so it has to release the members, not merely blank them. */
|
||||
it('frees the family members it drains at the conversation boundary', () => {
|
||||
const { result, getProgress } = renderStepHandlerWithReader();
|
||||
const { submission } = seedResponseWithSubagentToolCalls(result, ['call_A']);
|
||||
|
||||
act(() => {
|
||||
(result.current as any).stepHandler(
|
||||
{
|
||||
event: StepEvents.ON_SUBAGENT_UPDATE,
|
||||
data: makeUpdate({ parentToolCallId: 'call_A', phase: 'start' }),
|
||||
},
|
||||
submission,
|
||||
);
|
||||
});
|
||||
const invocationKey = subagentProgressKey('response-msg-1', 'call_A', 0);
|
||||
const held = subagentProgressByToolCallId(invocationKey);
|
||||
expect(getProgress('call_A')).not.toBeNull();
|
||||
|
||||
act(() => (result.current as any).resetSubagentAtoms());
|
||||
|
||||
expect(getProgress('call_A')).toBeNull();
|
||||
expect(subagentProgressByToolCallId(invocationKey)).not.toBe(held);
|
||||
});
|
||||
|
||||
it('falls back to oldest-unclaimed tool call when parentToolCallId is absent', () => {
|
||||
const { result, getProgress } = renderStepHandlerWithReader();
|
||||
/** Two subagent tool calls seeded in creation order. Without
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import {
|
||||
Constants,
|
||||
|
|
@ -28,13 +29,16 @@ import {
|
|||
listRegisteredSubagentProgressKeys,
|
||||
reduceSubagentProgress,
|
||||
registerSubagentProgressKey,
|
||||
removeSubagentProgressAtoms,
|
||||
subagentParentStreamOpenByToolCallId,
|
||||
subagentProgressByToolCallId,
|
||||
takeRegisteredSubagentProgressKeys,
|
||||
subagentProgressKey,
|
||||
} from '~/components/Chat/Subagents/state';
|
||||
import {
|
||||
sandboxStartingByToolCallId,
|
||||
ptcTraceByToolCallId,
|
||||
PTC_TRACE_MAX_ENTRIES,
|
||||
subagentProgressKey,
|
||||
ptcTraceKey,
|
||||
} from '~/store';
|
||||
import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '~/utils/approval';
|
||||
|
|
@ -156,6 +160,7 @@ export default function useStepHandler({
|
|||
lastAnnouncementTimeRef,
|
||||
onSkillAuthoringComplete,
|
||||
}: TUseStepHandler) {
|
||||
const subagentStore = useStore();
|
||||
const toolCallIdMap = useRef(new Map<string, string | undefined>());
|
||||
const messageMap = useRef(new Map<string, TMessage>());
|
||||
const stepMap = useRef(new Map<string, Agents.RunStep>());
|
||||
|
|
@ -248,73 +253,67 @@ export default function useStepHandler({
|
|||
);
|
||||
|
||||
/**
|
||||
* Merges an incoming {@link SubagentUpdateEvent} into the Recoil atom bucket
|
||||
* keyed by the parent `tool_call_id`. Buffers early-arriving events whose
|
||||
* tool call is not yet mapped, and replays the buffer once correlation
|
||||
* completes.
|
||||
* Merges an incoming {@link SubagentUpdateEvent} into the atom bucket keyed
|
||||
* by the parent `tool_call_id`. Buffers early-arriving events whose tool call
|
||||
* is not yet mapped, and replays the buffer once correlation completes.
|
||||
*/
|
||||
const applySubagentUpdate = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(payload: SubagentUpdateEvent, parentMessageId: string): void => {
|
||||
const invocationKey = resolveSubagentInvocationKey(payload, parentMessageId);
|
||||
const applySubagentUpdate = useCallback(
|
||||
(payload: SubagentUpdateEvent, parentMessageId: string): void => {
|
||||
const invocationKey = resolveSubagentInvocationKey(payload, parentMessageId);
|
||||
|
||||
if (!invocationKey) {
|
||||
const pending = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? {
|
||||
parentMessageId,
|
||||
events: [],
|
||||
};
|
||||
pending.events.push(payload);
|
||||
pendingSubagentBuffer.current.set(payload.subagentRunId, pending);
|
||||
return;
|
||||
}
|
||||
if (!invocationKey) {
|
||||
const pending = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? {
|
||||
parentMessageId,
|
||||
events: [],
|
||||
};
|
||||
pending.events.push(payload);
|
||||
pendingSubagentBuffer.current.set(payload.subagentRunId, pending);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingSubagentBuffer.current.get(payload.subagentRunId);
|
||||
if (pending && pending.events.length > 0) {
|
||||
pendingSubagentBuffer.current.delete(payload.subagentRunId);
|
||||
}
|
||||
const toApply = pending ? [...pending.events, payload] : [payload];
|
||||
const pending = pendingSubagentBuffer.current.get(payload.subagentRunId);
|
||||
if (pending && pending.events.length > 0) {
|
||||
pendingSubagentBuffer.current.delete(payload.subagentRunId);
|
||||
}
|
||||
const toApply = pending ? [...pending.events, payload] : [payload];
|
||||
|
||||
registerSubagentProgressKey(invocationKey);
|
||||
set(subagentParentStreamOpenByToolCallId(invocationKey), true);
|
||||
set(subagentProgressByToolCallId(invocationKey), (prev) =>
|
||||
reduceSubagentProgress(prev, toApply, 'parent', true),
|
||||
);
|
||||
},
|
||||
[resolveSubagentInvocationKey],
|
||||
registerSubagentProgressKey(invocationKey);
|
||||
subagentStore.set(subagentParentStreamOpenByToolCallId(invocationKey), true);
|
||||
subagentStore.set(subagentProgressByToolCallId(invocationKey), (prev) =>
|
||||
reduceSubagentProgress(prev, toApply, 'parent', true),
|
||||
);
|
||||
},
|
||||
[resolveSubagentInvocationKey, subagentStore],
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets all accumulated subagent Recoil state. Kept for conversation-
|
||||
* switch cleanup (see top-level hook usage) but NOT called from
|
||||
* `clearStepMaps` — the collapsed SubagentCall ticker and its panel
|
||||
* read from these atoms to render the child's content parts, and we
|
||||
* want that history to remain visible after the stream ends so the
|
||||
* user can reopen the panel for auditability. The atoms are bounded
|
||||
* by aggregated structure and per-conversation (one atom per
|
||||
* subagent spawn), so growth is proportional to messages — the same
|
||||
* growth profile as the rest of the conversation state.
|
||||
* Resets all accumulated subagent state. Kept for conversation-switch
|
||||
* cleanup (see top-level hook usage) but NOT called from `clearStepMaps` —
|
||||
* the collapsed SubagentCall ticker and its panel read from these atoms to
|
||||
* render the child's content parts, and we want that history to remain
|
||||
* visible after the stream ends so the user can reopen the panel for
|
||||
* auditability. The atoms are bounded by aggregated structure and
|
||||
* per-conversation (one atom per subagent spawn), so growth is proportional
|
||||
* to messages — the same growth profile as the rest of the conversation
|
||||
* state.
|
||||
*/
|
||||
const resetSubagentAtoms = useRecoilCallback(
|
||||
({ reset }) =>
|
||||
(): void => {
|
||||
for (const invocationKey of takeRegisteredSubagentProgressKeys()) {
|
||||
reset(subagentProgressByToolCallId(invocationKey));
|
||||
reset(subagentParentStreamOpenByToolCallId(invocationKey));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const resetSubagentAtoms = useCallback((): void => {
|
||||
for (const invocationKey of takeRegisteredSubagentProgressKeys()) {
|
||||
/** Clear before freeing: `remove` drops the cached family member but
|
||||
* tells nothing still subscribed to it, so anything mounted at this
|
||||
* boundary has to see the empty value first. */
|
||||
subagentStore.set(subagentProgressByToolCallId(invocationKey), null);
|
||||
subagentStore.set(subagentParentStreamOpenByToolCallId(invocationKey), false);
|
||||
removeSubagentProgressAtoms(invocationKey);
|
||||
}
|
||||
}, [subagentStore]);
|
||||
|
||||
const closeParentSubagentStreams = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(): void => {
|
||||
for (const invocationKey of listRegisteredSubagentProgressKeys()) {
|
||||
set(subagentParentStreamOpenByToolCallId(invocationKey), false);
|
||||
set(subagentProgressByToolCallId(invocationKey), closeParentSubagentProgress);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const closeParentSubagentStreams = useCallback((): void => {
|
||||
for (const invocationKey of listRegisteredSubagentProgressKeys()) {
|
||||
subagentStore.set(subagentParentStreamOpenByToolCallId(invocationKey), false);
|
||||
subagentStore.set(subagentProgressByToolCallId(invocationKey), closeParentSubagentProgress);
|
||||
}
|
||||
}, [subagentStore]);
|
||||
|
||||
/** Tool-call ids whose sandbox-starting atom is set, so completion can clear them. */
|
||||
const knownSandboxAtomKeys = useRef(new Set<string>());
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import misc from './misc';
|
|||
export * from './agents';
|
||||
export * from './mcp';
|
||||
export * from './favorites';
|
||||
export * from './subagents';
|
||||
export * from './sandbox';
|
||||
export * from './ptc';
|
||||
export * from './usage';
|
||||
|
|
|
|||
59
client/test/harness.tsx
Normal file
59
client/test/harness.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from 'react';
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai';
|
||||
import type { ChatSurface } from '~/components/Chat/Subagents/surface';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChatSurfaceProvider } from '~/components/Chat/Subagents/surface';
|
||||
|
||||
export type JotaiStore = ReturnType<typeof createStore>;
|
||||
|
||||
/** The application's own defaults, so a test that does not care about a
|
||||
* preference reads what a reader who never changed one would see. */
|
||||
export const testChatSurface = (overrides: Partial<ChatSurface> = {}): ChatSurface => ({
|
||||
enterToSend: true,
|
||||
maximizeChatSpace: false,
|
||||
showScrollButton: true,
|
||||
composerBindings: {
|
||||
shortcutsEnabled: true,
|
||||
submitOverride: undefined,
|
||||
yieldedChords: new Set<string>(),
|
||||
},
|
||||
claimForeground: () => undefined,
|
||||
handOffComposerText: () => undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* One test's own atom store. Jotai's default store is module-global, so
|
||||
* without a provider one test's selection is still there for the next.
|
||||
*/
|
||||
export function IsolatedAtomStore({
|
||||
seed,
|
||||
children,
|
||||
}: {
|
||||
seed?: (store: JotaiStore) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [store] = useState(() => {
|
||||
const created = createStore();
|
||||
seed?.(created);
|
||||
return created;
|
||||
});
|
||||
return <JotaiProvider store={store}>{children}</JotaiProvider>;
|
||||
}
|
||||
|
||||
/** An isolated store plus the host half of the seam the feature reads through. */
|
||||
export function ChatSurfaceHarness({
|
||||
surface,
|
||||
seed,
|
||||
children,
|
||||
}: {
|
||||
surface?: ChatSurface;
|
||||
seed?: (store: JotaiStore) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<IsolatedAtomStore seed={seed}>
|
||||
<ChatSurfaceProvider value={surface ?? testChatSurface()}>{children}</ChatSurfaceProvider>
|
||||
</IsolatedAtomStore>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue