mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
* feat(composer): insert suggested commands into drafts * fix(composer): close suggestions after command insertion * fix(composer): place caret after inserted commands
2729 lines
86 KiB
TypeScript
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} />
|
|
);
|