{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"
>
-
+ {/* 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. */}
+
{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}
- {(showControlFooter || canContinueAsChat) && (
-
+ {(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. */
+
{transientControl?.status === 'failed' && (
@@ -1316,20 +1494,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
)}
{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. */
-
-
-
- >
- ) : null
+ actionsInline
+ ? submitActions
+ .filter((action) => action.key !== 'steer')
+ .map((action) => (
+
+ ))
+ : null
}
/>
)}
diff --git a/packages/client/src/components/Composer.spec.tsx b/packages/client/src/components/Composer.spec.tsx
index 9dc4cbc09a..9d3f4c0299 100644
--- a/packages/client/src/components/Composer.spec.tsx
+++ b/packages/client/src/components/Composer.spec.tsx
@@ -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 (
+
+ );
+ };
+ render();
+
+ 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();
+
+ 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();
diff --git a/packages/client/src/components/Composer.tsx b/packages/client/src/components/Composer.tsx
index 06208d27b3..da1427ced6 100644
--- a/packages/client/src/components/Composer.tsx
+++ b/packages/client/src/components/Composer.tsx
@@ -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) => 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> =
- 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,
- ) {
- const [isComposing, setIsComposing] = useState(false);
+const Composer: ForwardRefExoticComponent<
+ ComposerPropsWithStop & RefAttributes
+> = 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,
+) {
+ 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 = (
+
+ );
- const handleKeyDown = (event: KeyboardEvent) => {
- /**
- * `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) => {
+ /**
+ * `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 (
-
- 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 (
+
+ 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. */}
-
+ );
+});
export default Composer;
diff --git a/packages/client/src/components/SendActions.spec.tsx b/packages/client/src/components/SendActions.spec.tsx
new file mode 100644
index 0000000000..b3d2976aa7
--- /dev/null
+++ b/packages/client/src/components/SendActions.spec.tsx
@@ -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 }) =>
,
+}));
+
+const anchor = (
+
+);
+
+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();
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/packages/client/src/components/SendActions.tsx b/packages/client/src/components/SendActions.tsx
new file mode 100644
index 0000000000..fc4a0d5afc
--- /dev/null
+++ b/packages/client/src/components/SendActions.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+}
+
+export default SendActions;
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
index 758b3f1aa6..3109982659 100644
--- a/packages/client/src/components/index.ts
+++ b/packages/client/src/components/index.ts
@@ -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';