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:
Mohamed Boudra
2026-06-27 09:03:04 +08:00
committed by GitHub
parent f275275074
commit 65f1143e6a
12 changed files with 488 additions and 388 deletions
@@ -16,6 +16,7 @@ import {
expectComposerEditable, expectComposerEditable,
expectAttachButtonDisabled, expectAttachButtonDisabled,
fillComposerDraft, fillComposerDraft,
dropFileOnComposer,
sendDraftToQueue, sendDraftToQueue,
expectQueuedMessageButton, expectQueuedMessageButton,
startRunningMockAgent, startRunningMockAgent,
@@ -36,6 +37,11 @@ const MINIMAL_PNG = Buffer.from(
); );
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG }; const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
const TEST_JSON = {
name: "config.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({ composer: "drop" })),
};
test.describe("Composer attachments", () => { test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => { test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
@@ -172,6 +178,47 @@ test.describe("Composer attachments", () => {
await expectAttachmentPill(page, "composer-image-attachment-pill"); await expectAttachmentPill(page, "composer-image-attachment-pill");
}); });
test("dropped JSON file renders as a file attachment in active chat", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "attach-drop-new-workspace-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
} finally {
await workspace.cleanup();
}
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => { test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" }); const workspace = await withWorkspace({ prefix: "attach-remove-" });
+27
View File
@@ -95,6 +95,33 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise<
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
} }
export async function dropFileOnComposer(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const dataTransfer = await page.evaluateHandle(
({ name, mimeType, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const droppedFile = new File([bytes], name, { type: mimeType });
const transfer = new DataTransfer();
transfer.items.add(droppedFile);
return transfer;
},
{
name: file.name,
mimeType: file.mimeType,
base64: file.buffer.toString("base64"),
},
);
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
await composerRoot.dispatchEvent("dragover", { dataTransfer });
await composerRoot.dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */ /** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill( export async function removeAttachmentPill(
page: Page, page: Page,
@@ -0,0 +1,28 @@
import { getFileExtension } from "@/attachments/file-types";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
export interface PickedFile {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: path,
extension: getFileExtension(path) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
return base64ToUint8Array(base64);
}
@@ -1,4 +1,5 @@
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -13,6 +14,7 @@ interface FileDropZoneProps {
onFilesDropped: (files: ImageAttachment[]) => void; onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void; onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean; disabled?: boolean;
style?: StyleProp<ViewStyle>;
} }
const IS_WEB = isWeb; const IS_WEB = isWeb;
@@ -22,6 +24,7 @@ export function FileDropZone({
onFilesDropped, onFilesDropped,
onGenericFilesDropped, onGenericFilesDropped,
disabled = false, disabled = false,
style,
}: FileDropZoneProps) { }: FileDropZoneProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -46,6 +49,7 @@ export function FileDropZone({
() => [styles.overlay, overlayAnimatedStyle], () => [styles.overlay, overlayAnimatedStyle],
[overlayAnimatedStyle], [overlayAnimatedStyle],
); );
const containerStyle = useMemo(() => [styles.container, style], [style]);
// On non-web platforms, just render children // On non-web platforms, just render children
if (!IS_WEB) { if (!IS_WEB) {
@@ -56,7 +60,7 @@ export function FileDropZone({
<View <View
// Cast ref for web - View renders as div on web // Cast ref for web - View renders as div on web
ref={containerRef as unknown as React.RefObject<View>} ref={containerRef as unknown as React.RefObject<View>}
style={styles.container} style={containerStyle}
> >
{children} {children}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Image, Text, View } from "react-native"; import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -26,7 +26,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -363,14 +363,6 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo( const agentControlsWithDisabled = useMemo(
@@ -427,7 +419,6 @@ export function WorkspaceSetupDialog() {
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
testID="workspace-setup-dialog" testID="workspace-setup-dialog"
desktopMaxWidth={640} desktopMaxWidth={640}
onFilesDropped={handleFilesDropped}
> >
<View style={styles.section}> <View style={styles.section}>
<Composer <Composer
@@ -447,7 +438,6 @@ export function WorkspaceSetupDialog() {
commandDraftConfig={composerState?.commandDraftConfig} commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled} agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper} inputWrapperStyle={styles.composerInputWrapper}
onAddImages={handleAddImagesCallback}
footer={composerFooter} footer={composerFooter}
/> />
</View> </View>
@@ -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 { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill"; import { ComposerImportPill } from "@/composer/draft/import-pill";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/agent-stream/view"; import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import type { ImageAttachment } from "@/composer/types";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
@@ -332,7 +330,6 @@ export function WorkspaceDraftAgentTab({
initialSetup: draftSetup, initialSetup: draftSetup,
}); });
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId }); const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo( const draftStoreKey = useMemo(
() => () =>
buildDraftStoreKey({ buildDraftStoreKey({
@@ -540,14 +537,6 @@ export function WorkspaceDraftAgentTab({
workspaceId, 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 focusInputRef = useRef<(() => void) | null>(null);
const handleFocusInputCallback = useCallback((focus: () => void) => { const handleFocusInputCallback = useCallback((focus: () => void) => {
@@ -655,71 +644,65 @@ export function WorkspaceDraftAgentTab({
); );
return ( return (
<FileDropZone onFilesDropped={handleFilesDropped}> <View style={styles.container}>
<View style={styles.container}> <View style={styles.contentContainer}>
<View style={styles.contentContainer}> {isSubmitting && draftAgent ? (
{isSubmitting && draftAgent ? ( <View style={styles.streamContainer}>
<View style={styles.streamContainer}> <AgentStreamView
<AgentStreamView agentId={tabId}
agentId={tabId} serverId={serverId}
serverId={serverId} agent={draftAgent}
agent={draftAgent} streamItems={optimisticStreamItems}
streamItems={optimisticStreamItems} pendingPermissions={EMPTY_PENDING_PERMISSIONS}
pendingPermissions={EMPTY_PENDING_PERMISSIONS} onOpenWorkspaceFile={onOpenWorkspaceFile}
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> </View>
) : ( </ScrollView>
<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>
</View> </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
View File
@@ -43,6 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker"; 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 { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types"; import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -99,7 +101,9 @@ import type {
UserComposerAttachment, UserComposerAttachment,
WorkspaceComposerAttachment, WorkspaceComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types"; import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill"; import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
@@ -762,10 +766,6 @@ interface ComposerProps {
clearDraft: (lifecycle: "sent" | "abandoned") => void; clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */ /** When true, auto-focuses the text input on web. */
autoFocus?: boolean; 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). */ /** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void; onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */ /** Optional draft context for listing commands before an agent exists. */
@@ -977,8 +977,6 @@ export function Composer({
cwd, cwd,
clearDraft, clearDraft,
autoFocus = false, autoFocus = false,
onAddImages,
onAddFiles,
onFocusInput, onFocusInput,
commandDraftConfig, commandDraftConfig,
onMessageSent, onMessageSent,
@@ -1139,7 +1137,6 @@ export function Composer({
>(null); >(null);
const onSubmitMessageRef = useRef(onSubmitMessage); const onSubmitMessageRef = useRef(onSubmitMessage);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback( const addImages = useCallback(
(images: ImageAttachment[]) => { (images: ImageAttachment[]) => {
setSelectedAttachments((prev) => [ setSelectedAttachments((prev) => [
@@ -1150,11 +1147,6 @@ export function Composer({
[setSelectedAttachments], [setSelectedAttachments],
); );
useEffect(() => {
onAddImages?.(addImages);
}, [addImages, onAddImages]);
// Expose addFiles function to parent for drag-and-drop support
const addFiles = useCallback( const addFiles = useCallback(
(files: UserComposerAttachment[]) => { (files: UserComposerAttachment[]) => {
setSelectedAttachments((prev) => [...prev, ...files]); setSelectedAttachments((prev) => [...prev, ...files]);
@@ -1162,12 +1154,6 @@ export function Composer({
[setSelectedAttachments], [setSelectedAttachments],
); );
/* oxlint-disable react-hooks/exhaustive-deps */
useEffect(() => {
onAddFiles?.(addFiles);
}, [addFiles, onAddFiles]);
/* oxlint-enable react-hooks/exhaustive-deps */
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
if (isNative) return; if (isNative) return;
focusWithRetries({ focusWithRetries({
@@ -1363,14 +1349,13 @@ export function Composer({
addImages(newImages); addImages(newImages);
}, [addImages, pickImages]); }, [addImages, pickImages]);
const handlePickFile = useCallback(async () => { const uploadPickedFiles = useCallback(
if (!client) { async (files: PickedFile[]) => {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected")); if (files.length === 0) return;
return; if (!client) {
} toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
try { return;
const files = await pickFiles(); }
if (!files || files.length === 0) return;
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES); const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
if (oversized) { if (oversized) {
@@ -1381,17 +1366,57 @@ export function Composer({
} }
setIsUploadingFile(true); setIsUploadingFile(true);
const uploaded = await uploadFileAttachments({ client, files }); try {
addFiles(uploaded); 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) { } catch (error) {
console.error("[Composer] Failed to upload file:", error); console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current( toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"), 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( const handleRemoveAttachment = useCallback(
(index: number) => { (index: number) => {
@@ -1869,90 +1894,97 @@ export function Composer({
return ( return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}> <ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<Animated.View style={composerContainerStyle}> <FileDropZone
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} /> onFilesDropped={addImages}
{/* Input area */} onGenericFilesDropped={handleGenericFilesDropped}
<View style={inputAreaContainerStyle}> disabled={isSubmitBusy}
<View style={styles.inputAreaContent}> style={styles.dropZone}
{queueList} >
{sendErrorNode} <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}> <View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover <AutocompletePopover
visible={autocompleteVisible} visible={autocompleteVisible}
anchorRef={messageInputContainerRef} anchorRef={messageInputContainerRef}
options={autocomplete.options} options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex} selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption} onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading} isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage} errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText} loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText} emptyText={autocomplete.emptyText}
/> />
{/* MessageInput handles everything: text, dictation, attachments, all buttons */} {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput <StableMessageInput
ref={messageInputRef} ref={messageInputRef}
value={userInput} value={userInput}
onChangeText={setUserInput} onChangeText={setUserInput}
onSubmit={handleSubmit} onSubmit={handleSubmit}
hasExternalContent={hasExternalContent} hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit} allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID} submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon} submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy} isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy} isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"} preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments} attachments={selectedAttachments}
cwd={cwd} cwd={cwd}
attachmentMenuItems={attachmentMenuItems} attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef} onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages} onAddImages={addImages}
client={client} client={client}
isReadyForDictation={isDictationReady} isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder} placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus} autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`} autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading} disabled={isSubmitLoading}
isPaneFocused={isPaneFocused} isPaneFocused={isPaneFocused}
leftContent={leftContent} leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent} beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent} rightContent={rightContent}
voiceServerId={serverId} voiceServerId={serverId}
voiceAgentId={agentId} voiceAgentId={agentId}
isAgentRunning={isAgentRunning} isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior} defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue} onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler} onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress} onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange} onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange} onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle} inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray} attachmentSlot={attachmentTray}
/> />
<Combobox <Combobox
options={githubSearchOptions} options={githubSearchOptions}
value="" value=""
onSelect={noop} onSelect={noop}
keepOpenOnSelect keepOpenOnSelect
searchable searchable
searchPlaceholder={t("composer.github.searchPlaceholder")} searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")} title={t("composer.github.title")}
open={isGithubPickerOpen} open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange} onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery} onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start" desktopPlacement="top-start"
anchorRef={attachButtonRef} anchorRef={attachButtonRef}
emptyText={githubEmptyText} emptyText={githubEmptyText}
renderOption={renderGithubPickerOption} renderOption={renderGithubPickerOption}
/> />
</View>
</View> </View>
</View> </View>
</View> {renderComposerFooter(footer, footerInlineContent)}
{renderComposerFooter(footer, footerInlineContent)} </Animated.View>
</Animated.View> </FileDropZone>
</ComposerKeyboardScopeProvider> </ComposerKeyboardScopeProvider>
); );
} }
@@ -1962,6 +1994,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "column", flexDirection: "column",
position: "relative", position: "relative",
}, },
dropZone: {
flex: 0,
width: "100%",
},
borderSeparator: { borderSeparator: {
height: theme.borderWidth[1], height: theme.borderWidth[1],
backgroundColor: theme.colors.border, backgroundColor: theme.colors.border,
+3 -38
View File
@@ -2,35 +2,9 @@ import { useCallback, useRef } from "react";
import * as DocumentPicker from "expo-document-picker"; import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system"; import { File } from "expo-file-system";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { getFileExtension, getMimeTypeFromPath } from "@/attachments/file-types"; import { getMimeTypeFromPath } from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
export interface PickedFile {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: path,
extension: getFileExtension(path) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
return base64ToUint8Array(base64);
}
async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> { async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
const dialog = getDesktopHost()?.dialog; const dialog = getDesktopHost()?.dialog;
@@ -58,16 +32,7 @@ async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
for (const filePath of paths) { for (const filePath of paths) {
const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath; const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath;
const mimeType = getMimeTypeFromPath(filePath); const mimeType = getMimeTypeFromPath(filePath);
const bytes = await readDesktopFileBytes(filePath);
// Copy into managed storage so we can read it through the existing secure IPC.
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: filePath,
extension: getFileExtension(filePath) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
const bytes = base64ToUint8Array(base64);
result.push({ fileName, mimeType, bytes }); result.push({ fileName, mimeType, bytes });
} }
+10 -103
View File
@@ -14,18 +14,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { FileDropZone } from "@/components/file-drop-zone";
import { uploadFileAttachments } from "@/composer/actions";
import {
getMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes } from "@/hooks/use-file-picker";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
import type { UserComposerAttachment } from "@/attachments/types";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import type { ImageAttachment } from "@/composer/types";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { import {
ToastViewport, ToastViewport,
@@ -706,8 +695,6 @@ function ChatAgentContent({
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null); const streamViewRef = useRef<AgentStreamViewHandle>(null);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const addFilesRef = useRef<((files: UserComposerAttachment[]) => void) | null>(null);
const clearOnAgentBlurRef = useRef<() => void>(() => {}); const clearOnAgentBlurRef = useRef<() => void>(() => {});
const wasPaneFocusedRef = useRef(isPaneFocused); const wasPaneFocusedRef = useRef(isPaneFocused);
const reconnectToastArmedRef = useRef(false); const reconnectToastArmedRef = useRef(false);
@@ -716,54 +703,6 @@ function ChatAgentContent({
routeKey: string; routeKey: string;
reason: "initial-entry" | "resume"; reason: "initial-entry" | "resume";
} | null>(null); } | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const handleAddFilesCallback = useCallback(
(addFiles: (files: UserComposerAttachment[]) => void) => {
addFilesRef.current = addFiles;
},
[],
);
const handleGenericFilesDropped = useCallback(
async (items: DroppedItem[]) => {
if (!client || !isConnected) return;
const nonImageItems = items.filter((item) => {
if (item.kind === "web-file") return !isRasterImageFile(item.file);
return !isRasterImagePath(item.path);
});
if (nonImageItems.length === 0) return;
try {
const files = await Promise.all(
nonImageItems.map(async (item) => {
if (item.kind === "web-file") {
return {
fileName: item.file.name,
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
bytes: new Uint8Array(await item.file.arrayBuffer()),
};
}
const fileName = item.path.split("/").pop() ?? item.path.split("\\").pop() ?? item.path;
const bytes = await readDesktopFileBytes(item.path);
return { fileName, mimeType: getMimeTypeFromPath(item.path), bytes };
}),
);
const uploaded = await uploadFileAttachments({ client, files });
addFilesRef.current?.(uploaded);
} catch (error) {
console.error("[AgentPanel] Failed to upload dropped files:", error);
toastApi.error(error instanceof Error ? error.message : "Failed to upload file");
}
},
[client, isConnected, toastApi],
);
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)), useShallow((state) => selectChatAgentState(state, serverId, agentId)),
); );
@@ -1121,10 +1060,6 @@ function ChatAgentContent({
dismiss={dismissToast} dismiss={dismissToast}
streamViewRef={streamViewRef} streamViewRef={streamViewRef}
animatedContentStyle={animatedContentStyle} animatedContentStyle={animatedContentStyle}
handleFilesDropped={handleFilesDropped}
handleGenericFilesDropped={handleGenericFilesDropped}
handleAddImagesCallback={handleAddImagesCallback}
handleAddFilesCallback={handleAddFilesCallback}
handleComposerHeightChange={handleComposerHeightChange} handleComposerHeightChange={handleComposerHeightChange}
handleMessageSent={handleMessageSent} handleMessageSent={handleMessageSent}
showHistorySyncOverlay={showHistorySyncOverlay} showHistorySyncOverlay={showHistorySyncOverlay}
@@ -1150,10 +1085,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
dismiss, dismiss,
streamViewRef, streamViewRef,
animatedContentStyle, animatedContentStyle,
handleFilesDropped,
handleGenericFilesDropped,
handleAddImagesCallback,
handleAddFilesCallback,
handleComposerHeightChange, handleComposerHeightChange,
handleMessageSent, handleMessageSent,
showHistorySyncOverlay, showHistorySyncOverlay,
@@ -1175,10 +1106,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
dismiss: () => void; dismiss: () => void;
streamViewRef: React.RefObject<AgentStreamViewHandle | null>; streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
animatedContentStyle: object[]; animatedContentStyle: object[];
handleFilesDropped: (files: ImageAttachment[]) => void;
handleGenericFilesDropped: (items: DroppedItem[]) => void;
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
handleAddFilesCallback: (addFiles: (files: UserComposerAttachment[]) => void) => void;
handleComposerHeightChange: (height: number) => void; handleComposerHeightChange: (height: number) => void;
handleMessageSent: () => void; handleMessageSent: () => void;
showHistorySyncOverlay: boolean; showHistorySyncOverlay: boolean;
@@ -1237,8 +1164,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
agentInputDraft={agentInputDraft} agentInputDraft={agentInputDraft}
onAttentionInputFocus={onAttentionInputFocus} onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend} onAttentionPromptSend={onAttentionPromptSend}
onAddImages={handleAddImagesCallback}
onAddFiles={handleAddFilesCallback}
onComposerHeightChange={handleComposerHeightChange} onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent} onMessageSent={handleMessageSent}
/> />
@@ -1252,25 +1177,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
return ( return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}> <RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}> <View style={styles.root}>
<FileDropZone <View style={styles.container}>
onFilesDropped={handleFilesDropped} {contentContainer}
onGenericFilesDropped={handleGenericFilesDropped}
disabled={isArchivingCurrentAgent}
>
<View style={styles.container}>
{contentContainer}
{composerSection} {composerSection}
{showHistorySyncOverlay ? ( {showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay"> <View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View> </View>
) : null} ) : null}
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" /> <ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
</View> </View>
</FileDropZone>
{isArchivingCurrentAgent ? ( {isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay"> <View style={styles.archivingOverlay} testID="agent-archiving-overlay">
@@ -1361,8 +1280,6 @@ const AgentComposerSection = memo(function AgentComposerSection({
agentInputDraft, agentInputDraft,
onAttentionInputFocus, onAttentionInputFocus,
onAttentionPromptSend, onAttentionPromptSend,
onAddImages,
onAddFiles,
onComposerHeightChange, onComposerHeightChange,
onMessageSent, onMessageSent,
}: { }: {
@@ -1376,8 +1293,6 @@ const AgentComposerSection = memo(function AgentComposerSection({
agentInputDraft: AgentInputDraft; agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void; onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void; onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void; onComposerHeightChange: (height: number) => void;
onMessageSent: () => void; onMessageSent: () => void;
}) { }) {
@@ -1401,8 +1316,6 @@ const AgentComposerSection = memo(function AgentComposerSection({
agentInputDraft={agentInputDraft} agentInputDraft={agentInputDraft}
onAttentionInputFocus={onAttentionInputFocus} onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend} onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onAddFiles={onAddFiles}
onComposerHeightChange={onComposerHeightChange} onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent} onMessageSent={onMessageSent}
/> />
@@ -1418,8 +1331,6 @@ function ActiveAgentComposer({
agentInputDraft, agentInputDraft,
onAttentionInputFocus, onAttentionInputFocus,
onAttentionPromptSend, onAttentionPromptSend,
onAddImages,
onAddFiles,
onComposerHeightChange, onComposerHeightChange,
onMessageSent, onMessageSent,
}: { }: {
@@ -1431,8 +1342,6 @@ function ActiveAgentComposer({
agentInputDraft: AgentInputDraft; agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void; onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void; onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void; onComposerHeightChange: (height: number) => void;
onMessageSent: () => void; onMessageSent: () => void;
}) { }) {
@@ -1578,8 +1487,6 @@ function ActiveAgentComposer({
isSubmitLoading={isSubmitLoading} isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus} onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend} onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onAddFiles={onAddFiles}
onComposerHeightChange={onComposerHeightChange} onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent} onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand} onClientSlashCommand={handleClientSlashCommand}
@@ -13,7 +13,6 @@ import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { FileDropZone } from "@/components/file-drop-zone";
import { HostStatusDot } from "@/components/host-status-dot"; import { HostStatusDot } from "@/components/host-status-dot";
import { HostPicker } from "@/components/hosts/host-picker"; import { HostPicker } from "@/components/hosts/host-picker";
import { ProjectIconView } from "@/components/project-icon-view"; import { ProjectIconView } from "@/components/project-icon-view";
@@ -57,7 +56,7 @@ import {
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
@@ -1809,14 +1808,6 @@ export function NewWorkspaceScreen({
[composerState, draftKey, ensureWorkspace, selectedServerId, t, toast], [composerState, draftKey, ensureWorkspace, selectedServerId, t, toast],
); );
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const renderPickerOption = useCallback( const renderPickerOption = useCallback(
(props: { (props: {
option: ComboboxOptionType; option: ComboboxOptionType;
@@ -1974,46 +1965,43 @@ export function NewWorkspaceScreen({
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []); const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return ( return (
<FileDropZone onFilesDropped={handleFilesDropped}> <View style={styles.container}>
<View style={styles.container}> <ScreenHeader left={screenHeaderLeft} borderless />
<ScreenHeader left={screenHeaderLeft} borderless /> <View style={contentStyle}>
<View style={contentStyle}> <TitlebarDragRegion />
<TitlebarDragRegion /> <ReanimatedAnimated.View style={centeredStyle}>
<ReanimatedAnimated.View style={centeredStyle}> <View style={styles.composerTitleContainer}>
<View style={styles.composerTitleContainer}> <Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text> </View>
</View> {formStack}
{formStack} <Composer
<Composer externalKeyboardShift
externalKeyboardShift agentId={draftKey}
agentId={draftKey} serverId={selectedServerId}
serverId={selectedServerId} isPaneFocused={true}
isPaneFocused={true} onSubmitMessage={handleSubmitNewWorkspace}
onSubmitMessage={handleSubmitNewWorkspace} allowEmptySubmit={true}
allowEmptySubmit={true} submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonAccessibilityLabel={t("newWorkspace.create")} submitButtonTestID="workspace-create-submit"
submitButtonTestID="workspace-create-submit" submitIcon="return"
submitIcon="return" isSubmitLoading={pendingAction !== null}
isSubmitLoading={pendingAction !== null} submitBehavior="preserve-and-lock"
submitBehavior="preserve-and-lock" blurOnSubmit={true}
blurOnSubmit={true} value={chatDraft.text}
value={chatDraft.text} onChangeText={chatDraft.setText}
onChangeText={chatDraft.setText} attachments={chatDraft.attachments}
attachments={chatDraft.attachments} onChangeAttachments={chatDraft.setAttachments}
onChangeAttachments={chatDraft.setAttachments} cwd={selectedSourceDirectory ?? ""}
cwd={selectedSourceDirectory ?? ""} clearDraft={handleClearDraft}
clearDraft={handleClearDraft} autoFocus
autoFocus commandDraftConfig={composerState?.commandDraftConfig}
commandDraftConfig={composerState?.commandDraftConfig} agentControls={agentControlsWithDisabled}
agentControls={agentControlsWithDisabled} footer={composerFooter}
onAddImages={handleAddImagesCallback} />
footer={composerFooter} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
/> </ReanimatedAnimated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</View>
</View> </View>
</FileDropZone> </View>
); );
} }