Attach dropped files in every composer (#1750)
* fix(app): attach dropped files in composer Composer parents had to expose attachment callbacks, so New Workspace only wired image drops and JSON files never became file attachments. Centralize dropped-file ingestion in Composer so all composer surfaces share the same image and file path. * fix(app): handle desktop dropped attachment paths
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { droppedItemsToPickedFiles } from "./drop";
|
||||
|
||||
describe("composer dropped attachments", () => {
|
||||
it("turns non-image browser files into picked files and leaves raster images for image handling", async () => {
|
||||
const jsonFile = new File([JSON.stringify({ ok: true })], "config.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const imageFile = new File([new Uint8Array([0])], "screen.png", { type: "image/png" });
|
||||
|
||||
const files = await droppedItemsToPickedFiles([
|
||||
{ kind: "web-file", file: jsonFile },
|
||||
{ kind: "web-file", file: imageFile },
|
||||
]);
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
fileName: "config.json",
|
||||
mimeType: "application/json",
|
||||
bytes: new Uint8Array(await jsonFile.arrayBuffer()),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns non-image desktop paths into picked files and leaves raster images for image handling", async () => {
|
||||
const windowsPath = "C:\\Users\\alice\\config.json";
|
||||
const posixPath = "/Users/alice/notes/readme.txt";
|
||||
const imagePath = "C:\\Users\\alice\\screen.png";
|
||||
const bytesByPath = new Map([
|
||||
[windowsPath, new Uint8Array([1, 2, 3])],
|
||||
[posixPath, new Uint8Array([4, 5])],
|
||||
]);
|
||||
const readPaths: string[] = [];
|
||||
|
||||
const files = await droppedItemsToPickedFiles(
|
||||
[
|
||||
{ kind: "desktop-path", path: windowsPath },
|
||||
{ kind: "desktop-path", path: imagePath },
|
||||
{ kind: "desktop-path", path: posixPath },
|
||||
],
|
||||
{
|
||||
readDesktopFileBytes: async (path) => {
|
||||
readPaths.push(path);
|
||||
const bytes = bytesByPath.get(path);
|
||||
if (!bytes) {
|
||||
throw new Error(`Unexpected desktop read: ${path}`);
|
||||
}
|
||||
return bytes;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(readPaths).toEqual([windowsPath, posixPath]);
|
||||
expect(files).toEqual([
|
||||
{
|
||||
fileName: "config.json",
|
||||
mimeType: "application/octet-stream",
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
},
|
||||
{
|
||||
fileName: "readme.txt",
|
||||
mimeType: "application/octet-stream",
|
||||
bytes: new Uint8Array([4, 5]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
getMimeTypeFromPath,
|
||||
isRasterImageFile,
|
||||
isRasterImagePath,
|
||||
} from "@/attachments/file-types";
|
||||
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
|
||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
|
||||
interface DroppedAttachmentsRuntime {
|
||||
readDesktopFileBytes(path: string): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
const defaultRuntime: DroppedAttachmentsRuntime = {
|
||||
readDesktopFileBytes,
|
||||
};
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const segments = path.split(/[/\\]/);
|
||||
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||
const segment = segments[index];
|
||||
if (segment) {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function droppedItemsToPickedFiles(
|
||||
items: DroppedItem[],
|
||||
runtime: DroppedAttachmentsRuntime = defaultRuntime,
|
||||
): Promise<PickedFile[]> {
|
||||
const files: PickedFile[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === "web-file") {
|
||||
if (isRasterImageFile(item.file)) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
fileName: item.file.name,
|
||||
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
|
||||
bytes: new Uint8Array(await item.file.arrayBuffer()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRasterImagePath(item.path)) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
fileName: fileNameFromPath(item.path),
|
||||
mimeType: getMimeTypeFromPath(item.path),
|
||||
bytes: await runtime.readDesktopFileBytes(item.path),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
|
||||
@@ -332,7 +330,6 @@ export function WorkspaceDraftAgentTab({
|
||||
initialSetup: draftSetup,
|
||||
});
|
||||
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const draftStoreKey = useMemo(
|
||||
() =>
|
||||
buildDraftStoreKey({
|
||||
@@ -540,14 +537,6 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
|
||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const focusInputRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const handleFocusInputCallback = useCallback((focus: () => void) => {
|
||||
@@ -655,71 +644,65 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
agent={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
agent={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.configScrollContent}
|
||||
>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.setText}
|
||||
attachments={draftInput.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.setText}
|
||||
attachments={draftInput.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+147
-111
@@ -43,6 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -99,7 +101,9 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
|
||||
@@ -762,10 +766,6 @@ interface ComposerProps {
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
/** When true, auto-focuses the text input on web. */
|
||||
autoFocus?: boolean;
|
||||
/** Callback to expose the addImages function to parent components */
|
||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
/** Callback to expose the addFiles function to parent components */
|
||||
onAddFiles?: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
@@ -977,8 +977,6 @@ export function Composer({
|
||||
cwd,
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
onAddImages,
|
||||
onAddFiles,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
@@ -1139,7 +1137,6 @@ export function Composer({
|
||||
>(null);
|
||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||
|
||||
// Expose addImages function to parent for drag-and-drop support
|
||||
const addImages = useCallback(
|
||||
(images: ImageAttachment[]) => {
|
||||
setSelectedAttachments((prev) => [
|
||||
@@ -1150,11 +1147,6 @@ export function Composer({
|
||||
[setSelectedAttachments],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onAddImages?.(addImages);
|
||||
}, [addImages, onAddImages]);
|
||||
|
||||
// Expose addFiles function to parent for drag-and-drop support
|
||||
const addFiles = useCallback(
|
||||
(files: UserComposerAttachment[]) => {
|
||||
setSelectedAttachments((prev) => [...prev, ...files]);
|
||||
@@ -1162,12 +1154,6 @@ export function Composer({
|
||||
[setSelectedAttachments],
|
||||
);
|
||||
|
||||
/* oxlint-disable react-hooks/exhaustive-deps */
|
||||
useEffect(() => {
|
||||
onAddFiles?.(addFiles);
|
||||
}, [addFiles, onAddFiles]);
|
||||
/* oxlint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (isNative) return;
|
||||
focusWithRetries({
|
||||
@@ -1363,14 +1349,13 @@ export function Composer({
|
||||
addImages(newImages);
|
||||
}, [addImages, pickImages]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
if (!client) {
|
||||
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files = await pickFiles();
|
||||
if (!files || files.length === 0) return;
|
||||
const uploadPickedFiles = useCallback(
|
||||
async (files: PickedFile[]) => {
|
||||
if (files.length === 0) return;
|
||||
if (!client) {
|
||||
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
|
||||
if (oversized) {
|
||||
@@ -1381,17 +1366,57 @@ export function Composer({
|
||||
}
|
||||
|
||||
setIsUploadingFile(true);
|
||||
const uploaded = await uploadFileAttachments({ client, files });
|
||||
addFiles(uploaded);
|
||||
try {
|
||||
const uploaded = await uploadFileAttachments({ client, files });
|
||||
addFiles(uploaded);
|
||||
} catch (error) {
|
||||
console.error("[Composer] Failed to upload file:", error);
|
||||
toastErrorRef.current(
|
||||
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingFile(false);
|
||||
}
|
||||
},
|
||||
[addFiles, client, t],
|
||||
);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
if (!client) {
|
||||
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files = await pickFiles();
|
||||
if (!files) return;
|
||||
await uploadPickedFiles(files);
|
||||
} catch (error) {
|
||||
console.error("[Composer] Failed to upload file:", error);
|
||||
toastErrorRef.current(
|
||||
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingFile(false);
|
||||
}
|
||||
}, [client, pickFiles, addFiles, t]);
|
||||
}, [client, pickFiles, t, uploadPickedFiles]);
|
||||
|
||||
const handleGenericFilesDropped = useCallback(
|
||||
async (items: DroppedItem[]) => {
|
||||
try {
|
||||
const files = await droppedItemsToPickedFiles(items);
|
||||
if (files.length === 0) return;
|
||||
if (!client || !isConnected) {
|
||||
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
|
||||
return;
|
||||
}
|
||||
await uploadPickedFiles(files);
|
||||
} catch (error) {
|
||||
console.error("[Composer] Failed to upload dropped files:", error);
|
||||
toastErrorRef.current(
|
||||
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
|
||||
);
|
||||
}
|
||||
},
|
||||
[client, isConnected, t, uploadPickedFiles],
|
||||
);
|
||||
|
||||
const handleRemoveAttachment = useCallback(
|
||||
(index: number) => {
|
||||
@@ -1869,90 +1894,97 @@ export function Composer({
|
||||
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<FileDropZone
|
||||
onFilesDropped={addImages}
|
||||
onGenericFilesDropped={handleGenericFilesDropped}
|
||||
disabled={isSubmitBusy}
|
||||
style={styles.dropZone}
|
||||
>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</FileDropZone>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1962,6 +1994,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
},
|
||||
dropZone: {
|
||||
flex: 0,
|
||||
width: "100%",
|
||||
},
|
||||
borderSeparator: {
|
||||
height: theme.borderWidth[1],
|
||||
backgroundColor: theme.colors.border,
|
||||
|
||||
Reference in New Issue
Block a user