mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🩹 fix: Stop the Subagent Composer Unmounting, and Give It Main Chat's Shape (#15448)
* 🩹 fix: Stop the Subagent Composer Unmounting, and Give It Main Chat's Shape Measured with react-scan and a DOM presence probe against the live panel: every external delivery unmounted and remounted the composer inside 10ms, taking focus and any half-typed steer with it. - The composer stays mounted across a delivery. A delivery re-keys the thread query to its new task and the task view blanks for the render or two that takes — deliberately, so task-scoped fields are never misattributed — but presence followed it down. The last mode the thread showed now carries the surface across that gap, holding submission until the new task's own view lands. `dispatched` also counts as live, so a run that has only just started no longer withdraws the field either. - One control, not three labels. Submitting IS the steer; with nothing to send the send button becomes main chat's Stop, which cancels the task. Queue and interrupt keep the chords main chat gives them (⌘/Ctrl+Enter, ⌥/Alt+Enter) instead of spelling themselves out beside the field, and every control says what it is through its tooltip and accessible name rather than a word label. - The two composers line up. The field takes `ChatForm`'s own metrics and the footer clears the viewport floor by the height of main chat's disclaimer, so both textareas now measure top 805 / height 52 and both surfaces end at 910 in a 950px viewport. - The header is main chat's header: a 52px bar that floats over the thread and fades into it, so the conversation scrolls under it instead of starting below a solid bordered bar. Residual re-renders are the per-second timestamp and elapsed tickers plus the 2s poll both queries run while a child is live — shared with main chat, about 2ms of render time across a 14s window, and left alone. * 🎛️ fix: Bind the Chord to Its Own Submission, and Keep Queue Reachable Codex and Copilot, round one. - A refused chord no longer decides a later click. `Composer` hands the submitting event to its host, and the panel keeps the resolved control bound to THAT event — a ⌘/Ctrl or ⌥ chord the composer then refuses (empty field, settling, a command in flight) leaves nothing behind for the next pointer submission to pick up. - The control comes from the reader's own key policy, not from raw modifiers. With Enter-to-send off, ⌘/Ctrl+Enter IS the submit chord, so reading modifiers directly made steering unreachable; the verdict from `resolveComposerKeyDown` decides instead. - Queue and interrupt keep a pointer. Chords alone stranded touch readers, and anyone whose shortcuts are disabled, rebound, or yielded. They are back as icon-only controls that say what they are through their tooltip and accessible name rather than a word label. - Stop cannot render unlabelled. Handler and label travel as one value (`ComposerStopProps`), so the type refuses a Stop without a name. * 🔗 refactor: Hang the Panel's Alternate Submissions Off One Shared Send Control Queue and interrupt had no pointer, then briefly had controls of their own beside the field — which repeats what submitting already does. Main chat never did that: it hangs its during-run actions off the send button. - `SendActions` moves to `@librechat/client`: the hovercard, the rows, the chord chips and their classes, lifted out of `DuringRunSendButton` unchanged. Each host passes its own anchor whole, so main chat keeps its ref (Enter's synthetic click routes through it), its submit type, and its identifying attributes. Its 13 existing tests still cover the rows. - The panel offers steer, queue and interrupt from that same control, and advertises a chord only while it still reaches its action — each hint is read from the decision table that will execute it, so a rebound, yielded or disabled chord advertises nothing. - The send control names what IT does (`com_ui_steer_send`) as distinct from the `Steer` row it offers, exactly as main chat pairs them. Two hosts now share one implementation, which is what the seam wanted. * 🧹 chore: Sort Panel Imports and Drop an Unrelated Reorder The import sort ran before the last edits to this file. Also reverts a Tailwind class reorder `eslint --fix` made to `MultiSearch`, which this change has no business touching. * 🛡️ fix: Only Offer Controls a Dispatched Task Can Actually Take Codex, round two. - A dispatched task is controllable only once the server can address it. `control.ts` answers 404 when the task has no durable input row and no live lease, which this panel reads as an inaccessible task and closes its controls for good — so treating every dispatched task as controllable could brick them. The field still stays (withdrawing it as a run starts is the swap this panel exists to avoid); submission waits for evidence. A task whose controls are definitively closed holds nothing. - Semantic status roles replace `text-amber-500`, `text-cyan-500` and `text-red-500` in the action rows — in main chat's list too, since both now render through the same module. - One popup over the send control. With actions to offer, the tooltip is withheld: both attached to the same button, opened on the same focus, above the same point, and the tooltip's higher z-index covered the list. - Where the pointer cannot hover, the alternate submissions are controls of their own: a tap on the hovercard anchor submits, so touch readers could not reach queue or interrupt at all. * 🚪 fix: Send Every Control Through the Same Addressability Gate Codex, round three — both findings were doors around the gate added last round. - Stop and the inline actions now answer to `controlAvailable`, not just to `composerMode`. A retained surface over a dispatched task the server cannot address yet still offered Stop, and the touch-side queue and interrupt buttons were live too; each is a command, and each 404 closes this task's controls for good. - A trapped panel keeps its actions inside itself. At mobile width with a hover-capable pointer the list still hung off the send control, portaled outside the `aside` the focus trap knows — so Tab never reached it. The actions come inline whenever the panel is a modal, hover or not.
This commit is contained in:
parent
083be12ddc
commit
1ff4a44b61
9 changed files with 971 additions and 267 deletions
|
|
@ -1,9 +1,9 @@
|
|||
import React, { forwardRef, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { SendIcon } from '@librechat/client';
|
||||
import { SendActions, SendIcon } from '@librechat/client';
|
||||
import { Zap, Clock, OctagonPause, ZapOff } from 'lucide-react';
|
||||
import type { SendAction } from '@librechat/client';
|
||||
import type { Control } from 'react-hook-form';
|
||||
import type { ComposerKeyContext, KeyChordSource } from '~/utils/shortcuts';
|
||||
import type { SteeringControls } from '~/hooks/Chat/useSteering';
|
||||
|
|
@ -13,25 +13,9 @@ import { useLocalize } from '~/hooks';
|
|||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ROW_CLASS =
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm text-text-primary hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy aria-disabled:cursor-not-allowed aria-disabled:opacity-50';
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="ml-auto rounded-md bg-surface-tertiary px-1.5 py-0.5 font-sans text-xs text-text-secondary">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
kbd?: string;
|
||||
icon: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
/** The rows, the popover and the chord chips are shared with every other chat
|
||||
* surface that can submit more than one way — see `SendActions`. */
|
||||
type ActionRow = SendAction;
|
||||
|
||||
type DuringRunSendButtonProps = {
|
||||
control: Control<{ text: string }>;
|
||||
|
|
@ -141,7 +125,7 @@ const DuringRunSendButton = React.memo(
|
|||
key: 'steer',
|
||||
label: localize('com_ui_steer'),
|
||||
kbd: steerKbd,
|
||||
icon: <Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />,
|
||||
icon: <Zap className="h-4 w-4 text-status-warning" aria-hidden="true" />,
|
||||
// Gate on availability, not the default action — the row exists to
|
||||
// override a queue-preferring default with an explicit steer.
|
||||
disabled: !steering.canSteer,
|
||||
|
|
@ -151,7 +135,7 @@ const DuringRunSendButton = React.memo(
|
|||
key: 'queue',
|
||||
label: localize('com_ui_queue'),
|
||||
kbd: primary === 'queue' ? submitHint : alternateHint,
|
||||
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
|
||||
icon: <Clock className="h-4 w-4 text-status-info" aria-hidden="true" />,
|
||||
onClick: () => runAction((text) => steering.queueFromComposer(text)),
|
||||
};
|
||||
/** Keeps the half-written answer, unlike interrupt & send below it. */
|
||||
|
|
@ -159,7 +143,7 @@ const DuringRunSendButton = React.memo(
|
|||
key: 'interrupt-steer',
|
||||
label: localize('com_ui_interrupt_steer'),
|
||||
kbd: interruptSteerKbd,
|
||||
icon: <ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />,
|
||||
icon: <ZapOff className="h-4 w-4 text-status-warning" aria-hidden="true" />,
|
||||
// Matches the standalone button's gate, and deliberately NOT
|
||||
// `!canSteer` like the steer row above: `canSteer` is also false before
|
||||
// a conversation exists, where `interruptSteer` falls back to interrupt
|
||||
|
|
@ -171,7 +155,7 @@ const DuringRunSendButton = React.memo(
|
|||
key: 'interrupt',
|
||||
label: localize('com_ui_interrupt_send'),
|
||||
kbd: verdicts.altEnter === 'interrupt' ? altEnter : undefined,
|
||||
icon: <OctagonPause className="h-4 w-4 text-red-500" aria-hidden="true" />,
|
||||
icon: <OctagonPause className="h-4 w-4 text-status-error" aria-hidden="true" />,
|
||||
disabled: !steering.canControlGeneration,
|
||||
onClick: () => runAction((text) => steering.interruptAndSend(text)),
|
||||
};
|
||||
|
|
@ -182,50 +166,28 @@ const DuringRunSendButton = React.memo(
|
|||
primary === 'steer' ? localize('com_ui_steer_send') : localize('com_ui_queue_send');
|
||||
|
||||
return (
|
||||
<Ariakit.HovercardProvider placement="top-end" showTimeout={100} hideTimeout={150}>
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<button
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
id="during-run-send-button"
|
||||
disabled={!content || props.disabled === true}
|
||||
className={cn(
|
||||
'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',
|
||||
)}
|
||||
data-testid="during-run-send-button"
|
||||
data-during-run-action={primary}
|
||||
type="submit"
|
||||
>
|
||||
<span data-state="closed">
|
||||
<SendIcon size={24} />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Hovercard
|
||||
portal
|
||||
gutter={8}
|
||||
unmountOnHide
|
||||
aria-label={localize('com_ui_during_run_actions')}
|
||||
className="z-50 min-w-[12rem] rounded-xl border border-border-light bg-surface-secondary p-1.5 text-text-primary shadow-lg outline-none"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
className={ROW_CLASS}
|
||||
aria-disabled={row.disabled === true}
|
||||
onClick={row.disabled === true ? undefined : row.onClick}
|
||||
>
|
||||
{row.icon}
|
||||
{row.label}
|
||||
{/* A disabled row's action refuses its chord too, so no hint. */}
|
||||
{row.kbd != null && row.disabled !== true && <Kbd>{row.kbd}</Kbd>}
|
||||
</button>
|
||||
))}
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
<SendActions
|
||||
actions={rows}
|
||||
label={localize('com_ui_during_run_actions')}
|
||||
anchor={
|
||||
<button
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
id="during-run-send-button"
|
||||
disabled={!content || props.disabled === true}
|
||||
className={cn(
|
||||
'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',
|
||||
)}
|
||||
data-testid="during-run-send-button"
|
||||
data-during-run-action={primary}
|
||||
type="submit"
|
||||
>
|
||||
<span data-state="closed">
|
||||
<SendIcon size={24} />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -127,9 +127,13 @@ function SubagentControlHistory({
|
|||
export function SubagentActivityScrollSurface({
|
||||
children,
|
||||
padded = true,
|
||||
headerInset = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
padded?: boolean;
|
||||
/** Clears the panel's overlay header, which floats above this surface so the
|
||||
* thread scrolls under it rather than starting below a solid bar. */
|
||||
headerInset?: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -172,7 +176,9 @@ export function SubagentActivityScrollSurface({
|
|||
className={cn('min-h-0 flex-1 overflow-y-auto', padded && 'px-4 py-4')}
|
||||
data-subagent-activity-scroll-surface
|
||||
>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
<div ref={contentRef} className={cn(headerInset && 'pt-[52px]')}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<CSSTransition
|
||||
in={!isAtBottom && scrollButtonPreference}
|
||||
|
|
@ -395,6 +401,7 @@ export default function SubagentActivity({
|
|||
state = 'ready',
|
||||
embedded = false,
|
||||
showPrompt = true,
|
||||
headerInset = false,
|
||||
onCancelControl,
|
||||
}: {
|
||||
activity: ChildActivity;
|
||||
|
|
@ -402,6 +409,7 @@ export default function SubagentActivity({
|
|||
state?: 'ready' | 'loading' | 'error';
|
||||
embedded?: boolean;
|
||||
showPrompt?: boolean;
|
||||
headerInset?: boolean;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) {
|
||||
const statusHeader = isAbnormalTerminalStatus(activity.status) ? (
|
||||
|
|
@ -431,7 +439,9 @@ export default function SubagentActivity({
|
|||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{statusHeader}
|
||||
<SubagentActivityScrollSurface>{content}</SubagentActivityScrollSurface>
|
||||
<SubagentActivityScrollSurface headerInset={headerInset}>
|
||||
{content}
|
||||
</SubagentActivityScrollSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const mockGetSubagentThread = jest.fn();
|
|||
const mockApprovalProviderMounted = jest.fn();
|
||||
const mockApprovalProviderUnmounted = jest.fn();
|
||||
let mockIsMobile = false;
|
||||
let mockCoarsePointer = false;
|
||||
let mockParentChildrenByMessage = new Map<string, ParentSubagentSummary[]>();
|
||||
let mockParentChildrenByThread = new Map<string, ParentSubagentSummary>();
|
||||
const mockRefreshParentChildren = jest.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -223,6 +224,9 @@ jest.mock('@librechat/client', () => ({
|
|||
<button {...props}>{children}</button>
|
||||
),
|
||||
Skeleton: () => null,
|
||||
/** Renders the anchored control itself; the tooltip text is the control's
|
||||
* accessible name here, which is what the assertions read. */
|
||||
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
|
||||
ControlCombobox: ({
|
||||
items,
|
||||
setValue,
|
||||
|
|
@ -281,10 +285,13 @@ jest.mock('@librechat/client', () => ({
|
|||
submitOnEnter = true,
|
||||
resolveKeyVerdict,
|
||||
maxLength,
|
||||
onStop,
|
||||
stopLabel,
|
||||
submitActions,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSubmit: (event?: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
canSubmit: boolean;
|
||||
submitLabel: string;
|
||||
ariaLabel: string;
|
||||
|
|
@ -297,6 +304,14 @@ jest.mock('@librechat/client', () => ({
|
|||
isComposing: boolean,
|
||||
) => 'submit' | 'block' | 'newline' | 'none';
|
||||
maxLength?: number;
|
||||
onStop?: () => void;
|
||||
stopLabel?: string;
|
||||
submitActions?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}) => (
|
||||
<div>
|
||||
<textarea
|
||||
|
|
@ -311,21 +326,41 @@ jest.mock('@librechat/client', () => ({
|
|||
resolveKeyVerdict?.(event, false) ?? mockComposerVerdict(event, submitOnEnter);
|
||||
if (verdict !== 'submit') return;
|
||||
event.preventDefault();
|
||||
if (canSubmit && value.trim() !== '') onSubmit();
|
||||
/** The real composer hands the submitting event to the host, which is
|
||||
* how a chord picks its control; a pointer click passes nothing. */
|
||||
if (canSubmit && value.trim() !== '') onSubmit(event);
|
||||
}}
|
||||
/>
|
||||
{actions}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={submitLabel}
|
||||
disabled={disabled === true || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
{/* The real composer hides these behind the send control until it is
|
||||
hovered; rendering them inline keeps them reachable to assertions. */}
|
||||
{(submitActions ?? []).map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
aria-disabled={action.disabled === true}
|
||||
onClick={action.disabled === true ? undefined : action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
{onStop != null && value.trim() === '' ? (
|
||||
<button type="button" aria-label={stopLabel} onClick={onStop}>
|
||||
{stopLabel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={submitLabel}
|
||||
disabled={disabled === true || !canSubmit}
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
useMediaQuery: () => mockIsMobile,
|
||||
useMediaQuery: (query: string) => (query.includes('hover') ? mockCoarsePointer : mockIsMobile),
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
|
|
@ -333,10 +368,10 @@ jest.mock('lucide-react', () => ({
|
|||
AlertCircle: () => null,
|
||||
CornerUpLeft: () => null,
|
||||
CheckCircle2: () => null,
|
||||
Clock: () => null,
|
||||
Clock3: () => null,
|
||||
Feather: () => null,
|
||||
ListEnd: () => null,
|
||||
OctagonX: () => null,
|
||||
OctagonPause: () => null,
|
||||
X: () => null,
|
||||
XCircle: () => null,
|
||||
Zap: () => null,
|
||||
|
|
@ -389,6 +424,7 @@ describe('SubagentThreadPanel', () => {
|
|||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
mockIsMobile = false;
|
||||
mockCoarsePointer = false;
|
||||
mockApprovalProviderMounted.mockClear();
|
||||
mockApprovalProviderUnmounted.mockClear();
|
||||
mockForkMutate.mockClear();
|
||||
|
|
@ -591,6 +627,247 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe('steer');
|
||||
});
|
||||
|
||||
/** A delivery re-keys the query to its new task and the task view blanks for
|
||||
* the render or two that takes. Measured against the live panel, the
|
||||
* composer used to unmount and remount inside 10ms there — taking focus and
|
||||
* a half-typed steer with it. */
|
||||
it('keeps the composer mounted while a delivery re-keys the task view', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
/** A fresh element each time: React bails out of an update whose element is
|
||||
* the very same object, so a reused tree would assert nothing. */
|
||||
const tree = () => (
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>
|
||||
);
|
||||
const { rerender } = render(tree());
|
||||
|
||||
const composer = screen.getByLabelText('com_ui_message_input');
|
||||
fireEvent.change(composer, { target: { value: 'Half-typed steer.' } });
|
||||
|
||||
/** The new task's view has not landed: `isPreviousData` withholds the task
|
||||
* view precisely so task-scoped fields are not misattributed. */
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isPreviousData: true,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
rerender(tree());
|
||||
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toHaveValue('Half-typed steer.');
|
||||
/** Present, but not submittable until the new task's own view arrives. */
|
||||
expect(screen.getByRole('button', { name: 'com_ui_steer_send' })).toBeDisabled();
|
||||
});
|
||||
|
||||
/** A task on its way to an executor is live: withdrawing the composer until
|
||||
* the first token lands is the swap this panel exists to avoid. But the
|
||||
* server can only address a task that has a durable input row — it answers
|
||||
* 404 otherwise, which this panel reads as inaccessible and closes controls
|
||||
* for good — so the field stays and submission waits instead. */
|
||||
it.each([
|
||||
['has its durable input row', completedView.messages, 1],
|
||||
['has not been written yet', [], 0],
|
||||
])('keeps the composer while a dispatched task %s', (_label, messages, invocations) => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'dispatched', messages, 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).toBeInTheDocument();
|
||||
/** Stop is a command too: on an unaddressable task it would 404 and close
|
||||
* this task's controls for good, so the retained surface withholds it. It
|
||||
* occupies the send slot only while the field is empty. */
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }) != null).toBe(
|
||||
invocations === 1,
|
||||
);
|
||||
|
||||
fireEvent.change(composer, { target: { value: 'Check the primary source.' } });
|
||||
fireEvent.keyDown(composer, { key: 'Enter' });
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(invocations);
|
||||
});
|
||||
|
||||
/** Every door onto a command answers to the same gate — including the inline
|
||||
* controls a touch reader gets instead of the hovercard. */
|
||||
it('withholds the inline actions while a dispatched task is unaddressable', () => {
|
||||
mockCoarsePointer = true;
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'dispatched', messages: [], controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
const queue = screen.getByRole('button', { name: 'com_ui_queue' });
|
||||
expect(queue).toBeDisabled();
|
||||
fireEvent.click(queue);
|
||||
expect(mockControlMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** While the panel is a focus-trapped modal the portaled list sits outside
|
||||
* the `aside` the trap knows, so Tab never reaches it — the actions come
|
||||
* inside the panel instead, hover or no hover. */
|
||||
it('keeps the actions inside the panel while it is a modal', () => {
|
||||
mockIsMobile = true;
|
||||
mockCoarsePointer = false;
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe('queue');
|
||||
});
|
||||
|
||||
/** Queue and interrupt keep the chords main chat gives them instead of
|
||||
* spelling themselves out beside the field. */
|
||||
it.each([
|
||||
['queue', { key: 'Enter', ctrlKey: true }, 'queue'],
|
||||
['interrupt', { key: 'Enter', altKey: true }, 'interrupt'],
|
||||
['steer', { key: 'Enter' }, 'steer'],
|
||||
])('submits a %s from its chord', (_label, event, action) => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', 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');
|
||||
fireEvent.change(composer, { target: { value: 'Check the primary source.' } });
|
||||
fireEvent.keyDown(composer, event);
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe(action);
|
||||
});
|
||||
|
||||
/** A chord the composer refuses must leave nothing behind: the next pointer
|
||||
* click carries no event and therefore always means the default steer. */
|
||||
it('does not let a refused chord decide a later pointer submission', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', 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');
|
||||
/** Refused: the field is empty, so the composer never submits it. */
|
||||
fireEvent.keyDown(composer, { key: 'Enter', altKey: true });
|
||||
expect(mockControlMutate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(composer, { target: { value: 'Check the primary source.' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer_send' }));
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe('steer');
|
||||
});
|
||||
|
||||
/** Where there is no hover, the send control's action list cannot be opened —
|
||||
* a tap on that anchor submits — so those readers get the actions as
|
||||
* controls of their own instead. */
|
||||
it('offers the alternate submissions inline when the pointer cannot hover', () => {
|
||||
mockIsMobile = true;
|
||||
mockCoarsePointer = true;
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe('queue');
|
||||
});
|
||||
|
||||
/** Queue and interrupt keep a pointer of their own — a touch reader, or one
|
||||
* whose shortcuts are off or rebound, still has to reach them. */
|
||||
it.each([
|
||||
['com_ui_queue', 'queue'],
|
||||
['com_ui_subagent_interrupt', 'interrupt'],
|
||||
])('offers %s to a pointer', (label, action) => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: label }));
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockControlMutate.mock.calls[0][0].command.action).toBe(action);
|
||||
});
|
||||
|
||||
it('submits one command invocation, blocks duplicate clicks, and shows its receipt', async () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
|
|
@ -607,9 +884,9 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
const queue = screen.getByRole('button', { name: 'com_ui_queue' });
|
||||
fireEvent.click(queue);
|
||||
fireEvent.click(queue);
|
||||
const composer = screen.getByLabelText('com_ui_message_input');
|
||||
fireEvent.keyDown(composer, { key: 'Enter', ctrlKey: true });
|
||||
fireEvent.keyDown(composer, { key: 'Enter', ctrlKey: true });
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
const [variables, callbacks] = mockControlMutate.mock.calls[0] as [
|
||||
|
|
@ -663,14 +940,14 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer_send' }));
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('com_ui_message_input')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_steer_send' })).toBeDisabled();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
|
|
@ -695,7 +972,7 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Blocked guidance.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer_send' }));
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 400 } });
|
||||
});
|
||||
|
|
@ -703,7 +980,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_message_input')).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_steer_send' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('retains an ambiguous invocation across closing and reopening the panel', () => {
|
||||
|
|
@ -733,7 +1010,10 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
fireEvent.keyDown(screen.getByLabelText('com_ui_message_input'), {
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
});
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
|
|
@ -762,7 +1042,10 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Keep the same invocation.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
fireEvent.keyDown(screen.getByLabelText('com_ui_message_input'), {
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
});
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
|
|
@ -806,7 +1089,10 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Retry after closing.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
fireEvent.keyDown(screen.getByLabelText('com_ui_message_input'), {
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
});
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
|
||||
act(() => {
|
||||
|
|
@ -835,7 +1121,7 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer_send' }));
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
|
|
@ -858,7 +1144,7 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).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_steer_send' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
|
@ -926,7 +1212,7 @@ describe('SubagentThreadPanel', () => {
|
|||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer_send' }));
|
||||
const command = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
|
|
@ -1085,7 +1371,7 @@ describe('SubagentThreadPanel', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_steer' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_steer_send' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_queue' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
|
|
@ -1358,13 +1644,15 @@ describe('SubagentThreadPanel', () => {
|
|||
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
|
||||
};
|
||||
|
||||
const tree = (
|
||||
/** A fresh element each time: React bails out of an update whose element is
|
||||
* the very same object, so a reused tree would assert nothing. */
|
||||
const tree = () => (
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, eventSelection)}>
|
||||
<Observer />
|
||||
<SubagentThreadPanel selection={eventSelection} />
|
||||
</RecoilRoot>
|
||||
);
|
||||
const { rerender } = render(tree);
|
||||
const { rerender } = render(tree());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_message_input'), {
|
||||
target: { value: 'Try that again.' },
|
||||
|
|
@ -1375,18 +1663,18 @@ describe('SubagentThreadPanel', () => {
|
|||
|
||||
mockParentChildrenByThread = new Map([[actor.threadId, withNewerTask]]);
|
||||
mockParentChildrenByMessage = new Map([['parent-message', [withNewerTask]]]);
|
||||
rerender(tree);
|
||||
rerender(tree());
|
||||
if (submitAndFail) {
|
||||
act(() => mockForkMutate.mock.calls[0][1].onError());
|
||||
}
|
||||
rerender(tree);
|
||||
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);
|
||||
rerender(tree());
|
||||
expect((active as ActiveSubagentPanel | null)?.durable?.taskId).toBe('task-newer');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { ListEnd, OctagonX, X, Zap } from 'lucide-react';
|
||||
import { Clock, OctagonPause, X, Zap } from 'lucide-react';
|
||||
import { dataService, ForkOptions } from 'librechat-data-provider';
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -23,9 +23,10 @@ import type {
|
|||
SubagentControlReceipt,
|
||||
SubagentControlRequest,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ComposerKeyVerdict, ComposerStopProps, SendAction } from '@librechat/client';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
|
||||
import type { ComposerKeyVerdict } from '@librechat/client';
|
||||
import type { ActiveSubagentPanel, SubagentControlUiState } from '~/store/subagents';
|
||||
import type { ComposerKeyAction } from '~/utils/shortcuts';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import {
|
||||
adaptDurableThreadActivity,
|
||||
|
|
@ -51,13 +52,14 @@ import {
|
|||
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
|
||||
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
|
||||
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
|
||||
import { isMacPlatform, resolveComposerKeyDown } from '~/utils/shortcuts';
|
||||
import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import useComposerBindings from '~/hooks/Input/useComposerBindings';
|
||||
import { useParentSubagents } from './ParentSubagentsProvider';
|
||||
import { resolveComposerKeyDown } from '~/utils/shortcuts';
|
||||
import SubagentConversation from './SubagentConversation';
|
||||
import { eventSubagentSelection } from './eventSelection';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { isLiveSubagentStatus } from './status';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -96,6 +98,16 @@ const failedControlReason = (
|
|||
return 'invalid_command';
|
||||
};
|
||||
|
||||
/** Which control a during-run verdict asks for. `submit` is whatever the
|
||||
* reader's own key policy treats as the default — with Enter-to-send off that
|
||||
* is ⌘/Ctrl+Enter, which is why the chord is never read raw. */
|
||||
const CONTROL_FOR_ACTION = {
|
||||
submit: 'steer',
|
||||
other: 'queue',
|
||||
interrupt: 'interrupt',
|
||||
preempt: 'interrupt',
|
||||
} as const satisfies Partial<Record<ComposerKeyAction, SubagentControlAction>>;
|
||||
|
||||
const failedControlLocaleKey = (reason?: string) => {
|
||||
if (reason === 'task_inaccessible') return 'com_ui_subagent_control_reason_task_inaccessible';
|
||||
if (reason === 'owner_unavailable') return 'com_ui_subagent_control_reason_owner_unavailable';
|
||||
|
|
@ -108,6 +120,12 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
/** Two reasons the send control's action list cannot be used where it hangs.
|
||||
* Without hover, a tap on its anchor submits instead of opening it. And
|
||||
* while the panel is a focus-trapped modal, the list is portaled outside the
|
||||
* `aside` the trap knows, so Tab never reaches it. Either way those readers
|
||||
* get the same actions as controls of their own, inside the panel. */
|
||||
const coarsePointer = useMediaQuery('(hover: none)');
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
|
||||
const [actorPickerOpen, setActorPickerOpen] = useState(false);
|
||||
|
|
@ -973,11 +991,19 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const hasConversationProjection =
|
||||
selection.durable == null || threadView == null || Array.isArray(threadView.turns);
|
||||
const taskInaccessible = controlInaccessible || transientControl?.reason === 'task_inaccessible';
|
||||
const taskIsLive = taskView != null && isLiveSubagentStatus(taskView.status);
|
||||
/** A command can only be addressed once the task has a durable input row or a
|
||||
* live lease — `control.ts` answers 404 otherwise, which this panel reads as
|
||||
* an inaccessible task and closes its controls for good. A `dispatched` task
|
||||
* without that evidence is live but not yet controllable: the composer stays
|
||||
* (withdrawing it as a run starts is the swap this panel exists to avoid),
|
||||
* while submission waits. */
|
||||
const controlAddressable =
|
||||
taskView != null &&
|
||||
(taskView.status === 'running' ||
|
||||
(taskView.status === 'dispatched' && subagentThreadHasTaskEvidence(taskView, taskId)));
|
||||
const controlAvailable =
|
||||
selection.durable != null &&
|
||||
taskView?.status === 'running' &&
|
||||
!taskInaccessible &&
|
||||
!controlsClosed;
|
||||
selection.durable != null && controlAddressable && !taskInaccessible && !controlsClosed;
|
||||
const controlPending =
|
||||
controlTask.isLoading || transientControl?.status === 'submitted' || retryControl != null;
|
||||
const showControlFooter =
|
||||
|
|
@ -1004,46 +1030,189 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
/** `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;
|
||||
let liveComposerMode: 'control' | 'continue' | null = null;
|
||||
if (controlAvailable) {
|
||||
composerMode = 'control';
|
||||
liveComposerMode = 'control';
|
||||
} else if (canContinueAsChat) {
|
||||
composerMode = 'continue';
|
||||
liveComposerMode = 'continue';
|
||||
}
|
||||
/** A delivery re-keys the query to its new task, and the task view blanks for
|
||||
* the render or two that takes — deliberately, so task-scoped fields are
|
||||
* never attributed to the wrong task. Presence must not follow it down: the
|
||||
* composer would unmount and remount within the same tenth of a second,
|
||||
* taking focus and the reader's half-typed words with it. The last mode this
|
||||
* thread showed carries the surface across that gap, holding submission
|
||||
* until the new task's own view arrives. */
|
||||
const retainedModeRef = useRef<{ threadId: string; mode: 'control' | 'continue' } | null>(null);
|
||||
if (liveComposerMode != null) {
|
||||
retainedModeRef.current = { threadId, mode: liveComposerMode };
|
||||
} else if (retainedModeRef.current != null && retainedModeRef.current.threadId !== threadId) {
|
||||
retainedModeRef.current = null;
|
||||
}
|
||||
/** Hold the surface both while a delivery re-keys the view and while a live
|
||||
* task is not yet addressable; submission waits in either case. A task whose
|
||||
* controls are definitively closed holds nothing — there is no command left
|
||||
* for the field to carry. */
|
||||
const composerSettling =
|
||||
liveComposerMode == null &&
|
||||
(taskView == null || (taskIsLive && !controlsClosed && !taskInaccessible));
|
||||
/** While settling, the surface the thread last showed — or, on a first render
|
||||
* that has never had one, the surface a live task would have. */
|
||||
let composerMode: 'control' | 'continue' | null = liveComposerMode;
|
||||
if (composerMode == null && composerSettling) {
|
||||
composerMode = retainedModeRef.current?.mode ?? (taskIsLive ? 'control' : null);
|
||||
}
|
||||
const actionsInline = coarsePointer || isMobile;
|
||||
const composerPlaceholder = localize('com_endpoint_message_new', { 0: panelTitle });
|
||||
const composerCanSubmit =
|
||||
/** The send control names what IT does, distinct from the `Steer` row it
|
||||
* offers — the same pairing main chat uses for its during-run send. */
|
||||
const composerSubmitLabel =
|
||||
composerMode === 'control'
|
||||
? !controlPending && controlMessage.trim() !== ''
|
||||
: !continueChat.isLoading;
|
||||
? localize('com_ui_steer_send')
|
||||
: localize('com_ui_subagent_continue_new_chat');
|
||||
const cancelTask = useCallback(() => submitControl('cancel'), [submitControl]);
|
||||
/** Handler and label travel as one value, so the Stop control can never
|
||||
* render without an accessible name. */
|
||||
const stopProps: ComposerStopProps =
|
||||
controlAvailable && !controlPending
|
||||
? { onStop: cancelTask, stopLabel: localize('com_ui_subagent_cancel_task') }
|
||||
: {};
|
||||
const controlModeRef = useRef(false);
|
||||
const chordControlRef = useRef<{
|
||||
event: ReactKeyboardEvent<HTMLTextAreaElement>;
|
||||
control: SubagentControlAction;
|
||||
} | null>(null);
|
||||
controlModeRef.current = composerMode === 'control';
|
||||
let composerCanSubmit: boolean;
|
||||
if (composerSettling) {
|
||||
composerCanSubmit = false;
|
||||
} else if (composerMode === 'control') {
|
||||
composerCanSubmit = !controlPending && controlMessage.trim() !== '';
|
||||
} else {
|
||||
composerCanSubmit = !continueChat.isLoading;
|
||||
}
|
||||
/** The alternate submissions, offered from the send control exactly as main
|
||||
* chat offers its during-run actions — never as a row of controls beside the
|
||||
* field repeating what submitting already does. Each chord is advertised
|
||||
* only while it still reaches its action, read from the same decision table
|
||||
* that will execute it. */
|
||||
const submitActions = useMemo<SendAction[]>(() => {
|
||||
if (composerMode !== 'control') return [];
|
||||
const context = {
|
||||
isComposing: false,
|
||||
isSubmitting: true,
|
||||
allowSubmitWhileGenerating: true,
|
||||
hasDuringRunModifier: true,
|
||||
shortcutsEnabled,
|
||||
enterToSend,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
};
|
||||
const base = { key: 'Enter', altKey: false, ctrlKey: false, metaKey: false, shiftKey: false };
|
||||
const plainEnter = resolveComposerKeyDown(base, context);
|
||||
const modEnter = resolveComposerKeyDown(
|
||||
{ ...base, ctrlKey: !isMacPlatform, metaKey: isMacPlatform },
|
||||
context,
|
||||
);
|
||||
const altEnter = resolveComposerKeyDown({ ...base, altKey: true }, context);
|
||||
const modSymbol = isMacPlatform ? '\u2318\u23CE' : 'Ctrl \u23CE';
|
||||
const altSymbol = isMacPlatform ? '\u2325\u23CE' : 'Alt \u23CE';
|
||||
let steerKbd: string | undefined;
|
||||
if (plainEnter === 'submit') {
|
||||
steerKbd = '\u23CE';
|
||||
} else if (modEnter === 'submit') {
|
||||
steerKbd = modSymbol;
|
||||
}
|
||||
/** The same gate the send button answers to: a retained surface over a task
|
||||
* the server cannot address yet must not offer a command through any of
|
||||
* its doors — every 404 here closes this task's controls for good. */
|
||||
const blocked = !controlAvailable || controlPending || controlMessage.trim() === '';
|
||||
return [
|
||||
{
|
||||
key: 'steer',
|
||||
label: localize('com_ui_steer'),
|
||||
kbd: steerKbd,
|
||||
icon: <Zap className="h-4 w-4 text-status-warning" aria-hidden="true" />,
|
||||
disabled: blocked,
|
||||
onClick: () => submitControl('steer'),
|
||||
},
|
||||
{
|
||||
key: 'queue',
|
||||
label: localize('com_ui_queue'),
|
||||
kbd: modEnter === 'other' ? modSymbol : undefined,
|
||||
icon: <Clock className="h-4 w-4 text-status-info" aria-hidden="true" />,
|
||||
disabled: blocked,
|
||||
onClick: () => submitControl('queue'),
|
||||
},
|
||||
{
|
||||
key: 'interrupt',
|
||||
label: localize('com_ui_subagent_interrupt'),
|
||||
kbd: altEnter === 'interrupt' ? altSymbol : undefined,
|
||||
icon: <OctagonPause className="h-4 w-4 text-status-error" aria-hidden="true" />,
|
||||
disabled: blocked,
|
||||
onClick: () => submitControl('interrupt'),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
composerMode,
|
||||
controlAvailable,
|
||||
controlMessage,
|
||||
controlPending,
|
||||
enterToSend,
|
||||
localize,
|
||||
shortcutsEnabled,
|
||||
submitControl,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
]);
|
||||
/** 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 => {
|
||||
/** A live run is exactly the condition main chat calls "during run", so
|
||||
* its during-run chords carry over verbatim: the default submits a
|
||||
* steer, ⌘/Ctrl+Enter takes the other queueing action, and the
|
||||
* interrupting chords interrupt. */
|
||||
const duringRun = controlModeRef.current;
|
||||
const action = resolveComposerKeyDown(event, {
|
||||
isComposing,
|
||||
isSubmitting: false,
|
||||
allowSubmitWhileGenerating: false,
|
||||
hasDuringRunModifier: false,
|
||||
isSubmitting: duringRun,
|
||||
allowSubmitWhileGenerating: duringRun,
|
||||
hasDuringRunModifier: duringRun,
|
||||
shortcutsEnabled,
|
||||
enterToSend,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
});
|
||||
if (action === 'submit') return 'submit';
|
||||
const control = CONTROL_FOR_ACTION[action as keyof typeof CONTROL_FOR_ACTION];
|
||||
if (control != null) {
|
||||
/** Bound to THIS event, so a chord the composer then refuses leaves
|
||||
* nothing a later pointer click could pick up. */
|
||||
chordControlRef.current = { event, control };
|
||||
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]);
|
||||
/** The submitting event decides which control this is, so a chord that was
|
||||
* refused (empty field, settling, a command already in flight) leaves
|
||||
* nothing behind for the next pointer click to pick up. */
|
||||
const submitComposer = useCallback(
|
||||
(event?: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!controlAvailable) {
|
||||
continueAsChat();
|
||||
return;
|
||||
}
|
||||
const chord = chordControlRef.current;
|
||||
chordControlRef.current = null;
|
||||
submitControl(event != null && chord?.event === event ? chord.control : 'steer');
|
||||
},
|
||||
[continueAsChat, controlAvailable, submitControl],
|
||||
);
|
||||
const selectActor = useCallback(
|
||||
(nextThreadId: string) => {
|
||||
const next = eventSiblings.find((child) => child.threadId === nextThreadId);
|
||||
|
|
@ -1133,7 +1302,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
let activityPanel: ReactNode;
|
||||
if (hasConversationProjection) {
|
||||
activityPanel = (
|
||||
<SubagentActivityScrollSurface padded={false}>
|
||||
<SubagentActivityScrollSurface padded={false} headerInset>
|
||||
{showUnavailableHistoryBoundary && (
|
||||
<div
|
||||
role="status"
|
||||
|
|
@ -1191,7 +1360,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
((eventSummary?.tasks.length ?? 0) > 1 || eventSummary?.tasksTruncated === true)
|
||||
) {
|
||||
activityPanel = (
|
||||
<SubagentActivityScrollSurface padded={false}>
|
||||
<SubagentActivityScrollSurface padded={false} headerInset>
|
||||
<div data-subagent-thread-timeline>
|
||||
{timelinePrefix}
|
||||
{visibleEventTasks.map(renderEventTask)}
|
||||
|
|
@ -1206,6 +1375,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
activity={activity}
|
||||
state={panelState}
|
||||
showPrompt={false}
|
||||
headerInset
|
||||
onCancelControl={
|
||||
controlAvailable && !controlPending
|
||||
? (controlId) => submitControl('cancel_message', controlId)
|
||||
|
|
@ -1222,9 +1392,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
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"
|
||||
className="relative 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">
|
||||
{/* The main chat header's own shape: a 52px bar that floats over the
|
||||
thread and fades into it, so the conversation scrolls under it and
|
||||
more of it is on screen. Gradient stops track THIS surface rather
|
||||
than the chat's, since the panel sits on its own background. */}
|
||||
<header className="absolute top-0 z-10 flex h-[52px] w-full items-center gap-2 bg-gradient-to-b from-surface-primary-alt via-surface-primary-alt/70 to-transparent p-2 font-semibold text-text-primary">
|
||||
{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,
|
||||
|
|
@ -1293,8 +1467,12 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
>
|
||||
{activityPanel}
|
||||
</ApprovalProvider>
|
||||
{(showControlFooter || canContinueAsChat) && (
|
||||
<div className="shrink-0 p-3 pt-2">
|
||||
{(showControlFooter || composerMode != null) && (
|
||||
/* The main chat form's own bottom rhythm: its composer clears the
|
||||
viewport floor by the height of the disclaimer beneath it, and this
|
||||
one clears it by the same, so the two surfaces end on one line when
|
||||
the panel is open beside the thread. */
|
||||
<div className="shrink-0 px-3 pb-10 pt-2">
|
||||
{transientControl?.status === 'failed' && (
|
||||
<Alert variant="error" className="mb-2 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
|
|
@ -1316,20 +1494,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
</Alert>
|
||||
)}
|
||||
{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. */
|
||||
/* One surface across the run's whole life, and one control on it:
|
||||
submitting IS the steer, with nothing to send the send button
|
||||
becomes main chat's Stop, and every alternate submission hangs
|
||||
off that same control the way main chat's during-run actions do. */
|
||||
<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')
|
||||
}
|
||||
submitLabel={composerSubmitLabel}
|
||||
{...stopProps}
|
||||
ariaLabel={localize('com_ui_message_input')}
|
||||
placeholder={composerPlaceholder}
|
||||
submitOnEnter={enterToSend}
|
||||
|
|
@ -1338,44 +1514,27 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
* continuation is ordinary chat text bound for an ordinary
|
||||
* composer, which caps nothing. */
|
||||
maxLength={composerMode === 'control' ? 4 * 1024 : undefined}
|
||||
submitActions={actionsInline ? [] : submitActions}
|
||||
submitActionsLabel={localize('com_ui_during_run_actions')}
|
||||
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
|
||||
actionsInline
|
||||
? submitActions
|
||||
.filter((action) => action.key !== 'steer')
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={action.label}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className="size-9 rounded-full text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,48 @@ describe('Composer', () => {
|
|||
expect(resolveKeyVerdict).toHaveBeenCalledWith(expect.anything(), false);
|
||||
});
|
||||
|
||||
/** Main chat's trade: one slot, showing Stop when there is nothing to send
|
||||
* and Send the moment there is. */
|
||||
it('swaps the send control for Stop while the field is empty', () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onStop = jest.fn();
|
||||
const Hosted = () => {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<Composer
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={onSubmit}
|
||||
onStop={onStop}
|
||||
stopLabel="Cancel task"
|
||||
canSubmit
|
||||
submitLabel="Steer"
|
||||
ariaLabel="Message input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Hosted />);
|
||||
|
||||
expect(screen.queryByLabelText('Steer')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('Cancel task'));
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Message input'), {
|
||||
target: { value: 'Try the file.' },
|
||||
});
|
||||
expect(screen.queryByLabelText('Cancel task')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('Steer'));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the send control when the surface has nothing to stop', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
||||
expect(screen.getByLabelText('Send it')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('composer-stop-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends from the button and keeps the caller actions alongside it', () => {
|
||||
const onSubmit = jest.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import type {
|
|||
Ref,
|
||||
RefAttributes,
|
||||
} from 'react';
|
||||
import type { SendAction } from './SendActions';
|
||||
import { composerSurfaceClasses, composerSurfaceShadow } from '~/utils/composer';
|
||||
import { TextareaAutosize } from './TextareaAutosize';
|
||||
import { SendActions } from './SendActions';
|
||||
import { TooltipAnchor } from './Tooltip';
|
||||
import { SendIcon } from '~/svgs';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -20,7 +22,10 @@ export type ComposerKeyVerdict = 'submit' | 'block' | 'newline' | 'none';
|
|||
export interface ComposerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
/** Given the key event that asked for the submission, or nothing when a
|
||||
* pointer asked. Hosts that vary the action by chord resolve it from this
|
||||
* event rather than from state left behind by an earlier keypress. */
|
||||
onSubmit: (event?: KeyboardEvent<HTMLTextAreaElement>) => 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;
|
||||
|
|
@ -33,6 +38,15 @@ export interface ComposerProps {
|
|||
maxRows?: number;
|
||||
/** Secondary controls, laid out inline-start of the send button. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* Alternate submissions, revealed from the send control on hover or focus —
|
||||
* the same trade main chat's during-run send button makes, so the field is
|
||||
* never lined with controls that repeat what submitting already does. The
|
||||
* primary submission stays the button's own click.
|
||||
*/
|
||||
submitActions?: SendAction[];
|
||||
submitActionsLabel?: string;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -54,6 +68,23 @@ export interface ComposerProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops whatever this surface is running. When supplied, an empty field shows
|
||||
* main chat's Stop in the send button's place — the same trade the main
|
||||
* composer makes, so "nothing to send" and "stop what is running" never need
|
||||
* two separate controls. The label travels with the handler so the control can
|
||||
* never render without an accessible name.
|
||||
*/
|
||||
export type ComposerStopProps =
|
||||
| { onStop: () => void; stopLabel: string }
|
||||
| { onStop?: undefined; stopLabel?: undefined };
|
||||
|
||||
export type ComposerPropsWithStop = ComposerProps & ComposerStopProps;
|
||||
|
||||
/** Main chat's send/stop button shape, shared by both states of the slot. */
|
||||
const CONTROL_CLASS =
|
||||
'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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
@ -64,107 +95,158 @@ export interface ComposerProps {
|
|||
* 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 Composer: ForwardRefExoticComponent<
|
||||
ComposerPropsWithStop & RefAttributes<HTMLTextAreaElement>
|
||||
> = forwardRef(function Composer(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
submitLabel,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
maxLength,
|
||||
minRows = 1,
|
||||
maxRows = 6,
|
||||
actions,
|
||||
submitActions,
|
||||
submitActionsLabel,
|
||||
onStop,
|
||||
stopLabel,
|
||||
submitOnEnter = true,
|
||||
resolveKeyVerdict,
|
||||
className,
|
||||
}: ComposerPropsWithStop,
|
||||
ref: Ref<HTMLTextAreaElement>,
|
||||
) {
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
/** Nothing to send and something to stop: main chat swaps the same slot
|
||||
* rather than standing a second control beside it. */
|
||||
const showStop = onStop != null && value.trim() === '';
|
||||
const offeredActions = canSubmit ? (submitActions ?? []) : [];
|
||||
const sendButton = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={submitLabel}
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={() => onSubmit()}
|
||||
data-testid="composer-send-button"
|
||||
className={cn(CONTROL_CLASS, offeredActions.length > 0 && 'ml-auto')}
|
||||
>
|
||||
<SendIcon size={24} />
|
||||
</button>
|
||||
);
|
||||
|
||||
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 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();
|
||||
const defaultVerdict = (): ComposerKeyVerdict => {
|
||||
if (composing || event.key !== 'Enter' || event.shiftKey) return 'none';
|
||||
if (!submitOnEnter && !(event.metaKey || event.ctrlKey)) return 'newline';
|
||||
return 'submit';
|
||||
};
|
||||
|
||||
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,
|
||||
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(event);
|
||||
};
|
||||
|
||||
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}
|
||||
/** Main chat's own field metrics (`ChatForm`'s `baseClasses`), so the
|
||||
* two composers stand the same height and their surfaces line up
|
||||
* when this panel is open beside the thread. */
|
||||
className="m-0 w-full resize-none bg-transparent px-3 py-[13px] text-text-primary placeholder:text-text-tertiary focus:outline-none disabled:cursor-not-allowed md:py-3.5"
|
||||
/>
|
||||
{/* 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}
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-1.5">
|
||||
{actions}
|
||||
{showStop ? (
|
||||
<TooltipAnchor
|
||||
description={submitLabel}
|
||||
description={stopLabel}
|
||||
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"
|
||||
aria-label={stopLabel}
|
||||
onClick={onStop}
|
||||
data-testid="composer-stop-button"
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<SendIcon size={24} />
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-lg text-surface-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="7" y="7" width="10" height="10" rx="1.25" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SendActions
|
||||
actions={offeredActions}
|
||||
label={submitActionsLabel ?? submitLabel}
|
||||
anchor={
|
||||
/** One popup over this control. With actions to offer, the list
|
||||
* names them and carries the primary; a tooltip would open on
|
||||
* the same focus, above the same button, and at a higher
|
||||
* z-index than the list it would cover. */
|
||||
offeredActions.length > 0 ? (
|
||||
sendButton
|
||||
) : (
|
||||
<TooltipAnchor description={submitLabel} className="ml-auto" render={sendButton} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Composer;
|
||||
|
|
|
|||
73
packages/client/src/components/SendActions.spec.tsx
Normal file
73
packages/client/src/components/SendActions.spec.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { SendActions } from './SendActions';
|
||||
|
||||
/**
|
||||
* Renders the hovercard eagerly. Ariakit's real show path depends on pointer
|
||||
* timers and layout, and these assertions are about which rows this module
|
||||
* renders, not about Ariakit's hover behavior. The same substitution is made in
|
||||
* `DuringRunSendButton.test.tsx`, whose 13 tests cover the populated rows
|
||||
* through this module's other host.
|
||||
*/
|
||||
jest.mock('@ariakit/react', () => ({
|
||||
HovercardProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HovercardAnchor: ({ render }: { render: React.ReactElement }) => render,
|
||||
Hovercard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const anchor = (
|
||||
<button type="button" aria-label="Send">
|
||||
send
|
||||
</button>
|
||||
);
|
||||
|
||||
describe('SendActions', () => {
|
||||
/** A host may pass a list that is sometimes empty — a settled run offers no
|
||||
* alternate submission — and must not have to branch for it. */
|
||||
it('renders the anchor alone when there is nothing else to offer', () => {
|
||||
const { container } = render(<SendActions anchor={anchor} actions={[]} label="More" />);
|
||||
|
||||
expect(screen.getByLabelText('Send')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('button')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('offers each action beside the anchor', () => {
|
||||
const steer = jest.fn();
|
||||
const queue = jest.fn();
|
||||
render(
|
||||
<SendActions
|
||||
anchor={anchor}
|
||||
label="More"
|
||||
actions={[
|
||||
{ key: 'steer', label: 'Steer', kbd: '⏎', onClick: steer },
|
||||
{ key: 'queue', label: 'Queue', onClick: queue },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Send')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Queue').closest('button') as HTMLButtonElement);
|
||||
expect(queue).toHaveBeenCalledTimes(1);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A disabled row's action refuses its chord too, so the hint would advertise
|
||||
* a key that does nothing. */
|
||||
it('withholds a disabled row and its chord', () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<SendActions
|
||||
anchor={anchor}
|
||||
label="More"
|
||||
actions={[{ key: 'queue', label: 'Queue', kbd: '⌘⏎', disabled: true, onClick }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByText('Queue').closest('button') as HTMLButtonElement;
|
||||
expect(row).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(row.querySelector('kbd')).toBeNull();
|
||||
fireEvent.click(row);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
81
packages/client/src/components/SendActions.tsx
Normal file
81
packages/client/src/components/SendActions.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { JSX } from 'react/jsx-runtime';
|
||||
|
||||
/** One alternate way to submit, offered from the send control rather than
|
||||
* standing beside the field. */
|
||||
export interface SendAction {
|
||||
key: string;
|
||||
label: string;
|
||||
/** The chord that still reaches this action, when one does. Omitted rather
|
||||
* than guessed: advertising a key that has been rebound, yielded to a global
|
||||
* shortcut, or disabled is worse than advertising none. */
|
||||
kbd?: string;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ROW_CLASS =
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm text-text-primary hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy aria-disabled:cursor-not-allowed aria-disabled:opacity-50';
|
||||
|
||||
function Kbd({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<kbd className="ml-auto rounded-md bg-surface-tertiary px-1.5 py-0.5 font-sans text-xs text-text-secondary">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The alternate submissions a send control offers on hover or focus.
|
||||
*
|
||||
* Every chat surface that can submit more than one way shows the same list in
|
||||
* the same place — hung off the send control, never lined up beside the field,
|
||||
* where a row would repeat what submitting already does. The anchor is passed
|
||||
* in whole so each host keeps its own button: its ref (Enter's synthetic click
|
||||
* routes through it), its submit type, and its own identifying attributes.
|
||||
*
|
||||
* With no actions the anchor renders alone, so a host can pass a list that is
|
||||
* sometimes empty without branching.
|
||||
*/
|
||||
export function SendActions({
|
||||
anchor,
|
||||
actions,
|
||||
label,
|
||||
}: {
|
||||
anchor: ReactElement;
|
||||
actions: SendAction[];
|
||||
label: string;
|
||||
}): JSX.Element {
|
||||
if (actions.length === 0) return anchor;
|
||||
return (
|
||||
<Ariakit.HovercardProvider placement="top-end" showTimeout={100} hideTimeout={150}>
|
||||
<Ariakit.HovercardAnchor render={anchor} />
|
||||
<Ariakit.Hovercard
|
||||
portal
|
||||
gutter={8}
|
||||
unmountOnHide
|
||||
aria-label={label}
|
||||
className="z-50 min-w-[12rem] rounded-xl border border-border-light bg-surface-secondary p-1.5 text-text-primary shadow-lg outline-none"
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
className={ROW_CLASS}
|
||||
aria-disabled={action.disabled === true}
|
||||
onClick={action.disabled === true ? undefined : action.onClick}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
{/* A disabled row's action refuses its chord too, so no hint. */}
|
||||
{action.kbd != null && action.disabled !== true && <Kbd>{action.kbd}</Kbd>}
|
||||
</button>
|
||||
))}
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default SendActions;
|
||||
|
|
@ -44,7 +44,14 @@ 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 { SendActions } from './SendActions';
|
||||
export type { SendAction } from './SendActions';
|
||||
export type {
|
||||
ComposerProps,
|
||||
ComposerPropsWithStop,
|
||||
ComposerStopProps,
|
||||
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