Files
paseo/packages/app/src/composer/index.tsx
T
Mohamed Boudra 1008e9fffc Insert suggested commands into the composer (#8371)
* feat(composer): insert suggested commands into drafts

* fix(composer): close suggestions after command insertion

* fix(composer): place caret after inserted commands
2026-10-31 19:25:16 +07:00

2729 lines
86 KiB
TypeScript

import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Pressable,
Text,
StyleSheet as RNStyleSheet,
type PressableStateCallbackType,
} from "react-native";
import type { TFunction } from "i18next";
import {
useState,
useEffect,
useLayoutEffect,
useRef,
useCallback,
useMemo,
useSyncExternalStore,
useImperativeHandle,
memo,
type ReactElement,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
import {
ArrowUp,
Square,
Pencil,
AudioLines,
CircleDot,
FileText,
GitPullRequest,
Image as ImageIcon,
ClipboardPaste,
Paperclip,
} from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import { FOOTER_HEIGHT } 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 {
selectAgentSentMessages,
selectAgentTurnPresentation,
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 AttachmentMenuItem,
type ComposerKeyPressEvent,
type MessageInputRef,
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandTarget } from "@/agent-commands";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
editQueuedComposerMessage,
findForgeItemByOption,
isAttachmentSelectedForForgeItem,
openComposerAttachment,
pickAndPersistImages,
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleForgeAttachmentFromPicker,
uploadFileAttachments,
type AttachmentPersister,
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 type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import { usePluginClientSlashCommands } from "@/plugins/client-slash-commands";
import {
executePluginClientSlashCommand,
resolvePluginClientSlashCommand,
} from "@/plugins/client-slash-commands/model";
import {
useHostRuntimeAgentDirectoryStatus,
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
import {
deleteAttachments,
persistAttachmentFromBlob,
persistAttachmentFromDataUrl,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { resolveActiveSendBehavior } from "./input/state";
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 { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
import { isWeb, isNative } from "@/constants/platform";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { SelectedFile } from "@/attachments/selected-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import {
AttachmentFrame,
AttachmentLabel,
AttachmentPill,
AttachmentThumbnail,
} from "@/components/attachment-pill";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerForgeAutoAttach } from "./forge-auto-attach";
import { readClipboardImage } from "./clipboard-image";
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
} from "@/attachments/workspace-file";
import {
resolveWorkspaceFileDrop,
type WorkspaceFileDragPayload,
} from "@/attachments/workspace-file-drag";
const composerImageAttachmentPersister: Pick<
AttachmentPersister,
"persistFromBlob" | "persistFromDataUrl" | "persistFromFileUri"
> = {
persistFromBlob: persistAttachmentFromBlob,
persistFromDataUrl: persistAttachmentFromDataUrl,
persistFromFileUri: persistAttachmentFromFileUri,
};
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(
inputMode: ComposerInputMode,
isDesktopWebBreakpoint: boolean,
t: TFunction,
override: string | undefined,
): string {
// A terminal placeholder names what it launches ("Prompt Codex", "Run a
// command"), which depends on the selected profile. Only the caller knows
// that, so it wins when supplied.
if (override !== undefined) {
return override;
}
if (inputMode === "terminal") {
return t("composer.placeholders.terminal");
}
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,
};
};
}
interface RenderLeftContentArgs {
agentControls: DraftAgentControlsProps | undefined;
agentId: string;
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
showAgentControls: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
if (!args.showAgentControls) return null;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
}
return (
<AgentControls
agentId={agentId}
serverId={serverId}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
);
}
interface PendingFileAttachment {
id: number;
file: SelectedFile;
}
interface RenderAttachmentTrayArgs {
serverId: string;
selectedAttachments: ComposerAttachment[];
pendingFiles: PendingFileAttachment[];
isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void;
labels: {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, numberLabel: string) => string;
removeGithub: (kind: string, numberLabel: string) => string;
};
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels,
} = args;
if (selectedAttachments.length === 0 && pendingFiles.length === 0) return null;
return (
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
serverId,
attachment,
index,
disabled: isComposerLocked,
onOpen: handleOpenAttachment,
onRemove: handleRemoveAttachment,
labels,
}),
)}
{pendingFiles.map(({ id, file }) => (
<AttachmentFrame key={id} testID="composer-pending-file-attachment">
<AttachmentLabel
icon={pendingFilePillIcon}
title={file.fileName}
subtitle={getFileTypeLabel(file.fileName) ?? ""}
/>
</AttachmentFrame>
))}
</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 {
serverId: string;
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 (attachment.kind === "workspace_file") {
return (
<WorkspaceFileAttachmentPill
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
index,
disabled,
onOpen,
onRemove,
});
}
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
serverId={args.serverId}
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
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 ComposerKeyboardRegistration({
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
isMessageInputFocused,
handlerId,
}: Omit<DispatchComposerKeyboardActionArgs, "action" | "isPaneFocused"> & {
isMessageInputFocused: boolean;
handlerId: string;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean =>
dispatchComposerKeyboardAction({
action,
isPaneFocused: isActiveComposer,
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
}),
[
focusMessageInputForKeyboardAction,
handleCancelAgent,
isActiveComposer,
isAgentRunning,
isCancellingAgent,
isConnected,
messageInputRef,
],
);
useKeyboardActionHandler({
handlerId,
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: isActiveComposer,
priority: resolveKeyboardPriority(isMessageInputFocused),
isActive: () => isActiveComposer,
handle: handleKeyboardAction,
});
return null;
}
function ComposerAutocomplete(props: React.ComponentProps<typeof AutocompletePopover>) {
const { isActiveComposer } = useComposerKeyboardScope();
return <AutocompletePopover {...props} visible={isActiveComposer && props.visible} />;
}
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={[styles.queueActionButton, styles.queueSendButton]}
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: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" }
>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, numberLabel: string) => string;
removeLabel: (kind: string, numberLabel: string) => string;
}
function GithubAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const presentation = getForgePresentation(item.forge ?? "github");
const isChangeRequest = item.kind === "change_request";
const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue";
const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue";
const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix;
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, `${numberPrefix}${item.number}`)}
removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)}
disabled={disabled}
>
<AttachmentLabel
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
title={item.title}
subtitle={`${subtitleKind} ${numberPrefix}${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 WorkspaceFileAttachmentPillProps {
attachment: WorkspaceFileComposerAttachment;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function WorkspaceFileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: WorkspaceFileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
const fileName = attachment.path.split("/").pop() ?? attachment.path;
return (
<AttachmentPill
testID="composer-workspace-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentLabel
icon={filePillIcon}
title={fileName}
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
/>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
active: boolean;
selected: boolean;
item: ForgeSearchItem;
onToggle: (item: ForgeSearchItem) => void;
}
function GithubPickerOption({
label,
testID,
active,
selected,
item,
onToggle,
}: GithubPickerOptionProps) {
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "change_request" ? (
<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;
workspaceId?: string | null;
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;
/** When true, waits for pasted forge links to resolve before enabling submit. */
waitForForgeAutoAttachOnSubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
textSource: ComposerTextSource;
onChangeText: (text: string) => void;
textReplacement: TextReplacement;
attachments: UserComposerAttachment[];
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
onForgeChangeRequestDetected?: () => void;
onForgeChangeRequestAutoAttach?: (item: ForgeSearchItem) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Changing this value requests focus again while autoFocus remains true. */
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
commandDraft?: DraftCommandTarget;
/** 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;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
isCompactLayout?: boolean;
/**
* What this composer is for. Terminal drops the chat-agent affordances and
* uses the terminal font; see `@/composer/input-mode`. Callers set the mode
* and nothing else — never branch on it at the call site.
*/
inputMode?: ComposerInputMode;
/** Renders the current text as static text on the same surface, for content there is nothing to type into. */
readOnly?: boolean;
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
submitLabel?: string;
/** Overrides the mode's default placeholder, for text only the caller can build. */
placeholder?: string;
/**
* The host that also lays out the dock and chrome shares this session so its
* fullscreen state and the composer's agree. Defaults to the tab's session for
* `inputMode`.
*/
editingSession?: ComposerEditingSession;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
interface ComposerAutocompleteHandle {
onKeyPress: (event: ComposerKeyPressEvent) => boolean;
}
function ComposerAutocompleteBinding({
text,
cursor,
recall,
configuration,
inputRef,
children,
anchorStyle,
show,
ref,
}: {
text: ComposerTextSource;
cursor: StoreApi<number>;
recall: ComposerRecall;
configuration: Omit<
Parameters<typeof useAgentAutocomplete>[0],
"userInput" | "cursorIndex" | "onAutocompleteApplied"
>;
inputRef: React.RefObject<MessageInputRef | null>;
children: React.ReactNode;
anchorStyle: React.ComponentProps<typeof View>["style"];
show: boolean;
ref: React.Ref<ComposerAutocompleteHandle>;
}) {
const userInput = useSyncExternalStore(text.subscribe, text.getSnapshot, text.getSnapshot);
const cursorIndex = useStore(cursor);
const autocomplete = useAgentAutocomplete({
...configuration,
userInput,
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
// A recalled message is sent as is; its leading `/command` must not open the picker.
const isShown = show && !recall.isShowing(userInput);
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
isShown,
handleKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
const onSelect = useCallback(
(option: AutocompleteOption) => selectOption(option, inputRef.current?.getInputSnapshot()),
[selectOption, inputRef],
);
return (
<ComposerAutocomplete
visible={autocomplete.isVisible && isShown}
anchorStyle={anchorStyle}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={onSelect}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
>
{children}
</ComposerAutocomplete>
);
}
function ComposerForgeBinding({
text,
configuration,
ref,
onResolvingChange,
}: {
text: ComposerTextSource;
configuration: Omit<Parameters<typeof useComposerForgeAutoAttach>[0], "text">;
ref: React.Ref<ReturnType<typeof useComposerForgeAutoAttach>>;
onResolvingChange: (resolving: boolean) => void;
}) {
const value = useSyncExternalStore(text.subscribe, text.getSnapshot, text.getSnapshot);
const binding = useComposerForgeAutoAttach({ ...configuration, text: value });
useImperativeHandle(ref, () => binding, [binding]);
useLayoutEffect(
() => onResolvingChange(binding.isResolving),
[binding.isResolving, onResolvingChange],
);
return 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 ? (
<LoadingSpinner 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 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;
isCompact: boolean;
showVoice: boolean;
}
function ComposerRightControlsSlot({
isVoiceModeForAgent,
hasAgent,
isAgentRunning,
hasSendableContent,
isCompact,
showVoice,
...voiceProps
}: ComposerRightControlsSlotProps) {
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton =
showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
if (!showVoiceModeButton) return null;
return (
<View style={styles.rightControls}>
<ComposerVoiceModeButton {...voiceProps} />
</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 <LoadingSpinner 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>
);
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
// not when its motion settles, so the exit action and autocomplete never race it.
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider
isPaneFocused={isPaneFocused}
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
</ComposerKeyboardScopeProvider>
);
}
type ComposerContentProps = Omit<ComposerProps, "isPaneFocused">;
const ComposerContent = memo(ComposerContentImpl);
// oxlint-disable-next-line complexity
function ComposerContentImpl({
agentId,
serverId,
workspaceId,
onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
waitForForgeAutoAttachOnSubmit = false,
submitBehavior = "clear",
blurOnSubmit = false,
textSource,
onChangeText,
textReplacement,
attachments,
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
onChangeAttachments,
onForgeChangeRequestDetected,
onForgeChangeRequestAutoAttach,
cwd,
clearDraft,
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraft,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
onAttentionPromptSend,
agentControls,
inputWrapperStyle,
isCompactLayout: isCompactLayoutOverride,
inputMode = "chat",
editingSession: providedEditingSession,
readOnly = false,
submitLabel,
placeholder,
}: ComposerContentProps) {
const mode = resolveComposerInputMode(inputMode);
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 isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
const hasText = useSyncExternalStore(
textSource.subscribe,
() => textSource.getSnapshot().trim().length > 0,
() => textSource.getSnapshot().trim().length > 0,
);
const setUserInput = onChangeText;
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
const {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
beginSubmit,
clearSentAttachments,
completeSubmit,
resetSuppression,
} = composerWorkspaceAttachment.useBinding({
normalAttachments: attachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const forgeAutoAttachRef = useRef<ReturnType<typeof useComposerForgeAutoAttach>>(null);
const [isForgeResolving, setIsForgeResolving] = useState(false);
const forgeConfiguration = useMemo(
() => ({
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client,
isConnected,
serverId,
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
onChangeRequestDetected: onForgeChangeRequestDetected,
onChangeRequestAdded: onForgeChangeRequestAutoAttach,
}),
[
checkoutStatusQuery.status,
attachments,
client,
isConnected,
serverId,
cwd,
supportsForgeSearch,
setSelectedAttachments,
onForgeChangeRequestDetected,
onForgeChangeRequestAutoAttach,
],
);
const cursor = useMemo(() => createStore<number>(() => 0), []);
const cursorPublication = useMemo(
() => new AfterPaintPublication<number>((position) => cursor.setState(position)),
[cursor],
);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
const recall = useMemo(() => new ComposerRecall(), []);
const [isProcessing, setIsProcessing] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const editingSession = useComposerEditingSession(inputMode, providedEditingSession);
const isFullscreen = useComposerFullscreen(editingSession);
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 pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const pluginClientSlashCommands = usePluginClientSlashCommands({
serverId,
workspaceId,
agentId,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
const replaceUserInput = useCallback(
(text: string, selection?: { start: number; end: number }) => {
if (messageInputRef.current) {
messageInputRef.current.replaceText(text, selection);
return;
}
onChangeText(text);
},
[onChangeText],
);
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
return false;
}
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
clearDraft("sent");
replaceUserInput("");
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,
replaceUserInput,
],
);
const runPluginClientSlashCommand = useCallback(
(resolved: { command: (typeof pluginClientSlashCommands)[number]; args: string }): boolean => {
if (blurOnSubmit) messageInputRef.current?.blur();
clearDraft("sent");
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
executePluginClientSlashCommand({
command: resolved.command,
args: resolved.args,
onError(error) {
console.error("[Composer] Failed to run plugin client slash command:", error);
toastErrorRef.current(error instanceof Error ? error.message : String(error));
},
});
return true;
},
[blurOnSubmit, clearDraft, replaceUserInput, resetSuppression, setSelectedAttachments],
);
const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
| ((
agentId: string,
text: string,
attachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
) => 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;
},
});
}, []);
const handleWorkspaceFileDropped = useCallback(
(payload: WorkspaceFileDragPayload) => {
if (!workspaceId) {
return;
}
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
if (!attachment) {
return;
}
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
focusInput();
},
[focusInput, serverId, setSelectedAttachments, workspaceId],
);
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,
appSettings.sendBehavior === "steer" ? "steer" : "interrupt",
);
},
[appSettings.sendBehavior, cwd, onMessageSent, t],
);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
sendAgentMessageRef.current = async (
targetAgentId: string,
text: string,
sendAttachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
) => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
};
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
}, [onSubmitMessage]);
const hasActiveTurn = useSessionStore(
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive,
);
const isCancellingAgent = useSessionStore(
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling,
);
const isAgentRunning = hasActiveTurn;
// Queueing behind a permission prompt would strand the message: the turn is
// parked until the request is answered.
const hasPendingPermission = useSessionStore((state) => {
const pendingPermissions = state.sessions[serverId]?.pendingPermissions;
if (!pendingPermissions) return false;
for (const permission of pendingPermissions.values()) {
if (permission.agentId === agentId) return true;
}
return false;
});
const activeSendBehavior = resolveActiveSendBehavior(
appSettings.sendBehavior,
hasPendingPermission,
);
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;
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
},
[
agentId,
clearSentAttachments,
queueWriter,
resetSuppression,
setSelectedAttachments,
replaceUserInput,
],
);
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 }) => {
if (submitBehavior !== "preserve-and-lock") {
beginSubmit(submitAttachments);
}
await submitMessage(submitText, submitAttachments);
},
clearDraft,
setUserInput: replaceUserInput,
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,
beginSubmit,
clearDraft,
completeSubmit,
hasExternalContent,
isAgentRunning,
queueMessage,
setSelectedAttachments,
replaceUserInput,
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;
}
const pluginSlashCommand = resolvePluginClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
commands: pluginClientSlashCommands,
});
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend);
},
[
attachments,
blurOnSubmit,
buildOutgoingAttachments,
runClientSlashCommand,
pluginClientSlashCommands,
runPluginClientSlashCommand,
sendMessageWithContent,
],
);
const handlePickImage = useCallback(async () => {
const newImages = await pickAndPersistImages({
pickImages,
persister: composerImageAttachmentPersister,
});
if (newImages.length === 0) return;
addImages(newImages);
}, [addImages, pickImages]);
const handlePasteImage = useCallback(async () => {
try {
const newImages = await pickAndPersistImages({
pickImages: async () => {
const image = await readClipboardImage(Clipboard);
return image ? [image] : null;
},
persister: composerImageAttachmentPersister,
});
if (newImages.length === 0) {
toastErrorRef.current(t("composer.errors.noClipboardImage"));
return;
}
addImages(newImages);
} catch (error) {
console.error("[Composer] Failed to paste clipboard image:", error);
toastErrorRef.current(t("composer.errors.pasteImageFailed"));
}
}, [addImages, t]);
const handleNativePasteImages = useCallback(
(files: readonly NativePastedFile[]) => {
setPendingNativeImagePastes((pending) => pending + 1);
void pickAndPersistImages({
pickImages: async () => normalizeNativePastedImages(files),
persister: composerImageAttachmentPersister,
})
.then((newImages) => {
if (newImages.length > 0) {
addImages(newImages);
}
return undefined;
})
.catch((error) => {
console.error("[Composer] Failed to persist pasted image:", error);
toastErrorRef.current(t("composer.errors.pasteImageFailed"));
})
.finally(() => {
setPendingNativeImagePastes((pending) => Math.max(0, pending - 1));
});
},
[addImages, t],
);
const uploadSelectedFiles = useCallback(
async (files: SelectedFile[]) => {
if (files.length === 0) return;
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
const placeholders = files.map((file) => ({ id: nextPendingFileId.current++, file }));
setPendingFiles((pending) => [...pending, ...placeholders]);
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 {
setPendingFiles((pending) => pending.filter((entry) => !placeholders.includes(entry)));
}
},
[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 uploadSelectedFiles(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, uploadSelectedFiles]);
const handleGenericFilesDropped = useCallback(
async (items: DroppedItem[]) => {
try {
const files = droppedItemsToSelectedFiles(items);
if (files.length === 0) return;
if (!client || !isConnected) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
await uploadSelectedFiles(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, uploadSelectedFiles],
);
const handleRemoveAttachment = useCallback(
(index: number) => {
forgeAutoAttachRef.current?.markForgeAttachmentRemoved(selectedAttachments[index]);
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
});
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) =>
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment({
attachment,
setLightboxMetadata,
openWorkspaceAttachment: openAttachment,
openExternalUrl: (url) => {
void openExternalUrl(url);
},
});
},
[openAttachment],
);
const handleCancelAgent = useCallback(() => {
const targetAgentId = agentIdRef.current;
const cancellation = cancelComposerAgent({
client,
agentId: targetAgentId,
isAgentRunning,
isCancellingAgent,
isConnected,
});
if (!cancellation) return;
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
void cancellation
.catch((error) => {
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
})
.finally(() => {
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
});
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
const focusMessageInputForKeyboardAction = useCallback(() => {
focusMessageInputWithPlatformStrategy(messageInputRef);
}, []);
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;
replaceUserInput(result.text);
setSelectedAttachments(result.attachments);
},
[agentId, queueWriter, replaceUserInput, setSelectedAttachments],
);
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;
}
const pluginSlashCommand = resolvePluginClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
commands: pluginClientSlashCommands,
});
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
queueMessage(payload.text, outgoingAttachments);
},
[
attachments,
buildOutgoingAttachments,
pluginClientSlashCommands,
queueMessage,
runClientSlashCommand,
runPluginClientSlashCommand,
],
);
const hasSendableContent = hasText || selectedAttachments.length > 0;
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => {
if (autocompleteRef.current?.onKeyPress(event)) return true;
const recalledText = recall.handleKeyPress(event, () =>
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
);
if (recalledText === null) return false;
event.preventDefault();
replaceUserInput(recalledText);
return true;
},
[agentId, recall, replaceUserInput, serverId],
);
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 activeActionContent = useMemo(
() => (
<ComposerCancelButton
buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent}
isConnected={isConnected}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
/>
),
[
agentInterruptKeys,
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
isCancellingAgent,
isConnected,
t,
],
);
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
isVoiceModeForAgent={isVoiceModeForAgent}
hasAgent={hasAgent}
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isCompact={isCompactLayout}
showVoice={mode.showVoice}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
/>
),
[
buttonIconSize,
handleToggleRealtimeVoice,
hasAgent,
hasSendableContent,
isAgentRunning,
isConnected,
isCompactLayout,
isVoiceModeForAgent,
isVoiceSwitching,
mode.showVoice,
realtimeVoiceButtonStyle,
t,
voiceToggleKeys,
],
);
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const beforeVoiceContent = useMemo(
() =>
hasAgent ? (
<View style={styles.contextWindowMeterSlot}>
<ContextWindowMeter
serverId={serverId}
agentId={agentId}
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
glyphSize={contextWindowMeterGlyphSize}
/>
</View>
) : null,
[
hasAgent,
serverId,
agentId,
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
contextWindowMeterGlyphSize,
],
);
const hasGithubAttachment = useMemo(
() =>
selectedAttachments.some(
(attachment) =>
attachment.kind === "forge_change_request" ||
attachment.kind === "forge_issue" ||
attachment.kind === "github_pr" ||
attachment.kind === "github_issue",
),
[selectedAttachments],
);
// Composer stays mounted for each focused agent, so avoid a forge CLI call
// until the forge-specific picker or attachment presentation is visible.
const { forge } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
});
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useForgeSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
const githubSearchItemsRaw = githubSearchResultsQuery.data?.items;
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => {
const presentation = getForgePresentation(item.forge ?? "github");
const numberPrefix =
item.kind === "change_request"
? presentation.numberPrefix
: presentation.issueNumberPrefix;
return {
id: `${item.kind}:${item.number}`,
label: `${numberPrefix}${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
};
}),
[githubSearchItems, githubSearchQueryTrimmed],
);
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(() => {
const items: AttachmentMenuItem[] = [
{
id: "image",
label: t("composer.attachments.addImage"),
icon: <ThemedImageIcon size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
},
},
];
if (isNative) {
items.push({
id: "paste-image",
label: t("composer.attachments.pasteImage"),
icon: <ThemedClipboardPaste size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePasteImage();
},
});
}
items.push(
{
id: "github",
label: t("composer.attachments.addIssueOrPr", {
context: forgePresentation.changeRequestContext,
}),
icon: renderForgeAttachmentIcon(forgePresentation.icon),
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
...pluginAttachments.menuItems,
{
id: "file",
label: t("composer.attachments.addFile"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickFile();
},
},
);
return items;
}, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
const nextAttachments = toggleForgeAttachmentFromPicker({
current: attachments,
item,
markForgeAttachmentRemoved: (attachment) =>
forgeAutoAttachRef.current?.markForgeAttachmentRemoved(attachment),
});
setSelectedAttachments(nextAttachments);
setIsGithubPickerOpen(false);
setGithubSearchQuery("");
},
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent = useMemo(
() =>
renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
showAgentControls: mode.showAgentControls,
}),
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
attachButtonRef.current = node;
}, []);
const handleSelectionChange = useCallback(
(selection: { start: number; end: number }) => {
if (isWeb) {
cursorPublication.stage(selection.start);
} else {
cursor.setState(selection.start);
}
},
[cursorPublication, cursor],
);
const handleFocusChange = useCallback(
(focused: boolean) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
},
[onAttentionInputFocus],
);
const handleLightboxClose = useCallback(() => {
setLightboxMetadata(null);
}, []);
const lightboxSource = useMemo<ImageLightboxSource | null>(
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
[lightboxMetadata],
);
const handleGithubPickerOpenChange = useCallback(
(open: boolean) => {
setIsGithubPickerOpen(open);
if (!open) {
setGithubSearchQuery("");
}
},
[setGithubSearchQuery],
);
const renderGithubPickerOption = useCallback(
({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => {
const item = findForgeItemByOption(githubSearchItems, option.id);
if (!item) {
return <View key={option.id} />;
}
const selected = isAttachmentSelectedForForgeItem(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],
);
// Fullscreen fills the dock slot: the chain down to the input stretches and drops
// the inline padding, which the fullscreen presentation supplies itself.
const inputAreaContainerStyle = useMemo(
() => [
styles.inputAreaContainer,
isComposerLocked && styles.inputAreaLocked,
isFullscreen && styles.inputAreaFullscreen,
],
[isComposerLocked, isFullscreen],
);
const inputAreaContentStyle = useMemo(
() => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill],
[isFullscreen],
);
const messageInputContainerStyle = useMemo(
() => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill],
[isFullscreen],
);
const attachmentTray = useMemo(
() =>
renderAttachmentTray({
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels: {
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.openGithub", { kind, number: numberLabel }),
removeGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.removeGithub", { kind, number: numberLabel }),
},
}),
[
handleOpenAttachment,
handleRemoveAttachment,
isComposerLocked,
serverId,
selectedAttachments,
pendingFiles,
t,
],
);
const queueList = useMemo(
() =>
renderQueueTrack({
queuedMessages,
handleEditQueuedMessage,
handleSendQueuedNow,
editLabel: t("composer.attachments.editQueuedMessage"),
sendNowLabel: t("composer.attachments.sendQueuedMessageNow"),
}),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t],
);
const autocompleteConfiguration = useMemo(
() => ({
setUserInput: replaceUserInput,
serverId,
agentId,
draft: commandDraft,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
pluginClientSlashCommands,
}),
[
replaceUserInput,
serverId,
agentId,
commandDraft,
buildOutgoingAttachments,
attachments,
runClientSlashCommand,
pluginClientSlashCommands,
],
);
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitDisabled =
isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && isForgeResolving);
// 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,
onWorkspaceFile: handleWorkspaceFileDropped,
},
{ disabled: isSubmitLoadingVisible },
);
// Focusing the composer on a touch screen raises the on-screen keyboard over the conversation.
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
() =>
sendError ? (
<Text accessibilityRole="alert" style={styles.sendErrorText}>
{sendError}
</Text>
) : null,
[sendError],
);
const githubEmptyText = githubSearchResultsQuery.isFetching
? t("composer.github.searching")
: t("composer.github.noResults");
return (
<>
<ComposerKeyboardRegistration
handlerId={keyboardHandlerIdRef.current}
messageInputRef={messageInputRef}
isAgentRunning={isAgentRunning}
isCancellingAgent={isCancellingAgent}
isConnected={isConnected}
handleCancelAgent={handleCancelAgent}
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<View
style={
isFullscreen ? animatedStaticStyles.fullscreenContainer : animatedStaticStyles.container
}
>
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={inputAreaContentStyle}>
{isFullscreen ? null : queueList}
{!isFullscreen && sendErrorNode}
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
recall={recall}
inputRef={messageInputRef}
anchorStyle={messageInputContainerStyle}
show={mode.showAutocomplete && !isFullscreen}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
>
<ComposerForgeBinding
text={textSource}
configuration={forgeConfiguration}
ref={forgeAutoAttachRef}
onResolvingChange={setIsForgeResolving}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
editingSession={editingSession}
submissionError={sendErrorNode}
value={textSource.getSnapshot()}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
sendShortcut={appSettings.sendShortcut}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacement={textReplacement}
submitLabel={submitLabel}
/>
</RenderProfile>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder", {
context: forgePresentation.changeRequestContext,
})}
title={t("composer.github.title", {
context: forgePresentation.changeRequestContext,
})}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</ComposerAutocompleteBinding>
</View>
</View>
</View>
</>
);
}
const animatedStaticStyles = RNStyleSheet.create({
container: {
flexDirection: "column",
// Propagate the viewport's height constraint down to the scrolling input.
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flexDirection: "column",
flex: 1,
minHeight: 0,
position: "relative",
},
});
const styles = StyleSheet.create((theme: Theme) => ({
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
},
inputAreaContainer: {
flexShrink: 1,
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,
},
inputAreaFullscreen: {
flex: 1,
minHeight: 0,
alignItems: "stretch",
paddingHorizontal: 0,
paddingBottom: 0,
},
fullscreenFill: {
flex: 1,
minHeight: 0,
maxWidth: "100%",
},
inputAreaContent: {
flexShrink: 1,
width: "100%",
maxWidth: theme.contentMaxWidth,
gap: theme.spacing[3],
},
messageInputContainer: {
flexShrink: 1,
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,
flexShrink: 0,
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.base,
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.base,
},
})) as unknown as Record<string, object>;
const ThemedAttachmentSpinner = withUnistyles(LoadingSpinner);
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 ThemedClipboardPaste = withUnistyles(ClipboardPaste);
const ThemedFileText = withUnistyles(FileText);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
function renderForgeAttachmentIcon(icon: string): ReactElement {
return (
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
);
}
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} />;
const pendingFilePillIcon = (
<ThemedAttachmentSpinner size={18} uniProps={iconForegroundMutedMapping} />
);