From 65f1143e6a09b7849808787759ed2039be10d023 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 27 Jun 2026 09:03:04 +0800 Subject: [PATCH] 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 --- packages/app/e2e/composer-attachments.spec.ts | 47 ++++ packages/app/e2e/helpers/composer.ts | 27 ++ packages/app/src/attachments/picked-file.ts | 28 ++ .../app/src/components/file-drop-zone.tsx | 6 +- .../src/components/workspace-setup-dialog.tsx | 14 +- .../app/src/composer/attachments/drop.test.ts | 67 +++++ packages/app/src/composer/attachments/drop.ts | 58 ++++ .../app/src/composer/draft/workspace-tab.tsx | 131 ++++----- packages/app/src/composer/index.tsx | 258 ++++++++++-------- packages/app/src/hooks/use-file-picker.ts | 41 +-- packages/app/src/panels/agent-panel.tsx | 113 +------- .../app/src/screens/new-workspace-screen.tsx | 86 +++--- 12 files changed, 488 insertions(+), 388 deletions(-) create mode 100644 packages/app/src/attachments/picked-file.ts create mode 100644 packages/app/src/composer/attachments/drop.test.ts create mode 100644 packages/app/src/composer/attachments/drop.ts diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts index e77c2bd25..98d0c7435 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -16,6 +16,7 @@ import { expectComposerEditable, expectAttachButtonDisabled, fillComposerDraft, + dropFileOnComposer, sendDraftToQueue, expectQueuedMessageButton, startRunningMockAgent, @@ -36,6 +37,11 @@ const MINIMAL_PNG = Buffer.from( ); 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("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"); }); + 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.setTimeout(60_000); const workspace = await withWorkspace({ prefix: "attach-remove-" }); diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index b40570bc2..68600f023 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -95,6 +95,33 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise< await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 }); } +export async function dropFileOnComposer( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + 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. */ export async function removeAttachmentPill( page: Page, diff --git a/packages/app/src/attachments/picked-file.ts b/packages/app/src/attachments/picked-file.ts new file mode 100644 index 000000000..a7efdfd63 --- /dev/null +++ b/packages/app/src/attachments/picked-file.ts @@ -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 { + const { path: managedPath } = await copyDesktopAttachmentFile({ + attachmentId: crypto.randomUUID(), + sourcePath: path, + extension: getFileExtension(path) || null, + }); + const base64 = await readDesktopFileBase64(managedPath); + return base64ToUint8Array(base64); +} diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx index 0d6a0081d..0a0ba4b3f 100644 --- a/packages/app/src/components/file-drop-zone.tsx +++ b/packages/app/src/components/file-drop-zone.tsx @@ -1,4 +1,5 @@ import { View, Text } from "react-native"; +import type { StyleProp, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import { useEffect, useMemo } from "react"; @@ -13,6 +14,7 @@ interface FileDropZoneProps { onFilesDropped: (files: ImageAttachment[]) => void; onGenericFilesDropped?: (items: DroppedItem[]) => void; disabled?: boolean; + style?: StyleProp; } const IS_WEB = isWeb; @@ -22,6 +24,7 @@ export function FileDropZone({ onFilesDropped, onGenericFilesDropped, disabled = false, + style, }: FileDropZoneProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); @@ -46,6 +49,7 @@ export function FileDropZone({ () => [styles.overlay, overlayAnimatedStyle], [overlayAnimatedStyle], ); + const containerStyle = useMemo(() => [styles.container, style], [style]); // On non-web platforms, just render children if (!IS_WEB) { @@ -56,7 +60,7 @@ export function FileDropZone({ } - style={styles.container} + style={containerStyle} > {children} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index f300508f3..0a607612d 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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 { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -26,7 +26,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; 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 { if (!icon) { @@ -363,14 +363,6 @@ export function WorkspaceSetupDialog() { const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); 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 iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const agentControlsWithDisabled = useMemo( @@ -427,7 +419,6 @@ export function WorkspaceSetupDialog() { snapPoints={SNAP_POINTS} testID="workspace-setup-dialog" desktopMaxWidth={640} - onFilesDropped={handleFilesDropped} > diff --git a/packages/app/src/composer/attachments/drop.test.ts b/packages/app/src/composer/attachments/drop.test.ts new file mode 100644 index 000000000..31a26a7fe --- /dev/null +++ b/packages/app/src/composer/attachments/drop.test.ts @@ -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]), + }, + ]); + }); +}); diff --git a/packages/app/src/composer/attachments/drop.ts b/packages/app/src/composer/attachments/drop.ts new file mode 100644 index 000000000..8114f780b --- /dev/null +++ b/packages/app/src/composer/attachments/drop.ts @@ -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; +} + +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 { + 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; +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 19031022f..e0067030a 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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 ( - - - - {isSubmitting && draftAgent ? ( - - + + + {isSubmitting && draftAgent ? ( + + + + ) : ( + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - - - {importPillPress ? ( - - - - - - ) : null} - - + + )} - + + + {importPillPress ? ( + + + + + + ) : null} + + + ); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 0f839c1ad..4065610fc 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ( - - - {/* Input area */} - - - {queueList} - {sendErrorNode} + + + + {/* Input area */} + + + {queueList} + {sendErrorNode} - - + + - {/* MessageInput handles everything: text, dictation, attachments, all buttons */} - - + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} + + + - - {renderComposerFooter(footer, footerInlineContent)} - + {renderComposerFooter(footer, footerInlineContent)} + + ); } @@ -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, diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts index e845e83e9..f75e6eb85 100644 --- a/packages/app/src/hooks/use-file-picker.ts +++ b/packages/app/src/hooks/use-file-picker.ts @@ -2,35 +2,9 @@ import { useCallback, useRef } from "react"; import * as DocumentPicker from "expo-document-picker"; import { File } from "expo-file-system"; 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 { getFileExtension, getMimeTypeFromPath } from "@/attachments/file-types"; - -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 { - const { path: managedPath } = await copyDesktopAttachmentFile({ - attachmentId: crypto.randomUUID(), - sourcePath: path, - extension: getFileExtension(path) || null, - }); - const base64 = await readDesktopFileBase64(managedPath); - return base64ToUint8Array(base64); -} +import { getMimeTypeFromPath } from "@/attachments/file-types"; +import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file"; async function pickFilesWithDesktopDialog(): Promise { const dialog = getDesktopHost()?.dialog; @@ -58,16 +32,7 @@ async function pickFilesWithDesktopDialog(): Promise { for (const filePath of paths) { const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath; const mimeType = getMimeTypeFromPath(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); + const bytes = await readDesktopFileBytes(filePath); result.push({ fileName, mimeType, bytes }); } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 52710109a..960c5e6a1 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -14,18 +14,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { Composer } from "@/composer"; 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 type { ImageAttachment } from "@/composer/types"; import { getProviderIcon } from "@/components/provider-icons"; import { ToastViewport, @@ -706,8 +695,6 @@ function ChatAgentContent({ const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - const addFilesRef = useRef<((files: UserComposerAttachment[]) => void) | null>(null); const clearOnAgentBlurRef = useRef<() => void>(() => {}); const wasPaneFocusedRef = useRef(isPaneFocused); const reconnectToastArmedRef = useRef(false); @@ -716,54 +703,6 @@ function ChatAgentContent({ routeKey: string; reason: "initial-entry" | "resume"; } | 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( useShallow((state) => selectChatAgentState(state, serverId, agentId)), ); @@ -1121,10 +1060,6 @@ function ChatAgentContent({ dismiss={dismissToast} streamViewRef={streamViewRef} animatedContentStyle={animatedContentStyle} - handleFilesDropped={handleFilesDropped} - handleGenericFilesDropped={handleGenericFilesDropped} - handleAddImagesCallback={handleAddImagesCallback} - handleAddFilesCallback={handleAddFilesCallback} handleComposerHeightChange={handleComposerHeightChange} handleMessageSent={handleMessageSent} showHistorySyncOverlay={showHistorySyncOverlay} @@ -1150,10 +1085,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ dismiss, streamViewRef, animatedContentStyle, - handleFilesDropped, - handleGenericFilesDropped, - handleAddImagesCallback, - handleAddFilesCallback, handleComposerHeightChange, handleMessageSent, showHistorySyncOverlay, @@ -1175,10 +1106,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ dismiss: () => void; streamViewRef: React.RefObject; 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; handleMessageSent: () => void; showHistorySyncOverlay: boolean; @@ -1237,8 +1164,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ agentInputDraft={agentInputDraft} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} - onAddImages={handleAddImagesCallback} - onAddFiles={handleAddFilesCallback} onComposerHeightChange={handleComposerHeightChange} onMessageSent={handleMessageSent} /> @@ -1252,25 +1177,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ return ( - - - {contentContainer} + + {contentContainer} - {composerSection} + {composerSection} - {showHistorySyncOverlay ? ( - - - - ) : null} + {showHistorySyncOverlay ? ( + + + + ) : null} - - - + + {isArchivingCurrentAgent ? ( @@ -1361,8 +1280,6 @@ const AgentComposerSection = memo(function AgentComposerSection({ agentInputDraft, onAttentionInputFocus, onAttentionPromptSend, - onAddImages, - onAddFiles, onComposerHeightChange, onMessageSent, }: { @@ -1376,8 +1293,6 @@ const AgentComposerSection = memo(function AgentComposerSection({ agentInputDraft: AgentInputDraft; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; - onAddImages: (addImages: (images: ImageAttachment[]) => void) => void; - onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void; onComposerHeightChange: (height: number) => void; onMessageSent: () => void; }) { @@ -1401,8 +1316,6 @@ const AgentComposerSection = memo(function AgentComposerSection({ agentInputDraft={agentInputDraft} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} - onAddImages={onAddImages} - onAddFiles={onAddFiles} onComposerHeightChange={onComposerHeightChange} onMessageSent={onMessageSent} /> @@ -1418,8 +1331,6 @@ function ActiveAgentComposer({ agentInputDraft, onAttentionInputFocus, onAttentionPromptSend, - onAddImages, - onAddFiles, onComposerHeightChange, onMessageSent, }: { @@ -1431,8 +1342,6 @@ function ActiveAgentComposer({ agentInputDraft: AgentInputDraft; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; - onAddImages: (addImages: (images: ImageAttachment[]) => void) => void; - onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void; onComposerHeightChange: (height: number) => void; onMessageSent: () => void; }) { @@ -1578,8 +1487,6 @@ function ActiveAgentComposer({ isSubmitLoading={isSubmitLoading} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} - onAddImages={onAddImages} - onAddFiles={onAddFiles} onComposerHeightChange={onComposerHeightChange} onMessageSent={onMessageSent} onClientSlashCommand={handleClientSlashCommand} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 2aaf0cd62..931725e39 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -13,7 +13,6 @@ import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide import { Composer } from "@/composer"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; -import { FileDropZone } from "@/components/file-drop-zone"; import { HostStatusDot } from "@/components/host-status-dot"; import { HostPicker } from "@/components/hosts/host-picker"; import { ProjectIconView } from "@/components/project-icon-view"; @@ -57,7 +56,7 @@ import { } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; 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 { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; @@ -1809,14 +1808,6 @@ export function NewWorkspaceScreen({ [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( (props: { option: ComboboxOptionType; @@ -1974,46 +1965,43 @@ export function NewWorkspaceScreen({ const screenHeaderLeft = useMemo(() => , []); return ( - - - - - - - - {t("newWorkspace.title")} - - {formStack} - - {errorMessage ? {errorMessage} : null} - - + + + + + + + {t("newWorkspace.title")} + + {formStack} + + {errorMessage ? {errorMessage} : null} + - + ); }