Files
paseo/packages/app/src/composer/index.tsx
T
Mohamed BoudraandGitHub d706c4339b Fix hidden Codex subagents and stuck parent sessions (#2068)
* fix(server): prevent hidden Codex subagents and stuck turns

Codex can announce native children only through its mirrored lifecycle stream, while rejected interrupts previously looked like successful local cancellation. Preserve those child announcements and keep manager state active until the provider acknowledges cancellation.

* fix(server): block actions after rejected cancellation

A provider can accept a turn before publishing the turn ID needed to interrupt it. Keep that interval non-cancelable, and prevent reload, replacement, or rewind from proceeding without an acknowledged cancellation.

* fix(server): surface rejected agent cancellations

Replacement prompts and Stop requests could appear accepted after the provider kept ownership of the active turn. Complete replacement cancellation before detaching the stream, and return cancellation failures through the client response.

* fix(server): handle turn completion during cancellation

* fix: finish cancellation lifecycle handling

* fix(server): close remaining cancellation races

* fix(server): settle autonomous cancellations

* fix: honor cancellation failures at call sites

* refactor(server): centralize agent run state

* fix(server): keep pending runs isolated from stale events

* fix: surface remaining cancellation failures

* fix(server): narrow cancellation error handling
2026-07-15 00:01:58 +02:00

2201 lines
68 KiB
TypeScript

import {
View,
Pressable,
Text,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import type { TFunction } from "i18next";
import {
useState,
useEffect,
useRef,
useCallback,
useMemo,
memo,
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import {
ArrowUp,
Square,
Pencil,
AudioLines,
CircleDot,
FileText,
GitPullRequest,
Github,
Image as ImageIcon,
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import {
AgentControls,
DraftAgentControls,
type DraftAgentControlsProps,
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
editQueuedComposerMessage,
findGithubItemByOption,
isAttachmentSelectedForGithubItem,
openComposerAttachment,
pickAndPersistImages,
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import {
useHostRuntimeAgentDirectoryStatus,
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
import {
deleteAttachments,
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
type QueuedMessage = QueuedComposerMessage;
type AttachmentListUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {}
const noopCallback = () => {};
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
}
function resolveIsComposerLocked(
submitBehavior: "clear" | "preserve-and-lock",
isSubmitLoading: boolean,
): boolean {
return submitBehavior === "preserve-and-lock" && isSubmitLoading;
}
function resolveIsVoiceModeForAgent(
voice: ReturnType<typeof useVoiceOptional>,
serverId: string,
agentId: string,
): boolean {
return voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
}
function resolveKeyboardPriority(isMessageInputFocused: boolean): number {
return isMessageInputFocused ? 200 : 100;
}
function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean {
return isWeb && !isMobile;
}
function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean {
return override ?? formFactor;
}
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
return isDesktopWebBreakpoint
? t("composer.placeholders.desktop")
: t("composer.placeholders.mobile");
}
function resolveGithubSearchEnabled(
isGithubPickerOpen: boolean,
isConnected: boolean,
cwd: string,
): boolean {
return isGithubPickerOpen && isConnected && cwd.trim().length > 0;
}
function resolveCheckoutRemoteUrl(
checkoutStatus: ReturnType<typeof useCheckoutStatusQuery>["status"],
): string | null {
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
function buildRealtimeVoiceButtonStyle(
hovered: boolean | undefined,
voiceButtonDisabled: boolean,
reserveLeadingSpace: boolean,
): object[] {
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
(value): value is object => Boolean(value),
);
}
function buildAgentStateSelector(serverId: string, agentId: string) {
return (state: ReturnType<typeof useSessionStore.getState>) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null,
provider: agent?.provider ?? null,
};
};
}
function renderContextWindowMeter(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
serverId: string,
provider: string | null,
pending: boolean,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
return null;
}
return (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
serverId={serverId}
provider={provider}
pending={pending}
/>
);
}
function resolveContextWindowPlacement(
meter: ReactElement | null,
isMobile: boolean,
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
if (isMobile) {
return { beforeVoiceContent: null, footerInlineContent: meter };
}
return {
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerInlineContent: null,
};
}
interface RenderLeftContentArgs {
agentControls: DraftAgentControlsProps | undefined;
agentId: string;
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
}
return (
<AgentControls
agentId={agentId}
serverId={serverId}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
);
}
interface RenderAttachmentTrayArgs {
selectedAttachments: ComposerAttachment[];
isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void;
labels: {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
};
}
function renderComposerFooter(
footer: ReactNode,
footerInlineContent: ReactNode,
): ReactElement | null {
if (!footer && !footerInlineContent) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>
<View style={styles.footerLeft}>
{footer}
{footerInlineContent}
</View>
</View>
</View>
);
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
selectedAttachments,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels,
} = args;
if (selectedAttachments.length === 0) return null;
return (
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
attachment,
index,
disabled: isComposerLocked,
onOpen: handleOpenAttachment,
onRemove: handleRemoveAttachment,
labels,
}),
)}
</View>
);
}
interface RenderQueueTrackArgs {
queuedMessages: readonly QueuedMessage[];
handleEditQueuedMessage: (id: string) => void;
handleSendQueuedNow: (id: string) => Promise<void>;
editLabel: string;
sendNowLabel: string;
}
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } =
args;
if (queuedMessages.length === 0) return null;
return (
<View style={styles.queueTrack}>
{queuedMessages.map((item) => (
<QueuedMessageRow
key={item.id}
item={item}
onEdit={handleEditQueuedMessage}
onSendNow={handleSendQueuedNow}
editLabel={editLabel}
sendNowLabel={sendNowLabel}
/>
))}
</View>
);
}
interface RenderComposerAttachmentPillArgs {
attachment: ComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
labels: RenderAttachmentTrayArgs["labels"];
}
function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement {
const { attachment, index, disabled, onOpen, onRemove, labels } = args;
if (attachment.kind === "image") {
return (
<ImageAttachmentPill
key={attachment.metadata.id}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openImage}
removeLabel={labels.removeImage}
/>
);
}
if (attachment.kind === "file") {
return (
<FileAttachmentPill
key={attachment.attachment.id}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
index,
disabled,
onOpen,
onRemove,
});
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
function resolveErrorMessage(error: unknown): string | null {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return null;
}
interface AttemptStartRealtimeVoiceArgs {
voice: ReturnType<typeof useVoiceOptional>;
isConnected: boolean;
hasAgent: boolean;
serverId: string;
agentId: string;
toastErrorRef: { current: (message: string) => void };
}
function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
const { voice, isConnected, hasAgent, serverId, agentId, toastErrorRef } = args;
if (!voice || !isConnected || !hasAgent) return;
if (voice.isVoiceSwitching) return;
if (voice.isVoiceModeForAgent(serverId, agentId)) return;
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[Composer] Failed to start voice mode", error);
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
});
}
function focusMessageInputWithPlatformStrategy(messageInputRef: {
current: MessageInputRef | null;
}): void {
if (isNative) {
messageInputRef.current?.focus();
return;
}
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null;
const active = typeof document !== "undefined" ? document.activeElement : null;
return Boolean(el) && active === el;
},
});
}
interface DispatchComposerKeyboardActionArgs {
action: KeyboardActionDefinition;
isPaneFocused: boolean;
messageInputRef: { current: MessageInputRef | null };
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
handleCancelAgent: () => void;
focusMessageInputForKeyboardAction: () => void;
}
function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs): boolean {
const {
action,
isPaneFocused,
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
} = args;
if (!isPaneFocused) return false;
if (action.id === "agent.interrupt") {
if (messageInputRef.current?.runKeyboardAction("dictation-cancel")) return true;
if (!isAgentRunning || isCancellingAgent || !isConnected) return false;
handleCancelAgent();
return true;
}
if (action.id === "message-input.focus") {
focusMessageInputForKeyboardAction();
return true;
}
const passthroughAction = resolveMessageInputPassthroughAction(action.id);
if (!passthroughAction) return false;
const result = messageInputRef.current?.runKeyboardAction(passthroughAction);
if (passthroughAction === "send" || passthroughAction === "dictation-confirm") {
return result ?? false;
}
return true;
}
function resolveMessageInputPassthroughAction(
actionId: string,
): MessageInputKeyboardActionKind | null {
switch (actionId) {
case "message-input.send":
return "send";
case "message-input.dictation-confirm":
return "dictation-confirm";
case "message-input.dictation-toggle":
return "dictation-toggle";
case "message-input.dictation-cancel":
return "dictation-cancel";
case "message-input.voice-toggle":
return "voice-toggle";
case "message-input.voice-mute-toggle":
return "voice-mute-toggle";
default:
return null;
}
}
interface QueuedMessageRowProps {
item: QueuedMessage;
onEdit: (id: string) => void;
onSendNow: (id: string) => void;
editLabel: string;
sendNowLabel: string;
}
function QueuedMessageRow({
item,
onEdit,
onSendNow,
editLabel,
sendNowLabel,
}: QueuedMessageRowProps) {
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
const handleSendNow = useCallback(() => {
onSendNow(item.id);
}, [onSendNow, item.id]);
return (
<View style={styles.queueItem}>
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel={editLabel}
accessibilityRole="button"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable>
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel={sendNowLabel}
accessibilityRole="button"
>
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
</Pressable>
</View>
</View>
);
}
interface ImageAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "image" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: string;
removeLabel: string;
}
function ImageAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: ImageAttachmentPillProps) {
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-image-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentThumbnail metadata={attachment.metadata} />
</AttachmentPill>
);
}
interface GithubAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
}
function GithubAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
disabled={disabled}
>
<AttachmentLabel
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
title={item.title}
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
/>
</AttachmentPill>
);
}
interface FileAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "file" }>;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function FileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: FileAttachmentPillProps) {
const { t } = useTranslation();
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
const fileName = attachment.attachment.fileName;
return (
<AttachmentPill
testID="composer-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentLabel
icon={filePillIcon}
title={fileName}
subtitle={getFileTypeLabel(fileName) ?? t("message.attachments.file")}
/>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
active: boolean;
selected: boolean;
item: GitHubSearchItem;
onToggle: (item: GitHubSearchItem) => void;
}
function GithubPickerOption({
label,
testID,
active,
selected,
item,
onToggle,
}: GithubPickerOptionProps) {
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
),
[item.kind],
);
return (
<ComboboxItem
testID={testID}
label={label}
selected={selected}
active={active}
onPress={handlePress}
leadingSlot={leadingSlot}
/>
);
}
interface ComposerProps {
agentId: string;
serverId: string;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
/** When true, the composer can submit even with no text or attachments. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
/** Optional testID for the primary submit button. */
submitButtonTestID?: string;
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: UserComposerAttachment[];
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void;
onComposerHeightChange?: (height: number) => void;
onAttentionInputFocus?: () => void;
onAttentionPromptSend?: () => void;
/** Controlled agent controls rendered in input area (draft flows). */
agentControls?: DraftAgentControlsProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
/** Rendered below the input, inside the keyboard-shifted container. */
footer?: ReactNode;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
isCompactLayout?: boolean;
}
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues(
rawMax: number | null,
rawUsed: number | null,
): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } {
if (typeof rawMax === "number" && typeof rawUsed === "number") {
return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed };
}
return { contextWindowMaxTokens: null, contextWindowUsedTokens: null };
}
interface ComposerCancelButtonProps {
buttonIconSize: number;
cancelButtonStyle: (object | undefined)[];
handleCancelAgent: () => void;
isConnected: boolean;
isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
function ComposerCancelButton({
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
isConnected,
isCancellingAgent,
agentInterruptKeys,
t,
}: ComposerCancelButtonProps) {
const accessibilityLabel = isCancellingAgent
? t("composer.cancel.cancelingAgent")
: t("composer.cancel.stopAgent");
const icon = isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
);
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
style={cancelButtonStyle}
>
{icon}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{t("composer.cancel.interrupt")}</Text>
{shortcutNode}
</View>
</TooltipContent>
</Tooltip>
);
}
interface ComposerCancelButtonSlotProps extends ComposerCancelButtonProps {
isAgentRunning: boolean;
hasSendableContent: boolean;
isProcessing: boolean;
}
function ComposerCancelButtonSlot({
isAgentRunning,
hasSendableContent,
isProcessing,
...rest
}: ComposerCancelButtonSlotProps) {
if (!isAgentRunning || hasSendableContent || isProcessing) return null;
return <ComposerCancelButton {...rest} />;
}
interface ComposerVoiceModeButtonProps {
buttonIconSize: number;
handleToggleRealtimeVoice: () => void;
isConnected: boolean;
isVoiceSwitching: boolean;
realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isVoiceModeForAgent: boolean;
hasAgent: boolean;
isAgentRunning: boolean;
hasSendableContent: boolean;
isProcessing: boolean;
isCompact: boolean;
cancelButton: ReactElement;
}
function ComposerRightControlsSlot({
isVoiceModeForAgent,
hasAgent,
isAgentRunning,
hasSendableContent,
isProcessing,
isCompact,
cancelButton,
...voiceProps
}: ComposerRightControlsSlotProps) {
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton =
!isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
if (!showVoiceModeButton && !shouldShowCancelButton) return null;
return (
<View style={styles.rightControls}>
{showVoiceModeButton ? <ComposerVoiceModeButton {...voiceProps} /> : null}
{cancelButton}
</View>
);
}
function ComposerVoiceModeButton({
buttonIconSize,
handleToggleRealtimeVoice,
isConnected,
isVoiceSwitching,
realtimeVoiceButtonStyle,
voiceToggleKeys,
t,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
},
[buttonIconSize, isVoiceSwitching],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || isVoiceSwitching}
accessibilityLabel={t("composer.voice.enableVoiceMode")}
accessibilityRole="button"
style={realtimeVoiceButtonStyle}
>
{renderTriggerContent}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
{shortcutNode}
</View>
</TooltipContent>
</Tooltip>
);
}
// oxlint-disable-next-line complexity
export function Composer({
agentId,
serverId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
onChangeText,
attachments,
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
onChangeAttachments,
cwd,
clearDraft,
autoFocus = false,
onFocusInput,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
onAttentionPromptSend,
agentControls,
inputWrapperStyle,
footer,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
const toast = useToast();
const toastErrorRef = useRef(toast.error);
toastErrorRef.current = toast.error;
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
const isDictationReady = useIsDictationReady({
serverId,
isConnected,
agentDirectoryStatus,
});
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore(useShallow(buildAgentStateSelector(serverId, agentId)));
const queuedMessagesRaw = useSessionStore((state) =>
state.sessions[serverId]?.queuedMessages?.get(agentId),
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value;
const setUserInput = onChangeText;
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
const {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
} = composerWorkspaceAttachment.useBinding({
normalAttachments: attachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client,
isConnected,
serverId,
cwd,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
const [githubSearchQuery, setGithubSearchQuery] = useState("");
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
return false;
}
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
clearDraft("sent");
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
setIsProcessing(true);
void onClientSlashCommand(command)
.catch((error) => {
console.error("[Composer] Failed to run client slash command:", error);
setSendError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
setIsProcessing(false);
});
return true;
},
[
blurOnSubmit,
clearDraft,
onClientSlashCommand,
resetSuppression,
setSelectedAttachments,
setUserInput,
],
);
const autocomplete = useAgentAutocomplete({
userInput,
cursorIndex,
setUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
onAutocompleteApplied: () => {
messageInputRef.current?.focus();
},
});
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
// Clear send error when user edits the input
useEffect(() => {
if (sendError && userInput) {
setSendError(null);
}
}, [userInput, sendError]);
useEffect(() => {
setCursorIndex((current) => Math.min(current, userInput.length));
}, [userInput.length]);
const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
const addImages = useCallback(
(images: ImageAttachment[]) => {
setSelectedAttachments((prev) => [
...prev,
...images.map((metadata) => ({ kind: "image" as const, metadata })),
]);
},
[setSelectedAttachments],
);
const addFiles = useCallback(
(files: UserComposerAttachment[]) => {
setSelectedAttachments((prev) => [...prev, ...files]);
},
[setSelectedAttachments],
);
const focusInput = useCallback(() => {
if (isNative) return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null;
return el != null && document.activeElement === el;
},
});
}, []);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, submitAttachments: ComposerAttachment[]) => {
onMessageSent?.();
if (onSubmitMessageRef.current) {
await onSubmitMessageRef.current({ text, attachments: submitAttachments, cwd });
return;
}
if (!sendAgentMessageRef.current) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
},
[cwd, onMessageSent, t],
);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
sendAgentMessageRef.current = async (
targetAgentId: string,
text: string,
sendAttachments: ComposerAttachment[],
) => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
};
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
text,
attachments: sendAttachments,
encodeImages,
stream,
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
}, [onSubmitMessage]);
const isAgentRunning = agentState.status === "running";
const hasAgent = agentState.status !== null;
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
);
const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
const result = queueComposerMessage({
agentId,
text: queuedMessage,
attachments: queuedAttachments,
queue: queueWriter,
});
if (!result.queued) return;
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
},
[
agentId,
clearSentAttachments,
queueWriter,
resetSuppression,
setSelectedAttachments,
setUserInput,
],
);
const sendMessageWithContent = useCallback(
async (
outgoingMessage: string,
outgoingAttachments: ComposerAttachment[],
forceSend?: boolean,
) => {
const result = await submitAgentInput({
message: outgoingMessage,
attachments: outgoingAttachments,
hasExternalContent,
allowEmptySubmit,
forceSend,
submitBehavior,
isAgentRunning,
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message: queuedText, attachments: queuedAttachments }) => {
queueMessage(queuedText, queuedAttachments);
},
submitMessage: async ({ message: submitText, attachments: submitAttachments }) => {
await submitMessage(submitText, submitAttachments);
},
clearDraft,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
},
setSendError,
setIsProcessing,
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
failedToSendMessage: t("composer.errors.failedToSend"),
});
completeSubmit({
result,
outgoingAttachments,
});
},
[
allowEmptySubmit,
clearDraft,
completeSubmit,
hasExternalContent,
isAgentRunning,
queueMessage,
setSelectedAttachments,
setUserInput,
submitBehavior,
submitMessage,
t,
],
);
const handleSubmit = useCallback(
(payload: MessagePayload) => {
const outgoingAttachments = buildOutgoingAttachments(attachments);
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend);
},
[
attachments,
blurOnSubmit,
buildOutgoingAttachments,
runClientSlashCommand,
sendMessageWithContent,
],
);
const handlePickImage = useCallback(async () => {
const newImages = await pickAndPersistImages({
pickImages,
persister: {
persistFromBlob: ({ blob, mimeType, fileName }) =>
persistAttachmentFromBlob({ blob, mimeType, fileName }),
persistFromFileUri: ({ uri, mimeType, fileName }) =>
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
},
});
if (newImages.length === 0) return;
addImages(newImages);
}, [addImages, pickImages]);
const uploadPickedFiles = useCallback(
async (files: PickedFile[]) => {
if (files.length === 0) return;
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
if (oversized) {
toastErrorRef.current(
t("composer.errors.fileTooLarge", { size: "50MB", fileName: oversized.fileName }),
);
return;
}
setIsUploadingFile(true);
try {
const uploaded = await uploadFileAttachments({ client, files });
addFiles(uploaded);
} catch (error) {
console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
} finally {
setIsUploadingFile(false);
}
},
[addFiles, client, t],
);
const handlePickFile = useCallback(async () => {
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
try {
const files = await pickFiles();
if (!files) return;
await uploadPickedFiles(files);
} catch (error) {
console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
}
}, [client, pickFiles, t, uploadPickedFiles]);
const handleGenericFilesDropped = useCallback(
async (items: DroppedItem[]) => {
try {
const files = await droppedItemsToPickedFiles(items);
if (files.length === 0) return;
if (!client || !isConnected) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
await uploadPickedFiles(files);
} catch (error) {
console.error("[Composer] Failed to upload dropped files:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
}
},
[client, isConnected, t, uploadPickedFiles],
);
const handleRemoveAttachment = useCallback(
(index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
});
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) =>
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
},
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment({
attachment,
setLightboxMetadata,
openWorkspaceAttachment: openAttachment,
openExternalUrl: (url) => {
void openExternalUrl(url);
},
});
},
[openAttachment],
);
useEffect(() => {
if (!isAgentRunning || !isConnected) {
setIsCancellingAgent(false);
}
}, [isAgentRunning, isConnected]);
const handleCancelAgent = useCallback(() => {
const didCancel = cancelComposerAgent({
client,
agentId: agentIdRef.current,
isAgentRunning,
isCancellingAgent,
isConnected,
onCancelFailed: (error) => {
setIsCancellingAgent(false);
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
},
});
if (!didCancel) return;
setIsCancellingAgent(true);
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
const focusMessageInputForKeyboardAction = useCallback(() => {
focusMessageInputWithPlatformStrategy(messageInputRef);
}, []);
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean =>
dispatchComposerKeyboardAction({
action,
isPaneFocused,
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
}),
[
focusMessageInputForKeyboardAction,
handleCancelAgent,
isAgentRunning,
isCancellingAgent,
isConnected,
isPaneFocused,
],
);
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: [
"agent.interrupt",
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
"message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isPaneFocused,
priority: resolveKeyboardPriority(isMessageInputFocused),
isActive: () => isPaneFocused,
handle: handleKeyboardAction,
});
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
enabled: !externalKeyboardShift,
});
const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId);
const handleToggleRealtimeVoice = useCallback(() => {
attemptStartRealtimeVoice({
voice,
isConnected,
hasAgent,
serverId,
agentId,
toastErrorRef,
});
}, [agentId, hasAgent, isConnected, serverId, voice]);
const handleEditQueuedMessage = useCallback(
(id: string) => {
const result = editQueuedComposerMessage({
agentId,
messageId: id,
queue: queueWriter,
});
if (!result) return;
setUserInput(result.text);
setSelectedAttachments(result.attachments);
},
[agentId, queueWriter, setSelectedAttachments, setUserInput],
);
const handleSendQueuedNow = useCallback(
async (id: string) => {
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
// Reuse the regular send path; server-side send atomically interrupts any active run.
const result = await sendQueuedComposerMessageNow({
agentId,
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
failedToSendMessage: t("composer.errors.failedToSend"),
});
if (result.status === "failed") {
setSendError(result.errorMessage);
}
},
[agentId, queueWriter, submitMessage, t],
);
const handleQueue = useCallback(
(payload: MessagePayload) => {
const outgoingAttachments = buildOutgoingAttachments(attachments);
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
queueMessage(payload.text, outgoingAttachments);
},
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) =>
autocompleteOnKeyPressRef.current(event),
[],
);
const cancelButtonStyle = useMemo(
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
[isConnected, isCancellingAgent],
);
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
const realtimeVoiceButtonStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) =>
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
[isCompactLayout, voiceButtonDisabled],
);
const cancelButton = useMemo(
() => (
<ComposerCancelButtonSlot
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent}
isConnected={isConnected}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
/>
),
[
agentInterruptKeys,
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
hasSendableContent,
isAgentRunning,
isCancellingAgent,
isConnected,
isProcessing,
t,
],
);
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
isVoiceModeForAgent={isVoiceModeForAgent}
hasAgent={hasAgent}
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
isCompact={isCompactLayout}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
cancelButton={cancelButton}
/>
),
[
buttonIconSize,
cancelButton,
handleToggleRealtimeVoice,
hasAgent,
hasSendableContent,
isAgentRunning,
isConnected,
isCompactLayout,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,
realtimeVoiceButtonStyle,
t,
voiceToggleKeys,
],
);
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
);
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeter = useMemo(
() =>
renderContextWindowMeter(
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
),
[
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
[contextWindowMeter, isCompactLayout],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useGithubSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
const githubSearchItemsRaw = githubSearchResultsQuery.data?.items;
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
})),
[githubSearchItems, githubSearchQueryTrimmed],
);
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(
() => [
{
id: "image",
label: t("composer.attachments.addImage"),
icon: <ThemedImageIcon size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
},
},
{
id: "github",
label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
{
id: "file",
label: t("composer.attachments.addFile"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickFile();
},
},
],
[handlePickImage, handlePickFile, t],
);
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
const nextAttachments = toggleGithubAttachmentFromPicker({
current: attachments,
item,
markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved,
});
setSelectedAttachments(nextAttachments);
setIsGithubPickerOpen(false);
setGithubSearchQuery("");
},
[
attachments,
githubAutoAttach,
setSelectedAttachments,
setGithubSearchQuery,
setIsGithubPickerOpen,
],
);
const leftContent = useMemo(
() =>
renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
}),
[agentControls, agentId, focusInput, isCompactLayout, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
attachButtonRef.current = node;
}, []);
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
setCursorIndex(selection.start);
}, []);
const handleFocusChange = useCallback(
(focused: boolean) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
},
[onAttentionInputFocus],
);
const handleLightboxClose = useCallback(() => {
setLightboxMetadata(null);
}, []);
const handleGithubPickerOpenChange = useCallback(
(open: boolean) => {
setIsGithubPickerOpen(open);
if (!open) {
setGithubSearchQuery("");
}
},
[setGithubSearchQuery],
);
const renderGithubPickerOption = useCallback(
({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => {
const item = findGithubItemByOption(githubSearchItems, option.id);
if (!item) {
return <View key={option.id} />;
}
const selected = isAttachmentSelectedForGithubItem(selectedAttachments, item);
return (
<GithubPickerOption
key={option.id}
testID={`composer-github-option-${option.id}`}
label={option.label}
selected={selected}
active={active}
item={item}
onToggle={handleToggleGithubItem}
/>
);
},
[githubSearchItems, selectedAttachments, handleToggleGithubItem],
);
const composerContainerStyle = useMemo(
() => [styles.container, keyboardAnimatedStyle],
[keyboardAnimatedStyle],
);
const inputAreaContainerStyle = useMemo(
() => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked],
[isComposerLocked],
);
const attachmentTray = useMemo(
() =>
renderAttachmentTray({
selectedAttachments,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels: {
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
t("composer.attachments.removeGithub", { kind, number }),
},
}),
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
);
const queueList = useMemo(
() =>
renderQueueTrack({
queuedMessages,
handleEditQueuedMessage,
handleSendQueuedNow,
editLabel: t("composer.attachments.editQueuedMessage"),
sendNowLabel: t("composer.attachments.sendQueuedMessageNow"),
}),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t],
);
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
useFileDrop(
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
{ disabled: isSubmitBusy },
);
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
() => (sendError ? <Text style={styles.sendErrorText}>{sendError}</Text> : null),
[sendError],
);
const githubEmptyText = githubSearchResultsQuery.isFetching
? t("composer.github.searching")
: t("composer.github.noResults");
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
</View>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
}
const styles = StyleSheet.create((theme: Theme) => ({
container: {
flexDirection: "column",
position: "relative",
},
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
},
inputAreaContainer: {
position: "relative",
minHeight: FOOTER_HEIGHT,
marginHorizontal: "auto",
alignItems: "center",
width: "100%",
overflow: "visible",
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
inputAreaLocked: {
opacity: 0.6,
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
gap: theme.spacing[3],
},
footer: {
width: "100%",
paddingHorizontal: theme.spacing[4],
// Negative margin pulls the footer up against the input area's paddingBottom.
// On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more.
marginTop: {
xs: -(theme.spacing[4] - 3),
md: -theme.spacing[3],
},
alignItems: "center",
paddingBottom: {
xs: 0,
md: theme.spacing[2],
},
},
footerContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
// On mobile, the negative margins below cancel each glyph's internal padding
// to reach the composer border; this inset adds a small visual gap from it.
paddingLeft: {
xs: 5,
md: 10,
},
paddingRight: {
xs: 5,
md: 10,
},
},
footerLeft: {
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
// so its icon aligns to the composer border before the footer inset is applied.
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
},
messageInputContainer: {
position: "relative",
width: "100%",
gap: theme.spacing[3],
},
cancelButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButtonCompactReserve: {
marginLeft: theme.spacing[1],
},
realtimeVoiceButtonActive: {
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
attachmentTray: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
buttonDisabled: {
opacity: 0.5,
},
queueTrack: {
flexDirection: "column",
gap: theme.spacing[2],
},
queueItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
gap: theme.spacing[2],
},
queueText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
queueActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
queueActionButton: {
width: 32,
height: 32,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
queueSendButton: {
backgroundColor: theme.colors.accent,
},
sendErrorText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
})) as unknown as Record<string, object>;
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
const ThemedPencil = withUnistyles(Pencil);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedFileText = withUnistyles(FileText);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const githubPrPillIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const githubIssuePillIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const filePillIcon = <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;