From e08a39d47bbe8ab9bee98cb06519b28266c53fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 9 Mar 2026 12:19:11 +0100 Subject: [PATCH] feat: insert native file path when dropping or pasting non-image files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, only image files could be attached to the composer. Dropping or pasting any other file type (e.g. .riv, .csv, .json) silently failed with an "unsupported file type" error, making it impossible to reference data or asset files by path. Closes #557. Implementation ────────────── contracts/ipc.ts Added getPathForFile(file) to the DesktopBridge interface. preload.ts Exposed webUtils.getPathForFile(file) via contextBridge. Electron's sandbox blocks the legacy file.path property on File objects; the webUtils API (available since Electron 32) is the official replacement. ComposerPromptEditor Added insertText(text) to ComposerPromptEditorHandle. Uses Lexical's native selection.insertText() to insert at the real cursor position, which correctly handles existing @mention nodes. Manipulating the plain-text snapshot and calling setPrompt() was not an option: Lexical's editor.update() is async, so a synchronous focusComposer() call after would read a stale snapshotRef and overwrite the inserted text via onChange. ChatView - COMPOSER_FILE_PATH_SEPARATOR constant replaces the magic " " literal. - addComposerAttachments(files) is a new unified helper (placed next to addComposerImages) that partitions files into images and non-images. Images are handled by the existing addComposerImages path. Non-image files have their native path resolved via desktopBridge.getPathForFile (fallback: file.name in non-Electron contexts), auto-quoted when the path contains spaces, joined by COMPOSER_FILE_PATH_SEPARATOR, and inserted into the composer via insertText(). - onComposerDrop and onComposerPaste both delegate to addComposerAttachments, eliminating the duplicated partition logic. onComposerDrop additionally calls focusComposer() only when no non-image files were dropped, since insertText() focuses the editor internally as a prerequisite for obtaining a Lexical selection. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/preload.ts | 3 +- apps/web/src/components/ChatView.tsx | 31 ++++++++++++++----- .../src/components/ComposerPromptEditor.tsx | 20 +++++++++++- packages/contracts/src/ipc.ts | 1 + 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8eb..0f1d5c5a3a1 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; @@ -15,6 +15,7 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, + getPathForFile: (file: File) => webUtils.getPathForFile(file), pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e64..d9f98dec463 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -171,6 +171,7 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; +const COMPOSER_FILE_PATH_SEPARATOR = " "; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -2097,17 +2098,32 @@ export default function ChatView({ threadId }: ChatViewProps) { removeComposerImageFromDraft(imageId); }; + const addComposerAttachments = (files: File[]) => { + const imageFiles = files.filter((f) => f.type.startsWith("image/")); + const nonImageFiles = files.filter((f) => !f.type.startsWith("image/")); + + if (imageFiles.length > 0) { + addComposerImages(imageFiles); + } + + if (nonImageFiles.length > 0) { + const paths = nonImageFiles.map( + (file) => window.desktopBridge?.getPathForFile(file) ?? file.name, + ); + const insertion = paths + .map((p) => (p.includes(" ") ? `"${p}"` : p)) + .join(COMPOSER_FILE_PATH_SEPARATOR); + composerEditorRef.current?.insertTextAndFocus(insertion); + } + }; + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) { return; } - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) { - return; - } event.preventDefault(); - addComposerImages(imageFiles); + addComposerAttachments(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -2151,8 +2167,9 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); - addComposerImages(files); - focusComposer(); + const hasNonImageFiles = files.some((f) => !f.type.startsWith("image/")); + addComposerAttachments(files); + if (!hasNonImageFiles) focusComposer(); }; const onRevertToTurnCount = useCallback( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index ab68f1fcbdc..71f2d9610a8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -459,6 +459,7 @@ export interface ComposerPromptEditorHandle { focusAt: (cursor: number) => void; focusAtEnd: () => void; readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; + insertTextAndFocus: (text: string) => void; } interface ComposerPromptEditorProps { @@ -813,6 +814,22 @@ function ComposerPromptEditorInner({ return snapshot; }, [editor]); + const insertTextAndFocus = useCallback( + (text: string) => { + const rootElement = editor.getRootElement(); + if (rootElement) { + rootElement.focus(); + } + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(text); + } + }); + }, + [editor], + ); + useImperativeHandle( editorRef, () => ({ @@ -829,8 +846,9 @@ function ComposerPromptEditorInner({ ); }, readSnapshot, + insertTextAndFocus, }), - [focusAt, readSnapshot], + [focusAt, readSnapshot, insertTextAndFocus], ); const handleEditorChange = useCallback((editorState: EditorState) => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb1766..959d70fce1c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -96,6 +96,7 @@ export interface DesktopUpdateActionResult { export interface DesktopBridge { getWsUrl: () => string | null; + getPathForFile: (file: File) => string; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise;