mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 10:36:09 +00:00
🧵 refactor: Give the Subagent Panel One Composer and Main Chat's Metrics (#15436)
* 🧵 refactor: Give the Subagent Panel One Composer and Main Chat's Metrics The panel drifted from main chat in the small measurements a reader notices side by side, and its footer swapped controls under the pointer every time a run settled. - One persistent composer. `@librechat/client` gains a `Composer`: the shared composer surface with a field, a height-holding action row, and the main chat send affordance. The panel renders it for the whole life of a run — Enter steers while the child is live, and continues the thread once it has settled — instead of replacing the input with a "Continue this chat" button at completion. Its placeholder is the main chat one (`Message {agent}`), not bespoke guidance copy. - Continuations carry the draft. Child threads are view-only server-side (`CHILD_THREAD_READ_ONLY_ERROR`), so continuing forks into a real conversation; what the reader typed rides along through the same per-conversation draft store the composer restores from, and comes back in the panel if the fork fails. - Main chat's footer metrics. Turn footers use `messageFooterClasses` and hold their height, so the elapsed timer is sized by the `text-xs` its main chat counterpart inherits rather than the panel's body size, and the reading leaving at completion no longer steps the thread. - The author column matches. Turns render `MessageIcon` unconditionally, and the streaming dot is inset onto the header icon's axis with `underHeaderIcon`, as main chat insets it. - Status indicators pin their color. Label first, dot last, so a longer status grows inboard and the color lands on one column in every row; a live row shimmers its label the way a running tool call does, and the dot no longer pulses against it. - The actor picker is the agent builder's. `ControlCombobox` with agent avatars and search, replacing a bespoke bare `Select`. * 🧷 fix: Keep Enter Off an Empty Field and Drop Dead Actor Rows - The shared `Composer` never submits an empty field on Enter. Continuing a settled thread needs no text, so its send button stays live — but a stray Enter must not navigate the reader out of the panel. - The actor picker lists only actors with a task to open. Its list has no disabled state, so a child without one was a row that silently did nothing; the selected thread stays listed regardless. - The header glyph loses its plate, like the `MessageRow` author slot, so an agent avatar reads as an avatar rather than an inset badge. The send glyph matches main chat's at 24px. * 🎚️ fix: Hand Continuation Text Over in Memory, Not Through Saved Drafts Codex review of the first pass, all three findings. - The continuation's text no longer goes through the composer draft store. Draft restoration is gated on the Save Drafts preference, so with drafts off the forked chat opened empty and the words were left sitting in local storage the reader had asked not to use. A new transient `pendingComposerTextByConvoId` carries them in memory instead, keyed by conversation so the handoff survives a navigation that resolves its record before moving the route; `useTextarea` drains it once, after the autosave restore it must not lose a race with. - The draft is read when the fork lands, not when it was requested, so anything typed while the request was in flight travels too. - Actors whose task id has not arrived are left out of the picker rather than offered as rows that cannot open anything (already in the previous commit; the filter keeps the selected thread listed regardless). * ⌨️ fix: Give the Panel Composer Main Chat's Keyboard Contract Codex round two. - The composer honors "Press Enter to send". With the preference off a bare Enter is a line break and never reaches the field's action, so reaching for one cannot steer a live run or leave for a continued chat; ⌘/Ctrl+Enter submits there instead. - The IME guard covers Safari. `isComposing` is already false on the very Enter that commits a candidate there, so tracked composition state and the legacy `Process`/229 signals stand in for it, as the main chat composer's own guard does. - The actor picker renders in the panel rather than a portal. On mobile the panel is a modal whose focus trap only knows its own descendants, so a portaled search field let Tab escape to the page behind it. * 🧭 fix: Bind the Continuation Draft, Contain Escape, Defer to the Key Policy Codex round three. - The continuation draft is bound to the selection that asked for it. The composer is shared across selections, so a fork that lands after the reader moved on was handing over the words now in front of them and clearing the composer they belong to. The live field is still preferred, but only while it is still that continuation's own. - Escape is handled on the panel element instead of through the focus trap's callback. That callback runs on a native listener on the aside, so it fired BEFORE any React handler inside it and closed the whole panel — taking the composer's text — when a nested popover meant to dismiss only itself. As a React handler it bubbles in DOM order, so the actor picker can contain its own Escape; `ControlCombobox` gained an `onOpenChange` so the panel knows when that is. - The composer defers to the host's key policy. `Composer` takes an optional `resolveKeyVerdict`, and the panel routes it through the same `resolveComposerKeyDown`/`useComposerBindings` the main chat form uses, so a rebound or unbound submit shortcut behaves identically here and chords claimed by global shortcuts are left for the window handler. * 🔑 fix: Stop the Trap Eating Escape, Freeze the Actor Mid-Continuation Codex round four. - `useFocusTrap` only consumes Escape when it actually answers it. Its listener is native and sits on the container, so it runs before every React handler inside — and by calling `preventDefault` with no callback it swallowed the key from the host that had taken Escape back, leaving the panel unclosable. Both other callers pass a handler, so nothing else changes. The panel suite now runs the REAL trap: stubbing it is what let the previous Escape assertions pass in both directions. - Nothing swaps the composer while a continuation is out. The panel holds the draft that travels with the fork, so the actor picker is frozen and the newest-task advance defers until the request settles, rather than leaving another actor's words to be discarded with the panel. - The 4 KiB cap applies only in control mode. It bounds a subagent control command's payload; a continuation is ordinary chat text bound for a composer that caps nothing. * 🪢 fix: Hold the Selection While a Failed Continuation's Words Survive Codex round five. The newest-task advance deferred while a continuation was in flight, but the error path released that hold at once — so the advance fired, the control identity changed, and the identity effect wiped the composer. The panel stays open after a failed continuation precisely so the reader can retry with those words, and this threw them away. The hold now covers a failed continuation for as long as its draft is still in the box, and releases itself the moment the draft is sent or cleared. The test asserts both halves, and fails if the hold narrows back to the in-flight case. * 🧹 fix: Hold for Any Unsent Draft, and Let a Cleared One Stay Cleared Codex round six. - Unsent words hold the selection wherever they came from. Protecting the draft only once a request had started or failed left the plainest case open: type a continuation, let a newer task arrive, and the advance changed the control identity and emptied the composer. The hold is now `continuationPending || draft is non-empty`, which subsumes the failed case and lets the extra flag go. - Emptying the field after asking to continue is the reader withdrawing the words. While the composer still belongs to that continuation it IS the draft, verbatim, so the destination no longer reinstates text they deleted; only a composer that has moved to another selection falls back to what was captured at submission.
This commit is contained in:
parent
47dbcb4910
commit
bd24e45489
17 changed files with 1391 additions and 248 deletions
|
|
@ -220,6 +220,40 @@ describe('EventSubagentActivityGroup', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('pins the status color to one column and shimmers only a live row', () => {
|
||||
mockChildrenByMessage = new Map([['parent-message', [mockChild, mockCompletedChild]]]);
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<EventSubagentActivityGroup
|
||||
conversationId="parent-conversation"
|
||||
parentMessageIds={['parent-message']}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ }));
|
||||
|
||||
const rows = [
|
||||
screen.getByRole('button', { name: /Visible Agent/ }),
|
||||
screen.getByRole('button', { name: /Completed Agent/ }),
|
||||
];
|
||||
const [running, completed] = rows.map((row) => row.lastElementChild as HTMLElement);
|
||||
|
||||
/** Label first, dot last, in every row: a longer status can only grow
|
||||
* inboard, so the color never moves between rows. */
|
||||
expect(running.firstElementChild).toHaveTextContent('com_ui_subagent_thread_status_running');
|
||||
expect(running.lastElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(running.lastElementChild).toHaveClass('bg-status-info');
|
||||
expect(running.firstElementChild).toHaveClass('shimmer');
|
||||
|
||||
expect(completed.firstElementChild).toHaveTextContent(
|
||||
'com_ui_subagent_thread_status_completed',
|
||||
);
|
||||
expect(completed.lastElementChild).toHaveClass('bg-status-success');
|
||||
expect(completed.firstElementChild).not.toHaveClass('shimmer');
|
||||
});
|
||||
|
||||
it('does not reopen a child after the user closes it while refresh is pending', async () => {
|
||||
let selection: ActiveSubagentPanel | null = null;
|
||||
let resolveRefresh!: (value: unknown) => void;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ 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 { subagentStatusDotClass, subagentStatusLabelKey } from './status';
|
||||
import { useParentSubagents } from './ParentSubagentsProvider';
|
||||
import { eventSubagentSelection } from './eventSelection';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
|
|
@ -217,10 +217,18 @@ function EventSubagentRows({
|
|||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Fixed-width slot: the dot gives at-a-glance color, the text
|
||||
keeps a non-color cue, and neither can shift the row as
|
||||
statuses change length. */}
|
||||
{/* Fixed-width slot, dot last: the label absorbs every length
|
||||
change inboard of it, so the color lands on the same pixel
|
||||
column in every row and cannot drift as statuses change. */}
|
||||
<span className="flex w-24 shrink-0 items-center justify-end gap-1.5 text-xs text-text-secondary">
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
isLiveSubagentStatus(child.status) && 'shimmer',
|
||||
)}
|
||||
>
|
||||
{localize(subagentStatusLabelKey(child.status))}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full border border-border-heavy/40',
|
||||
|
|
@ -228,7 +236,6 @@ function EventSubagentRows({
|
|||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{localize(subagentStatusLabelKey(child.status))}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import { CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-reac
|
|||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { ChildActivity, ChildActivityItem } from './adapters';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { isAbnormalTerminalStatus, subagentStatusIcon, subagentStatusLabelKey } from './status';
|
||||
import {
|
||||
isAbnormalTerminalStatus,
|
||||
isLiveSubagentStatus,
|
||||
subagentStatusIcon,
|
||||
subagentStatusLabelKey,
|
||||
} from './status';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import Container from '~/components/Chat/Messages/Content/Container';
|
||||
|
|
@ -297,6 +302,7 @@ export function SubagentActivityContent({
|
|||
state = 'ready',
|
||||
showPrompt = true,
|
||||
conversationId = null,
|
||||
underHeaderIcon = false,
|
||||
onCancelControl,
|
||||
}: {
|
||||
activity: ChildActivity;
|
||||
|
|
@ -304,17 +310,21 @@ export function SubagentActivityContent({
|
|||
state?: 'ready' | 'loading' | 'error';
|
||||
showPrompt?: boolean;
|
||||
conversationId?: string | null;
|
||||
/** Set when this body sits directly beneath a `MessageRow` author header, so
|
||||
* the streaming dot centers on the header icon's axis as it does in main
|
||||
* chat (see `EmptyTextPart`). */
|
||||
underHeaderIcon?: boolean;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
|
||||
const isSubmitting = isLiveSubagentStatus(activity.status);
|
||||
const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (state === 'loading') {
|
||||
body = (
|
||||
<Container>
|
||||
<EmptyText />
|
||||
<EmptyText underHeaderIcon={underHeaderIcon} />
|
||||
</Container>
|
||||
);
|
||||
} else if (state === 'error') {
|
||||
|
|
@ -326,7 +336,7 @@ export function SubagentActivityContent({
|
|||
} else if (activity.items.length === 0) {
|
||||
body = isSubmitting ? (
|
||||
<Container>
|
||||
<EmptyText />
|
||||
<EmptyText underHeaderIcon={underHeaderIcon} />
|
||||
</Container>
|
||||
) : null;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,14 @@ jest.mock('~/components/Chat/Messages/Content/Container', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Messages/Content/Parts', () => ({
|
||||
EmptyText: () => <div data-testid="thinking-cursor" />,
|
||||
EmptyText: ({ underHeaderIcon }: { underHeaderIcon?: boolean }) => (
|
||||
<div data-testid="thinking-cursor" data-under-header-icon={String(underHeaderIcon === true)} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Messages/MessageIcon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <span data-testid="message-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
|
|
@ -114,7 +121,10 @@ describe('SubagentConversation', () => {
|
|||
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
|
||||
/** Both halves of the main chat author column: the shared glyph, and the
|
||||
* streaming dot inset onto that glyph's axis the way main chat insets it. */
|
||||
expect(screen.getAllByTestId('message-icon')).toHaveLength(2);
|
||||
expect(screen.getByTestId('thinking-cursor')).toHaveAttribute('data-under-header-icon', 'true');
|
||||
expect(container.querySelectorAll('.message-render')).toHaveLength(4);
|
||||
expect(container.querySelectorAll('.user-turn')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('.agent-turn')).toHaveLength(2);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ChevronDown, CornerDownRight, Radio } from 'lucide-react';
|
||||
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Bot, ChevronDown, CornerDownRight, Radio } from 'lucide-react';
|
||||
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
|
|
@ -9,12 +9,14 @@ import type { ChildConversationTurn } from './adapters';
|
|||
import type { TranslationKeys } from '~/hooks';
|
||||
import { SubagentActivityContent, SubagentStatus } from './SubagentActivity';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import { isAbnormalTerminalStatus, isLiveSubagentStatus } from './status';
|
||||
import { messageFooterClasses } from '~/components/Chat/Messages/styles';
|
||||
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
|
||||
import { ElapsedTimer } from '~/components/Chat/Messages/Elapsed';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { isAbnormalTerminalStatus } from './status';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const TRIGGER_LABELS = {
|
||||
|
|
@ -201,14 +203,21 @@ function ChildMessage({
|
|||
} else {
|
||||
limitedNotice = localize('com_ui_subagent_activity_details_unavailable');
|
||||
}
|
||||
let footer: ReactNode = null;
|
||||
let footerContent: ReactNode = null;
|
||||
if (isAbnormalTerminalStatus(turn.activity.status)) {
|
||||
footer = <SubagentStatus activity={turn.activity} />;
|
||||
} else if (turn.activity.status === 'running' || turn.activity.status === 'dispatched') {
|
||||
footerContent = <SubagentStatus activity={turn.activity} />;
|
||||
} else if (isLiveSubagentStatus(turn.activity.status)) {
|
||||
const triggeredAt = turn.trigger.createdAt ?? turn.trigger.externalEvent?.occurredAt;
|
||||
const startedAt = triggeredAt == null ? NaN : Date.parse(triggeredAt);
|
||||
footer = <ElapsedTimer start={Number.isFinite(startedAt) ? startedAt : undefined} />;
|
||||
footerContent = <ElapsedTimer start={Number.isFinite(startedAt) ? startedAt : undefined} />;
|
||||
}
|
||||
/** The main chat footer's own metrics, held whether or not anything occupies
|
||||
* the slot: the timer leaving at completion must not step the turns below it
|
||||
* upward, and the reading has to be sized by the same `text-xs` its main
|
||||
* chat counterpart inherits rather than by the panel's body size. */
|
||||
const footer = (
|
||||
<div className={cn('mt-1 flex justify-start gap-3', messageFooterClasses)}>{footerContent}</div>
|
||||
);
|
||||
const iconData = {
|
||||
endpoint: EModelEndpoint.agents,
|
||||
modelLabel: label,
|
||||
|
|
@ -217,15 +226,10 @@ function ChildMessage({
|
|||
return (
|
||||
<MessageRow
|
||||
id={`${turn.taskId}:assistant`}
|
||||
icon={
|
||||
agent == null ? (
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-surface-tertiary text-text-secondary">
|
||||
<Bot size={14} aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<MessageIcon iconData={iconData} agent={agent} />
|
||||
)
|
||||
}
|
||||
/** The main chat author glyph, unconditionally: with no resolved agent it
|
||||
* falls back to the endpoint icon there too, so an unresolved child does
|
||||
* not get a differently-inset placeholder of its own. */
|
||||
icon={<MessageIcon iconData={iconData} agent={agent} />}
|
||||
label={label}
|
||||
footer={footer}
|
||||
ariaLabel={label}
|
||||
|
|
@ -239,6 +243,7 @@ function ChildMessage({
|
|||
state={state}
|
||||
showPrompt={false}
|
||||
conversationId={conversationId}
|
||||
underHeaderIcon
|
||||
onCancelControl={onCancelControl}
|
||||
/>
|
||||
{detailsLimited && detailState !== 'loading' && (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
} from '~/store/subagents';
|
||||
import { initSubagentAggregatorState, initSubagentTickerState } from '~/utils/subagentContent';
|
||||
import SubagentThreadPanel from './SubagentThreadPanel';
|
||||
import { getDraft } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const mockUseSubagentThreadQuery = jest.fn();
|
||||
const mockUseSubagentActivityStream = jest.fn();
|
||||
|
|
@ -75,7 +77,10 @@ jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useFocusTrap: jest.fn(),
|
||||
/** The real trap: its Escape listener is native and sits on the panel, so it
|
||||
* runs before the panel's own React handler. Stubbing it made the panel's
|
||||
* Escape assertions pass in both directions. */
|
||||
useFocusTrap: jest.requireActual('~/hooks/useFocusTrap').default,
|
||||
useLocalize: () => (key: string) => key,
|
||||
useNavigateToConvo: () => ({ navigateToConvo: mockNavigateToConvo }),
|
||||
}));
|
||||
|
|
@ -197,69 +202,140 @@ jest.mock('./SubagentConversation', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const mockReact = jest.requireActual<typeof import('react')>('react');
|
||||
const MockSelectContext = mockReact.createContext((_value: string): void => {});
|
||||
return {
|
||||
Alert: ({ children, ...props }: React.ComponentProps<'div'>) => (
|
||||
<div role="alert" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Select: ({
|
||||
children,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onValueChange: (value: string) => void;
|
||||
}) => <MockSelectContext.Provider value={onValueChange}>{children}</MockSelectContext.Provider>,
|
||||
SelectTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => (
|
||||
<button role="combobox" aria-controls="mock-select-options" aria-expanded="true" {...props}>
|
||||
{children}
|
||||
/** The shared composer's own Enter contract, for the mock to fall back on when
|
||||
* a host supplies no key policy. */
|
||||
const mockComposerVerdict = (
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
submitOnEnter: boolean,
|
||||
): 'submit' | 'newline' | 'none' => {
|
||||
if (event.key !== 'Enter' || event.shiftKey) return 'none';
|
||||
if (!submitOnEnter && !(event.metaKey || event.ctrlKey)) return 'newline';
|
||||
return 'submit';
|
||||
};
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Alert: ({ children, ...props }: React.ComponentProps<'div'>) => (
|
||||
<div role="alert" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Skeleton: () => null,
|
||||
ControlCombobox: ({
|
||||
items,
|
||||
setValue,
|
||||
ariaLabel,
|
||||
displayValue,
|
||||
onOpenChange,
|
||||
disabled,
|
||||
}: {
|
||||
items: { value: string; label: string }[];
|
||||
setValue: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
displayValue?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-controls="mock-combobox-options"
|
||||
aria-expanded="true"
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
>
|
||||
{displayValue}
|
||||
</button>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div id="mock-select-options">{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { value: string }) => {
|
||||
const onValueChange = mockReact.useContext(MockSelectContext);
|
||||
return (
|
||||
<button role="option" aria-selected="false" onClick={() => onValueChange(value)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
composerSurfaceClasses: () => '',
|
||||
composerSurfaceShadow: { focused: '', blurred: '', within: '' },
|
||||
TextareaAutosize: ({
|
||||
minRows: _minRows,
|
||||
maxRows: _maxRows,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'> & { minRows?: number; maxRows?: number }) => (
|
||||
<textarea {...props} />
|
||||
),
|
||||
useMediaQuery: () => mockIsMobile,
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
};
|
||||
});
|
||||
<div id="mock-combobox-options">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
disabled={disabled}
|
||||
onClick={() => setValue(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
/** Mirrors the real composer's contract: one field, the caller's action row,
|
||||
* and a send control whose accessible name says what Enter will do. */
|
||||
Composer: ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
submitLabel,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
disabled,
|
||||
actions,
|
||||
submitOnEnter = true,
|
||||
resolveKeyVerdict,
|
||||
maxLength,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
canSubmit: boolean;
|
||||
submitLabel: string;
|
||||
ariaLabel: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
submitOnEnter?: boolean;
|
||||
resolveKeyVerdict?: (
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
isComposing: boolean,
|
||||
) => 'submit' | 'block' | 'newline' | 'none';
|
||||
maxLength?: number;
|
||||
}) => (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label={ariaLabel}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
const verdict =
|
||||
resolveKeyVerdict?.(event, false) ?? mockComposerVerdict(event, submitOnEnter);
|
||||
if (verdict !== 'submit') return;
|
||||
event.preventDefault();
|
||||
if (canSubmit && value.trim() !== '') onSubmit();
|
||||
}}
|
||||
/>
|
||||
{actions}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={submitLabel}
|
||||
disabled={disabled === true || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
useMediaQuery: () => mockIsMobile,
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
AlertCircle: () => null,
|
||||
Bot: () => null,
|
||||
CornerDownRight: () => null,
|
||||
CornerUpLeft: () => null,
|
||||
CheckCircle2: () => null,
|
||||
Clock3: () => null,
|
||||
Feather: () => null,
|
||||
ListEnd: () => null,
|
||||
MessagesSquare: () => null,
|
||||
OctagonX: () => null,
|
||||
X: () => null,
|
||||
XCircle: () => null,
|
||||
|
|
@ -484,6 +560,37 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(screen.getByText(/A newer request/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** The panel composer obeys the same "Press Enter to send" preference the
|
||||
* main chat form does: with it off, reaching for a line break must not steer
|
||||
* a live run. */
|
||||
it('leaves a bare Enter alone when the reader turned Enter-to-send off', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
set(activeSubagentPanel, selection);
|
||||
set(store.enterToSend, false);
|
||||
}}
|
||||
>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText('com_ui_message_input');
|
||||
fireEvent.change(composer, { target: { value: 'Check the primary source.' } });
|
||||
fireEvent.keyDown(composer, { key: 'Enter' });
|
||||
expect(mockControlMutate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(composer, { key: 'Enter', metaKey: true });
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe('steer');
|
||||
});
|
||||
|
||||
it('submits one command invocation, blocks duplicate clicks, and shows its receipt', async () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
|
|
@ -497,7 +604,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
const queue = screen.getByRole('button', { name: 'com_ui_queue' });
|
||||
|
|
@ -553,7 +660,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
|
|
@ -562,7 +669,7 @@ describe('SubagentThreadPanel', () => {
|
|||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('com_ui_subagent_control_message')).toBeDisabled();
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' })).toBeDisabled();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
|
||||
|
|
@ -585,7 +692,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Blocked guidance.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
|
|
@ -595,7 +702,7 @@ describe('SubagentThreadPanel', () => {
|
|||
|
||||
expect(screen.getByText('com_ui_subagent_control_reason_invalid_command')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_subagent_control_message')).toBeEnabled();
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' })).toBeEnabled();
|
||||
});
|
||||
|
||||
|
|
@ -623,7 +730,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
|
|
@ -652,7 +759,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Keep the same invocation.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
|
|
@ -696,7 +803,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Retry after closing.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
|
|
@ -725,7 +832,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
|
|
@ -749,7 +856,12 @@ describe('SubagentThreadPanel', () => {
|
|||
screen.getByText('com_ui_subagent_control_reason_owner_unavailable'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
/** The settled child leaves the composer standing — Enter continues the
|
||||
* thread from here — but nothing in it still addresses the finished run. */
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_steer' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves drafted guidance when withdrawing an accepted control', () => {
|
||||
|
|
@ -778,7 +890,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText('com_ui_subagent_control_message');
|
||||
const composer = screen.getByLabelText('com_ui_message_input');
|
||||
fireEvent.change(composer, { target: { value: 'Keep this draft.' } });
|
||||
fireEvent.click(screen.getByTestId('withdraw-control'));
|
||||
const command = mockControlMutate.mock.calls[0][0].command;
|
||||
|
|
@ -811,7 +923,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
|
|
@ -848,7 +960,7 @@ describe('SubagentThreadPanel', () => {
|
|||
|
||||
expect(screen.getByText('applied')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_subagent_control_message')).toHaveValue('');
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toHaveValue('');
|
||||
expect(
|
||||
screen.queryByText('com_ui_subagent_control_reason_owner_unavailable'),
|
||||
).not.toBeInTheDocument();
|
||||
|
|
@ -882,7 +994,7 @@ describe('SubagentThreadPanel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_message_input')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
|
@ -911,7 +1023,7 @@ describe('SubagentThreadPanel', () => {
|
|||
screen.getByText('com_ui_subagent_control_reason_task_inaccessible'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_message_input')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
|
@ -955,7 +1067,7 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(screen.getByText('applied')).toBeInTheDocument();
|
||||
expect(screen.getByText('rejected')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'running');
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_message_input')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
});
|
||||
|
||||
|
|
@ -973,7 +1085,11 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_steer' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_queue' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
});
|
||||
|
||||
|
|
@ -1008,7 +1124,9 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(screen.getByText('Review this change.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review complete.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'ready');
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_continue_chat' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_continue_new_chat' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('continues a completed durable agent task as an ordinary conversation snapshot', () => {
|
||||
|
|
@ -1019,13 +1137,24 @@ describe('SubagentThreadPanel', () => {
|
|||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
let handedOffText: string | undefined;
|
||||
const Observer = () => {
|
||||
handedOffText = useRecoilValue(store.pendingComposerTextByConvoId('continued-chat'));
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_continue_chat' }));
|
||||
/** The settled thread keeps ONE composer: Enter continues from it, and what
|
||||
* the reader typed rides along instead of being discarded with the panel. */
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Take this further.' },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByLabelText('com_ui_message_input'), { key: 'Enter' });
|
||||
expect(mockForkMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
conversationId: 'child-thread',
|
||||
|
|
@ -1037,8 +1166,291 @@ describe('SubagentThreadPanel', () => {
|
|||
|
||||
const mutationOptions = mockForkMutate.mock.calls[0][1];
|
||||
const conversation = { conversationId: 'continued-chat', agent_id: 'agent-1' };
|
||||
/** Typed AFTER the request went out: the fork lands a round trip later and
|
||||
* the composer stays live for it, so the newer words are the ones that
|
||||
* travel. */
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Take this further, starting from the endgame.' },
|
||||
});
|
||||
act(() => mutationOptions.onSuccess({ conversation, messages: [] }));
|
||||
expect(mockNavigateToConvo).toHaveBeenCalledWith(conversation);
|
||||
/** In memory, never in the composer draft store: an unsent draft must not
|
||||
* be written to storage a reader may have asked not to use. */
|
||||
expect(handedOffText).toBe('Take this further, starting from the endgame.');
|
||||
expect(getDraft('continued-chat')).toBe('');
|
||||
});
|
||||
|
||||
/** The composer is shared across selections, so a continuation that lands
|
||||
* after the reader has moved on must hand over the words IT was sent with,
|
||||
* and must not take the words now in front of them. */
|
||||
it('binds the continuation draft to the selection that asked for it', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
let handedOffText: string | undefined;
|
||||
const Observer = () => {
|
||||
handedOffText = useRecoilValue(store.pendingComposerTextByConvoId('continued-chat'));
|
||||
return null;
|
||||
};
|
||||
const { rerender } = render(
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Keep going on the first task.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_continue_new_chat' }));
|
||||
|
||||
/** A different selection owns the composer now — same child thread, but the
|
||||
* panel is showing it for another parent, so its control identity (and its
|
||||
* draft) is not the one the fork was sent from. */
|
||||
const laterSelection = { ...selection, parentConversationId: 'other-parent-conversation' };
|
||||
rerender(
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={laterSelection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'A different question entirely.' },
|
||||
});
|
||||
|
||||
const conversation = { conversationId: 'continued-chat', agent_id: 'agent-1' };
|
||||
act(() => mockForkMutate.mock.calls[0][1].onSuccess({ conversation, messages: [] }));
|
||||
|
||||
expect(handedOffText).toBe('Keep going on the first task.');
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toHaveValue(
|
||||
'A different question entirely.',
|
||||
);
|
||||
});
|
||||
|
||||
/** The cap belongs to the bounded control command, not to ordinary chat text
|
||||
* bound for a composer that caps nothing. */
|
||||
it.each([
|
||||
['a live run composes a bounded control command', 'running' as const, 4096],
|
||||
['a settled run composes an ordinary continuation', 'completed' as const, null],
|
||||
])('caps the field only while %s', (_label, status, expected) => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status, controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText('com_ui_message_input');
|
||||
expect(composer.getAttribute('maxlength')).toBe(expected == null ? null : String(expected));
|
||||
});
|
||||
|
||||
/** The panel holds the draft that travels with a continuation, so nothing may
|
||||
* swap the composer for another actor's while that request is out. */
|
||||
it('freezes the actor picker while a continuation is in flight', () => {
|
||||
const first: ParentSubagentSummary = {
|
||||
threadId: 'child-thread',
|
||||
parentMessageId: 'parent-message',
|
||||
subagentType: 'agent-1',
|
||||
subagentKind: 'agent',
|
||||
agentId: 'agent-1',
|
||||
title: 'First actor',
|
||||
origin: 'event',
|
||||
actorId: 'actor-1',
|
||||
status: 'completed',
|
||||
latestTaskId: 'task',
|
||||
tasks: [{ taskId: 'task', status: 'completed' }],
|
||||
tasksTruncated: false,
|
||||
};
|
||||
const second: ParentSubagentSummary = {
|
||||
...first,
|
||||
threadId: 'child-thread-2',
|
||||
subagentType: 'agent-2',
|
||||
agentId: 'agent-2',
|
||||
title: 'Second actor',
|
||||
actorId: 'actor-2',
|
||||
latestTaskId: 'task-2',
|
||||
tasks: [{ taskId: 'task-2', status: 'completed' }],
|
||||
};
|
||||
mockParentChildrenByMessage = new Map([['parent-message', [first, second]]]);
|
||||
mockParentChildrenByThread = new Map([
|
||||
[first.threadId, first],
|
||||
[second.threadId, second],
|
||||
]);
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const eventSelection: ActiveSubagentPanel = {
|
||||
...selection,
|
||||
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
|
||||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, eventSelection)}>
|
||||
<SubagentThreadPanel selection={eventSelection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'com_ui_subagent_actor' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_continue_new_chat' }));
|
||||
expect(screen.getByRole('combobox', { name: 'com_ui_subagent_actor' })).toBeDisabled();
|
||||
|
||||
act(() => mockForkMutate.mock.calls[0][1].onError());
|
||||
expect(screen.getByRole('combobox', { name: 'com_ui_subagent_actor' })).toBeEnabled();
|
||||
});
|
||||
|
||||
/** An advance changes the control identity, which empties the composer, so
|
||||
* unsent words hold the selection wherever they came from: typed and not yet
|
||||
* sent, or left behind by a continuation that failed and is waiting to be
|
||||
* retried. The hold lifts once the draft is gone. */
|
||||
it.each([
|
||||
['a draft that was never submitted', false],
|
||||
['a draft left behind by a failed continuation', true],
|
||||
])('holds the selection for %s', (_label, submitAndFail) => {
|
||||
const actor: ParentSubagentSummary = {
|
||||
threadId: 'child-thread',
|
||||
parentMessageId: 'parent-message',
|
||||
subagentType: 'agent-1',
|
||||
subagentKind: 'agent',
|
||||
agentId: 'agent-1',
|
||||
title: 'First actor',
|
||||
origin: 'event',
|
||||
actorId: 'actor-1',
|
||||
status: 'completed',
|
||||
latestTaskId: 'task',
|
||||
tasks: [{ taskId: 'task', status: 'completed' }],
|
||||
tasksTruncated: false,
|
||||
};
|
||||
const withNewerTask: ParentSubagentSummary = {
|
||||
...actor,
|
||||
latestTaskId: 'task-newer',
|
||||
tasks: [
|
||||
{ taskId: 'task-newer', status: 'completed' },
|
||||
{ taskId: 'task', status: 'completed' },
|
||||
],
|
||||
};
|
||||
mockParentChildrenByMessage = new Map([['parent-message', [actor]]]);
|
||||
mockParentChildrenByThread = new Map([[actor.threadId, actor]]);
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
let active: ActiveSubagentPanel | null = null;
|
||||
const Observer = () => {
|
||||
active = useRecoilValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
const eventSelection: ActiveSubagentPanel = {
|
||||
...selection,
|
||||
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
|
||||
};
|
||||
|
||||
const tree = (
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, eventSelection)}>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={eventSelection} />
|
||||
</RecoilRoot>
|
||||
);
|
||||
const { rerender } = render(tree);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Try that again.' },
|
||||
});
|
||||
if (submitAndFail) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_continue_new_chat' }));
|
||||
}
|
||||
|
||||
mockParentChildrenByThread = new Map([[actor.threadId, withNewerTask]]);
|
||||
mockParentChildrenByMessage = new Map([['parent-message', [withNewerTask]]]);
|
||||
rerender(tree);
|
||||
if (submitAndFail) {
|
||||
act(() => mockForkMutate.mock.calls[0][1].onError());
|
||||
}
|
||||
rerender(tree);
|
||||
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toHaveValue('Try that again.');
|
||||
expect((active as ActiveSubagentPanel | null)?.durable?.taskId).toBe('task');
|
||||
|
||||
/** Draft gone, nothing left to protect: the panel follows the actor again. */
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), { target: { value: '' } });
|
||||
rerender(tree);
|
||||
expect((active as ActiveSubagentPanel | null)?.durable?.taskId).toBe('task-newer');
|
||||
});
|
||||
|
||||
/** Emptying the field after asking to continue is the reader withdrawing the
|
||||
* words, not leaving them behind — the destination must not reinstate what
|
||||
* they deleted. */
|
||||
it('hands over nothing when the draft is cleared before the fork lands', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
let handedOffText: string | undefined;
|
||||
const Observer = () => {
|
||||
handedOffText = useRecoilValue(store.pendingComposerTextByConvoId('continued-chat'));
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'On second thought.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_continue_new_chat' }));
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), { target: { value: '' } });
|
||||
|
||||
const conversation = { conversationId: 'continued-chat', agent_id: 'agent-1' };
|
||||
act(() => mockForkMutate.mock.calls[0][1].onSuccess({ conversation, messages: [] }));
|
||||
|
||||
expect(mockNavigateToConvo).toHaveBeenCalledWith(conversation);
|
||||
expect(handedOffText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('continues with no draft when the reader asks for the chat without typing', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
let handedOffText: string | undefined;
|
||||
const Observer = () => {
|
||||
handedOffText = useRecoilValue(store.pendingComposerTextByConvoId('empty-continuation'));
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_continue_new_chat' }));
|
||||
const mutationOptions = mockForkMutate.mock.calls[0][1];
|
||||
const conversation = { conversationId: 'empty-continuation', agent_id: 'agent-1' };
|
||||
act(() => mutationOptions.onSuccess({ conversation, messages: [] }));
|
||||
expect(mockNavigateToConvo).toHaveBeenCalledWith(conversation);
|
||||
expect(handedOffText).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -1059,7 +1471,9 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_continue_chat' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_continue_new_chat' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports continuation failures without closing the child panel', () => {
|
||||
|
|
@ -1076,13 +1490,19 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_continue_chat' }));
|
||||
mockForkMutate.mock.calls[0][1].onError();
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Carry this over.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_continue_new_chat' }));
|
||||
act(() => mockForkMutate.mock.calls[0][1].onError());
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_ui_continue_chat_error',
|
||||
status: 'error',
|
||||
});
|
||||
expect(screen.getByRole('region')).toBeInTheDocument();
|
||||
/** The panel stays open on this path and never took the words away, so the
|
||||
* reader can retry with them still in front of them. */
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toHaveValue('Carry this over.');
|
||||
});
|
||||
|
||||
it('renders newer detached progress instead of a dispatch-time parent snapshot', () => {
|
||||
|
|
@ -1441,6 +1861,78 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(mockRefreshParentChildren).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** The picker's popover renders inside the panel so the mobile focus trap can
|
||||
* see it. That same trap closes the panel on Escape, which would take the
|
||||
* composer's text with it — so an Escape aimed at the open picker has to
|
||||
* stop there. */
|
||||
/** The picker's popover renders inside the panel so the mobile focus trap can
|
||||
* see it. That same trap closes the panel on Escape, which would take the
|
||||
* composer's text with it — so an Escape aimed at the OPEN picker has to stop
|
||||
* there, while an Escape anywhere else still closes the panel. */
|
||||
it.each([
|
||||
['closes the panel when the actor picker is shut', false, true],
|
||||
['is left to the actor picker while it is open', true, false],
|
||||
])('Escape %s', (_label, openPicker, expectsClose) => {
|
||||
const first: ParentSubagentSummary = {
|
||||
threadId: 'child-thread',
|
||||
parentMessageId: 'parent-message',
|
||||
subagentType: 'agent-1',
|
||||
subagentKind: 'agent',
|
||||
agentId: 'agent-1',
|
||||
title: 'First actor',
|
||||
origin: 'event',
|
||||
actorId: 'actor-1',
|
||||
status: 'completed',
|
||||
latestTaskId: 'task',
|
||||
tasks: [{ taskId: 'task', status: 'completed' }],
|
||||
tasksTruncated: false,
|
||||
};
|
||||
const second: ParentSubagentSummary = {
|
||||
...first,
|
||||
threadId: 'child-thread-2',
|
||||
subagentType: 'agent-2',
|
||||
agentId: 'agent-2',
|
||||
title: 'Second actor',
|
||||
actorId: 'actor-2',
|
||||
latestTaskId: 'task-2',
|
||||
tasks: [{ taskId: 'task-2', status: 'completed' }],
|
||||
};
|
||||
mockParentChildrenByMessage = new Map([['parent-message', [first, second]]]);
|
||||
mockParentChildrenByThread = new Map([
|
||||
[first.threadId, first],
|
||||
[second.threadId, second],
|
||||
]);
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
mockIsMobile = true;
|
||||
let active: ActiveSubagentPanel | null = null;
|
||||
const Observer = () => {
|
||||
active = useRecoilValue(activeSubagentPanel);
|
||||
return null;
|
||||
};
|
||||
const eventSelection: ActiveSubagentPanel = {
|
||||
...selection,
|
||||
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
|
||||
};
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, eventSelection)}>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={eventSelection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const picker = screen.getByRole('combobox', { name: 'com_ui_subagent_actor' });
|
||||
if (openPicker) fireEvent.click(picker);
|
||||
fireEvent.keyDown(picker, { key: 'Escape' });
|
||||
|
||||
expect(active == null).toBe(expectsClose);
|
||||
});
|
||||
|
||||
it('keeps a single event actor in the compact header without a duplicate selector', () => {
|
||||
const eventChild: ParentSubagentSummary = {
|
||||
threadId: 'child-thread',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { ListEnd, OctagonX, X, Zap } from 'lucide-react';
|
||||
import { dataService, ForkOptions } from 'librechat-data-provider';
|
||||
import { Bot, CornerDownRight, ListEnd, MessagesSquare, OctagonX, X, Zap } from 'lucide-react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Composer,
|
||||
ControlCombobox,
|
||||
useMediaQuery,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
useRecoilCallback,
|
||||
useRecoilState,
|
||||
|
|
@ -9,28 +17,16 @@ import {
|
|||
useResetRecoilState,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import {
|
||||
Button,
|
||||
Alert,
|
||||
composerSurfaceClasses,
|
||||
composerSurfaceShadow,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TextareaAutosize,
|
||||
useMediaQuery,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type {
|
||||
ParentSubagentTaskSummary,
|
||||
SubagentControlAction,
|
||||
SubagentControlReceipt,
|
||||
SubagentControlRequest,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
|
||||
import type { ComposerKeyVerdict } from '@librechat/client';
|
||||
import type { ActiveSubagentPanel, SubagentControlUiState } from '~/store/subagents';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import {
|
||||
adaptDurableThreadActivity,
|
||||
adaptDurableThreadConversation,
|
||||
|
|
@ -56,11 +52,14 @@ import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActi
|
|||
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
|
||||
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
|
||||
import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import useComposerBindings from '~/hooks/Input/useComposerBindings';
|
||||
import { useParentSubagents } from './ParentSubagentsProvider';
|
||||
import { resolveComposerKeyDown } from '~/utils/shortcuts';
|
||||
import SubagentConversation from './SubagentConversation';
|
||||
import { eventSubagentSelection } from './eventSelection';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const EVENT_TASK_PAGE_SIZE = 3;
|
||||
const TERMINAL_CONTROL_REASONS = new Set([
|
||||
|
|
@ -109,6 +108,16 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
|
||||
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. */
|
||||
const [continuationPending, setContinuationPending] = useState(false);
|
||||
const [controlMessage, setControlMessage] = useState('');
|
||||
/** 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 agentsMap = useAgentsMapContext();
|
||||
|
|
@ -243,6 +252,12 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
if (
|
||||
selection.event == null ||
|
||||
selection.event.pinnedTask === true ||
|
||||
/** An advance changes the control identity, which empties the composer.
|
||||
* Defer while anything unsent is in it — a draft not yet submitted, one
|
||||
* travelling with a continuation in flight, or one left behind by a
|
||||
* continuation that failed and is waiting to be retried. The hold
|
||||
* releases itself once the draft is sent or cleared. */
|
||||
selectionHeldForDraft ||
|
||||
eventSummary?.latestTaskId == null ||
|
||||
eventSummary.latestTaskId === taskId
|
||||
) {
|
||||
|
|
@ -254,23 +269,62 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
selection.event.siblingParentMessageIds,
|
||||
);
|
||||
if (nextSelection != null) setSelection(nextSelection);
|
||||
}, [eventSummary, selection, setSelection, taskId]);
|
||||
}, [eventSummary, selection, selectionHeldForDraft, setSelection, taskId]);
|
||||
const detachedLiveSubmitting =
|
||||
selection.durable != null &&
|
||||
progress != null &&
|
||||
progress.status !== 'stop' &&
|
||||
progress.status !== 'error';
|
||||
|
||||
const controlMessageRef = useRef(controlMessage);
|
||||
controlMessageRef.current = controlMessage;
|
||||
/** The continuation in flight, bound to the selection that asked for it. The
|
||||
* fork lands a round trip later and the panel stays live for it, so the live
|
||||
* field is the better source — but ONLY while it still belongs to the actor
|
||||
* 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;
|
||||
/** The server keeps child threads view-only (`CHILD_THREAD_READ_ONLY_ERROR`),
|
||||
* so a continuation is a real conversation forked from the thread. Hand
|
||||
* what the reader typed to that conversation's composer instead of
|
||||
* dropping it with this panel — in memory, since an unsent draft must not
|
||||
* be written to storage the reader may have asked not to use. */
|
||||
const continuation = continuationRef.current;
|
||||
continuationRef.current = null;
|
||||
setContinuationPending(false);
|
||||
/** While the field still belongs to this continuation it IS the draft,
|
||||
* verbatim — including when the reader has emptied it since, which is
|
||||
* them withdrawing the words rather than leaving them behind. Only a
|
||||
* composer that has moved to another selection falls back to what was
|
||||
* captured at submission. */
|
||||
const stillItsOwnComposer = continuation?.identity === controlSelectionRef.current;
|
||||
const draft = stillItsOwnComposer
|
||||
? controlMessageRef.current.trim()
|
||||
: (continuation?.text ?? '');
|
||||
if (continuedConversationId != null && draft !== '') {
|
||||
handOffComposerText(continuedConversationId, draft);
|
||||
}
|
||||
if (stillItsOwnComposer) setControlMessage('');
|
||||
resetSelection();
|
||||
navigateToConvo(result.conversation);
|
||||
},
|
||||
onError: () => {
|
||||
/** The panel stays open on a failed continuation, and the composer still
|
||||
* holds the words, so there is nothing to restore. */
|
||||
continuationRef.current = null;
|
||||
setContinuationPending(false);
|
||||
showToast({ message: localize('com_ui_continue_chat_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
const [controlMessage, setControlMessage] = useState('');
|
||||
const [turnDetailOverrides, setTurnDetailOverrides] = useState(
|
||||
() => new Map<string, ReturnType<typeof adaptDurableThreadActivity>>(),
|
||||
);
|
||||
|
|
@ -687,7 +741,20 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
});
|
||||
}, [resetSelection, selection.parentMessageId, selection.partIndex, selection.toolCallId]);
|
||||
|
||||
useFocusTrap(panelRef, isMobile, close);
|
||||
/** Escape is handled on the element rather than through the trap's own
|
||||
* native listener: that listener sits on this `aside`, so it runs BEFORE any
|
||||
* React handler inside it and would close the whole panel out from under a
|
||||
* nested popover that meant to dismiss only itself. As a React handler it
|
||||
* bubbles in DOM order, so an inner control can stop it or mark it handled. */
|
||||
useFocusTrap(panelRef, isMobile);
|
||||
const handlePanelKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (!isMobile || event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
},
|
||||
[close, isMobile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activeElement = document.activeElement;
|
||||
|
|
@ -745,6 +812,33 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
return { ...merged, controls: [...(merged.controls ?? []), transientControl] };
|
||||
}, [liveActivity, progress, selection.durable, taskView, transientControl]);
|
||||
const panelTitle = selection.event == null ? activity.title : selectedEventActorName;
|
||||
const actorOptions = useMemo<OptionWithIcon[]>(() => {
|
||||
if (selection.event == null) return [];
|
||||
return (
|
||||
eventSiblings
|
||||
/** An actor with no task has nothing to open, and this list has no
|
||||
* disabled state — leave it out rather than offering a dead row. The
|
||||
* selected thread stays listed whatever the index currently says. */
|
||||
.filter((child) => child.latestTaskId != null || child.threadId === threadId)
|
||||
.map((child) => {
|
||||
const agent = child.agentId == null ? undefined : agentsMap?.[child.agentId];
|
||||
const name = agent?.name || child.actorId || child.title;
|
||||
return {
|
||||
value: child.threadId,
|
||||
label:
|
||||
agent?.name != null && child.actorId != null ? `${name} · ${child.actorId}` : name,
|
||||
icon: renderAgentAvatar(agent, { size: 'icon', showBorder: false }),
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [agentsMap, eventSiblings, selection.event, threadId]);
|
||||
const selectedActorLabel =
|
||||
actorOptions.find((option) => option.value === threadId)?.label ?? panelTitle;
|
||||
const selectedActorAgentId = selectedEventActor?.agentId ?? threadView?.agentId;
|
||||
const selectedActorIcon = renderAgentAvatar(
|
||||
selectedActorAgentId == null ? undefined : agentsMap?.[selectedActorAgentId],
|
||||
{ size: 'icon', showBorder: false },
|
||||
);
|
||||
const latestConversationTurns = useMemo(
|
||||
() => (threadView == null ? [] : adaptDurableThreadConversation(threadView)),
|
||||
[threadView],
|
||||
|
|
@ -898,13 +992,58 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
taskView.messages.some((message) => message.messageId === `${taskId}:assistant`);
|
||||
|
||||
const continueAsChat = useCallback(() => {
|
||||
if (!canContinueAsChat || selection.durable == null) return;
|
||||
if (!canContinueAsChat || selection.durable == null || continueChat.isLoading) return;
|
||||
continuationRef.current = { identity: controlIdentity, text: controlMessage.trim() };
|
||||
setContinuationPending(true);
|
||||
continueChat.mutate({
|
||||
conversationId: selection.durable.threadId,
|
||||
messageId: `${selection.durable.taskId}:assistant`,
|
||||
option: ForkOptions.DIRECT_PATH,
|
||||
});
|
||||
}, [canContinueAsChat, continueChat, selection.durable]);
|
||||
}, [canContinueAsChat, continueChat, controlIdentity, controlMessage, selection.durable]);
|
||||
/** `control` steers the live run, `continue` carries the thread into a chat
|
||||
* of the reader's own. Both compose into the same field, with the same
|
||||
* placeholder the main chat composer shows for this agent. */
|
||||
let composerMode: 'control' | 'continue' | null = null;
|
||||
if (controlAvailable) {
|
||||
composerMode = 'control';
|
||||
} else if (canContinueAsChat) {
|
||||
composerMode = 'continue';
|
||||
}
|
||||
const composerPlaceholder = localize('com_endpoint_message_new', { 0: panelTitle });
|
||||
const composerCanSubmit =
|
||||
composerMode === 'control'
|
||||
? !controlPending && controlMessage.trim() !== ''
|
||||
: !continueChat.isLoading;
|
||||
/** The main chat form's own Enter decision table, so a reader who rebound or
|
||||
* unbound the submit shortcut gets the same contract here, and chords
|
||||
* claimed by global shortcuts are left for the window handler. */
|
||||
const resolveKeyVerdict = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLTextAreaElement>, isComposing: boolean): ComposerKeyVerdict => {
|
||||
const action = resolveComposerKeyDown(event, {
|
||||
isComposing,
|
||||
isSubmitting: false,
|
||||
allowSubmitWhileGenerating: false,
|
||||
hasDuringRunModifier: false,
|
||||
shortcutsEnabled,
|
||||
enterToSend,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
});
|
||||
if (action === 'submit') return 'submit';
|
||||
if (action === 'newline') return 'newline';
|
||||
if (action === 'block') return 'block';
|
||||
return 'none';
|
||||
},
|
||||
[enterToSend, shortcutsEnabled, submitOverride, yieldedChords],
|
||||
);
|
||||
const submitComposer = useCallback(() => {
|
||||
if (controlAvailable) {
|
||||
submitControl('steer');
|
||||
return;
|
||||
}
|
||||
continueAsChat();
|
||||
}, [continueAsChat, controlAvailable, submitControl]);
|
||||
const selectActor = useCallback(
|
||||
(nextThreadId: string) => {
|
||||
const next = eventSiblings.find((child) => child.threadId === nextThreadId);
|
||||
|
|
@ -1082,44 +1221,58 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
role={isMobile ? 'dialog' : 'region'}
|
||||
aria-modal={isMobile || undefined}
|
||||
aria-label={localize('com_ui_subagent_thread_panel')}
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
className="flex h-full w-full flex-col overflow-hidden bg-surface-primary-alt text-text-primary"
|
||||
>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border-light px-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<Bot size={17} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{selection.event != null && eventSiblings.length > 1 ? (
|
||||
<Select value={threadId} onValueChange={selectActor}>
|
||||
<SelectTrigger
|
||||
className="h-8 max-w-sm border-0 bg-transparent px-1 font-semibold shadow-none"
|
||||
aria-label={localize('com_ui_subagent_actor')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventSiblings.map((child) => (
|
||||
<SelectItem
|
||||
key={child.threadId}
|
||||
value={child.threadId}
|
||||
disabled={!child.latestTaskId}
|
||||
>
|
||||
{child.agentId != null && agentsMap?.[child.agentId]?.name
|
||||
? agentsMap[child.agentId]?.name
|
||||
: child.actorId || child.title}
|
||||
{child.actorId != null && agentsMap?.[child.agentId ?? '']?.name
|
||||
? ` · ${child.actorId}`
|
||||
: ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<h2 className="truncate text-sm font-semibold" title={panelTitle}>
|
||||
{actorOptions.length > 1 ? (
|
||||
/* The agent builder's picker, so switching actors here reads as the
|
||||
same control as every other agent selection in the app — avatar,
|
||||
searchable list, and the shared theming that comes with it.
|
||||
|
||||
The wrapper keeps an Escape aimed at the open popover from reaching
|
||||
the panel's focus trap, which closes the whole panel on Escape and
|
||||
would take the composer's text with it. */
|
||||
<div
|
||||
className="flex min-w-0 flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && actorPickerOpen) event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
selectedValue={threadId}
|
||||
setValue={selectActor}
|
||||
displayValue={selectedActorLabel}
|
||||
selectPlaceholder={selectedActorLabel}
|
||||
searchPlaceholder={localize('com_agents_search_name')}
|
||||
ariaLabel={localize('com_ui_subagent_actor')}
|
||||
items={actorOptions}
|
||||
SelectIcon={selectedActorIcon}
|
||||
disabled={continuationPending}
|
||||
/** In the panel, not in a portal: on mobile this `aside` is a
|
||||
modal whose focus trap only knows its own descendants, so a
|
||||
portaled search field would let Tab escape to the page
|
||||
behind it. */
|
||||
portal={false}
|
||||
onOpenChange={setActorPickerOpen}
|
||||
containerClassName="min-w-0 flex-1 px-0"
|
||||
className="h-9 w-full border-transparent bg-transparent font-semibold hover:bg-surface-hover"
|
||||
showCarat
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The `MessageRow` author-glyph slot, one size up: no plate
|
||||
behind it, so an agent avatar reads as the avatar it is. */}
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||
{selectedActorIcon}
|
||||
</div>
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold" title={panelTitle}>
|
||||
{panelTitle}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -1162,88 +1315,69 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{controlAvailable && (
|
||||
/* Same surface language as the main chat composer, scaled to the panel. */
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-1.5 rounded-3xl p-2.5',
|
||||
composerSurfaceClasses(),
|
||||
composerSurfaceShadow.within,
|
||||
)}
|
||||
>
|
||||
<TextareaAutosize
|
||||
value={controlMessage}
|
||||
onChange={(event) => setControlMessage(event.target.value)}
|
||||
placeholder={localize('com_ui_subagent_control_placeholder')}
|
||||
aria-label={localize('com_ui_subagent_control_message')}
|
||||
maxLength={4 * 1024}
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
disabled={controlPending}
|
||||
className="w-full resize-none bg-transparent px-1.5 py-1 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('steer')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<CornerDownRight size={14} aria-hidden />
|
||||
{localize('com_ui_steer')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('queue')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<ListEnd size={14} aria-hidden />
|
||||
{localize('com_ui_queue')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('interrupt')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Zap size={14} aria-hidden />
|
||||
{localize('com_ui_subagent_interrupt')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={controlPending}
|
||||
onClick={() => submitControl('cancel')}
|
||||
className="ml-auto rounded-full text-status-error"
|
||||
>
|
||||
<OctagonX size={14} aria-hidden />
|
||||
{localize('com_ui_subagent_cancel_task')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!controlAvailable && canContinueAsChat && (
|
||||
/* The settled run keeps a footer affordance where the composer was,
|
||||
instead of the input vanishing at completion. */
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={continueAsChat}
|
||||
disabled={continueChat.isLoading}
|
||||
aria-label={localize('com_ui_continue_chat')}
|
||||
className="w-full gap-1.5 rounded-3xl"
|
||||
>
|
||||
<MessagesSquare size={15} aria-hidden="true" />
|
||||
{localize('com_ui_continue_chat')}
|
||||
</Button>
|
||||
{composerMode != null && (
|
||||
/* One surface across the run's whole life. A settled thread swaps
|
||||
what Enter DOES, never the control the reader is looking at, so
|
||||
nothing under the pointer moves as the run completes. */
|
||||
<Composer
|
||||
value={controlMessage}
|
||||
onChange={setControlMessage}
|
||||
onSubmit={submitComposer}
|
||||
canSubmit={composerCanSubmit}
|
||||
disabled={composerMode === 'control' && controlPending}
|
||||
submitLabel={
|
||||
composerMode === 'control'
|
||||
? localize('com_ui_steer')
|
||||
: localize('com_ui_subagent_continue_new_chat')
|
||||
}
|
||||
ariaLabel={localize('com_ui_message_input')}
|
||||
placeholder={composerPlaceholder}
|
||||
submitOnEnter={enterToSend}
|
||||
resolveKeyVerdict={resolveKeyVerdict}
|
||||
/** The cap bounds a subagent CONTROL command's payload. A
|
||||
* continuation is ordinary chat text bound for an ordinary
|
||||
* composer, which caps nothing. */
|
||||
maxLength={composerMode === 'control' ? 4 * 1024 : undefined}
|
||||
actions={
|
||||
composerMode === 'control' ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('queue')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<ListEnd size={14} aria-hidden />
|
||||
{localize('com_ui_queue')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('interrupt')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Zap size={14} aria-hidden />
|
||||
{localize('com_ui_subagent_interrupt')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={controlPending}
|
||||
onClick={() => submitControl('cancel')}
|
||||
className="rounded-full text-status-error"
|
||||
>
|
||||
<OctagonX size={14} aria-hidden />
|
||||
{localize('com_ui_subagent_cancel_task')}
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,17 @@ export const subagentStatusLabelKey = (status: SubagentThreadStatus) =>
|
|||
)[status];
|
||||
|
||||
/** Fixed-size status dot classes: a stable-width indicator that cannot make
|
||||
* rows shift as status text changes length. */
|
||||
* rows shift as status text changes length. The dot only carries color — a
|
||||
* run announces itself through the same label shimmer main chat uses for a
|
||||
* running tool call, so the two cues never animate against each other. */
|
||||
export const subagentStatusDotClass = (status: SubagentThreadStatus): string => {
|
||||
if (status === 'completed') return 'bg-status-success';
|
||||
if (status === 'running') return 'animate-pulse bg-status-info';
|
||||
if (status === 'running') return 'bg-status-info';
|
||||
if (status === 'failed' || status === 'interrupted') return 'bg-status-error';
|
||||
if (status === 'cancelled') return 'bg-status-warning';
|
||||
return 'bg-text-tertiary';
|
||||
};
|
||||
|
||||
/** A run is live in both of the statuses the panel treats as in-flight. */
|
||||
export const isLiveSubagentStatus = (status: SubagentThreadStatus): boolean =>
|
||||
status === 'running' || status === 'dispatched';
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const mockShowToast = jest.fn();
|
|||
const mockOpenModal = jest.fn();
|
||||
const mockLocalize = jest.fn((key: string) => key);
|
||||
const mockSetActivePrompt = jest.fn();
|
||||
const mockSetPendingComposerText = jest.fn();
|
||||
let mockPendingComposerText: string | undefined;
|
||||
|
||||
let useTextarea: typeof import('./useTextarea').default;
|
||||
let mockIndex = 0;
|
||||
|
|
@ -43,7 +45,11 @@ jest.mock('~/utils', () => ({
|
|||
|
||||
jest.mock('recoil', () => ({
|
||||
useRecoilValue: jest.fn(() => true),
|
||||
useRecoilState: jest.fn(() => [undefined, mockSetActivePrompt]),
|
||||
useRecoilState: jest.fn((atom: { key?: string }) =>
|
||||
atom?.key === 'pendingComposerText'
|
||||
? [mockPendingComposerText, mockSetPendingComposerText]
|
||||
: [undefined, mockSetActivePrompt],
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
|
|
@ -57,6 +63,7 @@ jest.mock('~/store', () => ({
|
|||
saveDrafts: { key: 'saveDrafts' },
|
||||
pasteLongTextAsFile: { key: 'pasteLongTextAsFile' },
|
||||
activePromptByIndex: jest.fn(() => ({ key: 'activePrompt' })),
|
||||
pendingComposerTextByConvoId: jest.fn(() => ({ key: 'pendingComposerText' })),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -163,6 +170,7 @@ describe('useTextarea long-paste fallback', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockPendingComposerText = undefined;
|
||||
mockIndex = 0;
|
||||
mockIsSubmitting = false;
|
||||
mockIsUploadConfigPending = false;
|
||||
|
|
@ -949,3 +957,31 @@ describe('useTextarea long-paste fallback', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTextarea composer handoff', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPendingComposerText = undefined;
|
||||
mockConversation = { endpoint: 'openAI', conversationId: 'convo-1' };
|
||||
});
|
||||
|
||||
/** A surface the reader is leaving (a continued subagent thread) hands its
|
||||
* words to the destination conversation. It travels in memory, so it is
|
||||
* delivered whatever the Save Drafts preference is. */
|
||||
it('drains text handed to this conversation into the composer exactly once', () => {
|
||||
mockPendingComposerText = 'Take this further.';
|
||||
const { textArea } = renderTextareaHook();
|
||||
|
||||
expect(mockInsertTextAtCursor).toHaveBeenCalledWith(textArea, 'Take this further.');
|
||||
expect(mockForceResize).toHaveBeenCalledWith(textArea);
|
||||
expect(mockSetPendingComposerText).toHaveBeenCalledWith(undefined);
|
||||
expect(getDraft('convo-1')).toBe('');
|
||||
});
|
||||
|
||||
it('leaves the composer alone when nothing was handed to this conversation', () => {
|
||||
renderTextareaHook();
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockSetPendingComposerText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { v4 } from 'uuid';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useRecoilValue, useRecoilState } from 'recoil';
|
||||
import { EToolResources, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import { Constants, EToolResources, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointOption } from 'librechat-data-provider';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { UploadLifecycleCallbacks } from '~/hooks/Files/useFileHandling';
|
||||
|
|
@ -96,6 +96,9 @@ export default function useTextarea({
|
|||
const reservedPasteFilenames = useRef<Set<string>>(new Set());
|
||||
const latestMessage = useLatestMessageMeta(index);
|
||||
const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index));
|
||||
const [pendingComposerText, setPendingComposerText] = useRecoilState(
|
||||
store.pendingComposerTextByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
|
||||
);
|
||||
|
||||
const { endpoint = '' } = conversation || {};
|
||||
const { entity, isAgent, isAssistant } = getEntity({
|
||||
|
|
@ -120,6 +123,18 @@ export default function useTextarea({
|
|||
}
|
||||
}, [activePrompt, setActivePrompt, textAreaRef]);
|
||||
|
||||
/** Text a surface the user was leaving handed to THIS conversation (see
|
||||
* `pendingComposerTextByConvoId`). It is drained once, on the first render
|
||||
* where that conversation's composer exists, which is what lets it survive a
|
||||
* navigation that resolves its record before moving the route. */
|
||||
useEffect(() => {
|
||||
const text = pendingComposerText ?? '';
|
||||
if (text === '' || textAreaRef.current == null) return;
|
||||
insertTextAtCursor(textAreaRef.current, text);
|
||||
forceResize(textAreaRef.current);
|
||||
setPendingComposerText(undefined);
|
||||
}, [pendingComposerText, setPendingComposerText, textAreaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue = textAreaRef.current?.value ?? '';
|
||||
if (currentValue) {
|
||||
|
|
|
|||
|
|
@ -36,8 +36,16 @@ export default function useFocusTrap(
|
|||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
/** Only consume Escape when this trap actually acts on it. This
|
||||
* listener is native and sits on the container, so it runs before
|
||||
* every React handler rendered inside it — a trap that swallowed the
|
||||
* key without answering it would leave the host unable to close on
|
||||
* Escape at all, and nothing within able to claim the key either. */
|
||||
if (onEscapeRef.current == null) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onEscapeRef.current?.();
|
||||
onEscapeRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2242,10 +2242,9 @@
|
|||
"com_ui_subagent_event_source": "Source",
|
||||
"com_ui_subagent_event_received": "Occurred",
|
||||
"com_ui_subagent_event_expected_action": "Expected action",
|
||||
"com_ui_subagent_continue_new_chat": "Continue in a new chat",
|
||||
"com_ui_subagent_control_interrupt": "Interrupt",
|
||||
"com_ui_subagent_control_message": "Message to subagent",
|
||||
"com_ui_subagent_control_message_truncated": "Message shortened for display.",
|
||||
"com_ui_subagent_control_placeholder": "Add guidance for this subagent",
|
||||
"com_ui_subagent_control_queue": "Queued guidance",
|
||||
"com_ui_subagent_control_reason_control_not_found": "That queued message is no longer available.",
|
||||
"com_ui_subagent_control_reason_invalid_command": "The command was not accepted.",
|
||||
|
|
|
|||
|
|
@ -332,6 +332,25 @@ const pendingQuotesByConvoId = atomFamily<string[], string>({
|
|||
default: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Text handed to a conversation's composer by a surface the user is leaving —
|
||||
* today, a subagent thread continued into a chat of its own, where the panel
|
||||
* and its composer unmount as the destination opens.
|
||||
*
|
||||
* Keyed by conversation rather than by composer index because the handoff
|
||||
* outlives the navigation that carries it: a first visit resolves its record
|
||||
* before the route moves, so the destination's composer mounts commits later.
|
||||
* `useTextarea` drains it when that conversation's composer is on screen.
|
||||
*
|
||||
* Deliberately in memory rather than in the composer draft store: nothing the
|
||||
* user has not sent should be written to storage they asked not to use, and
|
||||
* draft restoration is itself gated on the Save Drafts preference.
|
||||
*/
|
||||
const pendingComposerTextByConvoId = atomFamily<string | undefined, string>({
|
||||
key: 'pendingComposerTextByConvoId',
|
||||
default: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* A steer message submitted mid-run. Server truth: `sending` covers the POST
|
||||
* in flight, `pending` means the server queued it (awaiting its injection
|
||||
|
|
@ -807,6 +826,7 @@ export default {
|
|||
useClearSubmissionState,
|
||||
showPromptsPopoverFamily,
|
||||
showSkillsPopoverFamily,
|
||||
pendingComposerTextByConvoId,
|
||||
pendingManualSkillsByConvoId,
|
||||
pendingQuotesByConvoId,
|
||||
pendingSteersByConvoId,
|
||||
|
|
|
|||
189
packages/client/src/components/Composer.spec.tsx
Normal file
189
packages/client/src/components/Composer.spec.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import React, { useState } from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { createEvent, fireEvent, render, screen } from '@testing-library/react';
|
||||
import Composer from './Composer';
|
||||
|
||||
const Harness = ({
|
||||
onSubmit,
|
||||
canSubmit = true,
|
||||
disabled = false,
|
||||
submitOnEnter = true,
|
||||
}: {
|
||||
onSubmit: jest.Mock;
|
||||
canSubmit?: boolean;
|
||||
disabled?: boolean;
|
||||
submitOnEnter?: boolean;
|
||||
}) => {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<Composer
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={onSubmit}
|
||||
canSubmit={canSubmit}
|
||||
disabled={disabled}
|
||||
submitOnEnter={submitOnEnter}
|
||||
submitLabel="Send it"
|
||||
ariaLabel="Message input"
|
||||
placeholder="Message Amara Nwosu"
|
||||
actions={<button type="button">Queue</button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Composer', () => {
|
||||
it('submits on Enter and keeps Shift+Enter for a newline', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
||||
const field = screen.getByLabelText('Message input');
|
||||
expect(field).toHaveAttribute('placeholder', 'Message Amara Nwosu');
|
||||
fireEvent.change(field, { target: { value: 'Look at the second source.' } });
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter', shiftKey: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/** Committing an IME candidate fires Enter; submitting there would send a
|
||||
* half-typed word in every CJK locale. */
|
||||
it('leaves Enter to the IME while a candidate is composing', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
||||
const field = screen.getByLabelText('Message input');
|
||||
fireEvent.change(field, { target: { value: '検討' } });
|
||||
fireEvent.keyDown(field, { key: 'Enter', isComposing: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Safari reports `isComposing` as false on the very Enter that commits a
|
||||
* candidate, so composition state and the legacy signals have to carry it. */
|
||||
it.each([
|
||||
['tracked composition state', { key: 'Enter' }, true],
|
||||
['a Process key', { key: 'Process' }, false],
|
||||
['keyCode 229', { key: 'Enter', keyCode: 229 }, false],
|
||||
])('leaves Enter to a Safari IME committing via %s', (_label, event, startComposition) => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
||||
const field = screen.getByLabelText('Message input');
|
||||
fireEvent.change(field, { target: { value: '検討' } });
|
||||
if (startComposition) fireEvent.compositionStart(field);
|
||||
fireEvent.keyDown(field, event);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.compositionEnd(field);
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/** With "Press Enter to send" off, a bare Enter is a line break — reaching
|
||||
* for one must not steer a run or navigate to a continued chat. */
|
||||
it('leaves a bare Enter alone when Enter-to-send is off', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} submitOnEnter={false} />);
|
||||
|
||||
const field = screen.getByLabelText('Message input');
|
||||
fireEvent.change(field, { target: { value: 'A first line.' } });
|
||||
|
||||
const bareEnter = createEvent.keyDown(field, { key: 'Enter' });
|
||||
fireEvent(field, bareEnter);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(bareEnter.defaultPrevented).toBe(false);
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter', metaKey: true });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter', ctrlKey: true });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('withholds submission but not typing when the surface cannot accept it', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} canSubmit={false} />);
|
||||
|
||||
const field = screen.getByLabelText('Message input');
|
||||
expect(field).toBeEnabled();
|
||||
fireEvent.change(field, { target: { value: 'Draft.' } });
|
||||
expect(field).toHaveValue('Draft.');
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('Send it')).toBeDisabled();
|
||||
});
|
||||
|
||||
/** Continuing a settled thread needs no text, so its send button is live on
|
||||
* an empty field — but a stray Enter must not navigate the reader away. */
|
||||
it('never submits an empty field on Enter, even where the button would', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
||||
const field = screen.getByLabelText('Message input');
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(field, { target: { value: ' ' } });
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByLabelText('Send it')).toBeEnabled();
|
||||
fireEvent.click(screen.getByLabelText('Send it'));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/** The host owns the key policy when it has one, so a reader who rebound or
|
||||
* unbound the submit shortcut gets the same contract as the main chat form,
|
||||
* and chords claimed by global shortcuts are left for the window handler. */
|
||||
it('defers to the host key policy over its own Enter contract', () => {
|
||||
const onSubmit = jest.fn();
|
||||
const resolveKeyVerdict = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('newline')
|
||||
.mockReturnValueOnce('block')
|
||||
.mockReturnValueOnce('submit');
|
||||
const Hosted = () => {
|
||||
const [value, setValue] = useState('Alt is my submit chord now.');
|
||||
return (
|
||||
<Composer
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={onSubmit}
|
||||
canSubmit
|
||||
submitLabel="Send it"
|
||||
ariaLabel="Message input"
|
||||
resolveKeyVerdict={resolveKeyVerdict}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Hosted />);
|
||||
const field = screen.getByLabelText('Message input');
|
||||
|
||||
/** `newline` leaves the key to the field; `block` swallows it without
|
||||
* acting, which is how a chord bound to a global shortcut is yielded. */
|
||||
const asNewline = createEvent.keyDown(field, { key: 'Enter' });
|
||||
fireEvent(field, asNewline);
|
||||
expect(asNewline.defaultPrevented).toBe(false);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
const asBlock = createEvent.keyDown(field, { key: 'Enter', ctrlKey: true });
|
||||
fireEvent(field, asBlock);
|
||||
expect(asBlock.defaultPrevented).toBe(true);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter', altKey: true });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(resolveKeyVerdict).toHaveBeenCalledWith(expect.anything(), false);
|
||||
});
|
||||
|
||||
it('sends from the button and keeps the caller actions alongside it', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Queue' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('Send it'));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
170
packages/client/src/components/Composer.tsx
Normal file
170
packages/client/src/components/Composer.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { forwardRef, useState } from 'react';
|
||||
import type {
|
||||
ForwardRefExoticComponent,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
Ref,
|
||||
RefAttributes,
|
||||
} from 'react';
|
||||
import { composerSurfaceClasses, composerSurfaceShadow } from '~/utils/composer';
|
||||
import { TextareaAutosize } from './TextareaAutosize';
|
||||
import { TooltipAnchor } from './Tooltip';
|
||||
import { SendIcon } from '~/svgs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/** What a key press means to the composer. Mirrors the main chat form's own
|
||||
* verdicts: `block` is "swallow it, do nothing", `newline` and `none` both
|
||||
* leave the key to the field. */
|
||||
export type ComposerKeyVerdict = 'submit' | 'block' | 'newline' | 'none';
|
||||
|
||||
export interface ComposerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
/** Blocks submission without disabling the field, so a reader can still type
|
||||
* and read back a draft the surface is not ready to accept. */
|
||||
canSubmit: boolean;
|
||||
submitLabel: string;
|
||||
ariaLabel: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
/** Secondary controls, laid out inline-start of the send button. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* The reader's "Press Enter to send" preference. When false, Enter inserts a
|
||||
* newline and ⌘/Ctrl+Enter submits — the same bargain the main chat composer
|
||||
* strikes, so a reader who turned the preference off cannot steer a run or
|
||||
* leave for a continued chat by reaching for a line break.
|
||||
*/
|
||||
submitOnEnter?: boolean;
|
||||
/**
|
||||
* The host's own key policy, so a reader who rebound (or unbound) the submit
|
||||
* shortcut gets the same contract they get in the main chat form, and chords
|
||||
* claimed by global shortcuts are left for the window handler. Given the
|
||||
* event and whether an IME is mid-composition. Without it, `submitOnEnter`
|
||||
* drives a plain Enter / ⌘-Ctrl+Enter contract.
|
||||
*/
|
||||
resolveKeyVerdict?: (
|
||||
event: KeyboardEvent<HTMLTextAreaElement>,
|
||||
isComposing: boolean,
|
||||
) => ComposerKeyVerdict;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat composer at panel scale: one persistent surface with a text field, a
|
||||
* stable action row, and the same send affordance the main chat form uses.
|
||||
*
|
||||
* It is deliberately state-free. Every surface that hosts a conversation beside
|
||||
* the main thread (a subagent thread, a side chat) owns what Enter means for it
|
||||
* and passes that in as `onSubmit`, so the affordance never has to be swapped
|
||||
* out for a different control when a run settles — which is what makes the
|
||||
* footer shift under the reader.
|
||||
*/
|
||||
const Composer: ForwardRefExoticComponent<ComposerProps & RefAttributes<HTMLTextAreaElement>> =
|
||||
forwardRef(function Composer(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
submitLabel,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
maxLength,
|
||||
minRows = 1,
|
||||
maxRows = 6,
|
||||
actions,
|
||||
submitOnEnter = true,
|
||||
resolveKeyVerdict,
|
||||
className,
|
||||
}: ComposerProps,
|
||||
ref: Ref<HTMLTextAreaElement>,
|
||||
) {
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
/**
|
||||
* `isComposing` alone does not settle the IME question: Safari reports it
|
||||
* as false on the very Enter that commits a candidate, so the tracked
|
||||
* composition state and the legacy `Process`/229 signals stand in for it,
|
||||
* as the main chat composer's own guard does. Committed here rather than
|
||||
* left to the host so every caller inherits it.
|
||||
*/
|
||||
const composing =
|
||||
isComposing ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.key === 'Process' ||
|
||||
event.keyCode === 229;
|
||||
|
||||
const defaultVerdict = (): ComposerKeyVerdict => {
|
||||
if (composing || event.key !== 'Enter' || event.shiftKey) return 'none';
|
||||
if (!submitOnEnter && !(event.metaKey || event.ctrlKey)) return 'newline';
|
||||
return 'submit';
|
||||
};
|
||||
|
||||
const verdict = resolveKeyVerdict?.(event, composing) ?? defaultVerdict();
|
||||
if (verdict === 'newline' || verdict === 'none') return;
|
||||
event.preventDefault();
|
||||
/** An empty field never submits, even where the surface would accept it:
|
||||
* a caller whose action needs no text (continuing a settled thread)
|
||||
* still wants that to be a deliberate press of the button, not a stray
|
||||
* Enter that navigates the reader somewhere. */
|
||||
if (verdict === 'block' || !canSubmit || value.trim() === '') return;
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-1.5 rounded-3xl p-2.5',
|
||||
composerSurfaceClasses(),
|
||||
composerSurfaceShadow.within,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<TextareaAutosize
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
maxLength={maxLength}
|
||||
minRows={minRows}
|
||||
maxRows={maxRows}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none bg-transparent px-1.5 py-1 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none disabled:cursor-not-allowed"
|
||||
/>
|
||||
{/* The row holds its height whether or not it carries secondary actions,
|
||||
so the surface cannot resize as a run changes what it offers. */}
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-1.5">
|
||||
{actions}
|
||||
<TooltipAnchor
|
||||
description={submitLabel}
|
||||
className="ml-auto"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={submitLabel}
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
data-testid="composer-send-button"
|
||||
className="size-theme-control rounded-theme-control-round bg-text-primary p-theme-compact text-text-primary outline-offset-4 transition-all duration-theme-normal disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10"
|
||||
>
|
||||
<SendIcon size={24} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Composer;
|
||||
|
|
@ -43,6 +43,10 @@ interface ControlComboboxProps {
|
|||
* the popover is not clipped.
|
||||
*/
|
||||
portal?: boolean;
|
||||
/** Told when the popover opens and closes, for hosts that must behave
|
||||
* differently while it is up — e.g. a focus-trapped panel whose own Escape
|
||||
* handler must not fire while an open popover owns the key. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
|
@ -73,6 +77,7 @@ function ControlCombobox({
|
|||
variant = 'default',
|
||||
gutter = 4,
|
||||
portal = true,
|
||||
onOpenChange,
|
||||
}: ControlComboboxProps): JSX.Element {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -98,6 +103,7 @@ function ControlCombobox({
|
|||
defaultItems: items.map(getItem),
|
||||
value: selectedValue,
|
||||
setValue,
|
||||
setOpen: onOpenChange,
|
||||
placement,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export { default as Radio } from './Radio';
|
|||
export { default as Badge } from './Badge';
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Composer } from './Composer';
|
||||
export type { ComposerProps, ComposerKeyVerdict } from './Composer';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as SplitText } from './SplitText';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue