From 61dab45870600a9409ff3d660572be2f32a8dfaa 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 1e1bb3bd8..0f1d5c5a3 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 3c8a0a152..8d60ce798 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -262,6 +262,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; const WORKTREE_BRANCH_PREFIX = "t3code"; @@ -2450,17 +2451,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) => { @@ -2504,8 +2520,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 96efc0fbf..dc5f3e28b 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -384,6 +384,7 @@ export interface ComposerPromptEditorHandle { focusAt: (cursor: number) => void; focusAtEnd: () => void; readSnapshot: () => { value: string; cursor: number }; + insertTextAndFocus: (text: string) => void; } interface ComposerPromptEditorProps { @@ -699,6 +700,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, () => ({ @@ -712,8 +729,9 @@ function ComposerPromptEditorInner({ focusAt(snapshotRef.current.value.length); }, 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 b9127fb17..959d70fce 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;