From 8b120d63cd8fb622187b28ff1009d12bfd7d4676 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 13 Feb 2026 15:45:14 +0000 Subject: [PATCH 1/6] Implement proper file attachments and attachment previews --- apps/twig/src/main/trpc/routers/os.ts | 38 +++++ .../components/EditorToolbar.tsx | 12 +- .../components/ImageAttachmentsBar.tsx | 148 ++++++++++++++++++ .../components/MessageEditor.tsx | 12 +- .../components/message-editor.css | 5 + .../message-editor/tiptap/MentionChipNode.ts | 8 + .../message-editor/tiptap/MentionChipView.tsx | 33 ++++ .../message-editor/tiptap/useDraftSync.ts | 50 +++++- .../message-editor/tiptap/useTiptapEditor.ts | 112 +++++++------ .../renderer/features/message-editor/types.ts | 4 +- .../features/message-editor/utils/content.ts | 83 +++++++--- .../message-editor/utils/imageUtils.ts | 17 ++ .../sessions/components/SessionView.tsx | 6 +- .../task-detail/components/TaskInput.tsx | 57 +++++++ .../components/TaskInputEditor.tsx | 12 +- .../task-detail/hooks/useTaskCreation.ts | 46 +----- 16 files changed, 509 insertions(+), 134 deletions(-) create mode 100644 apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx create mode 100644 apps/twig/src/renderer/features/message-editor/tiptap/MentionChipView.tsx create mode 100644 apps/twig/src/renderer/features/message-editor/utils/imageUtils.ts diff --git a/apps/twig/src/main/trpc/routers/os.ts b/apps/twig/src/main/trpc/routers/os.ts index 5d35e5fb0..33a222c50 100644 --- a/apps/twig/src/main/trpc/routers/os.ts +++ b/apps/twig/src/main/trpc/routers/os.ts @@ -150,6 +150,44 @@ export const osRouter = router({ */ getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()), + /** + * Read a file and return it as a base64 data URL + * Used for image thumbnails in the editor + */ + readFileAsDataUrl: publicProcedure + .input( + z.object({ + filePath: z.string(), + maxSizeBytes: z.number().optional().default(10 * 1024 * 1024), + }), + ) + .query(async ({ input }) => { + try { + const stat = await fsPromises.stat(input.filePath); + if (stat.size > input.maxSizeBytes) return null; + + const ext = path.extname(input.filePath).toLowerCase().slice(1); + const mimeMap: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + svg: "image/svg+xml", + tiff: "image/tiff", + tif: "image/tiff", + }; + const mime = mimeMap[ext] ?? "application/octet-stream"; + + const buffer = await fsPromises.readFile(input.filePath); + return `data:${mime};base64,${buffer.toString("base64")}`; + } catch { + return null; + } + }), + /** * Save clipboard image data to a temp file * Returns the file path for use as a file attachment diff --git a/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx b/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx index f7619f679..cf8522d9f 100644 --- a/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx @@ -3,13 +3,13 @@ import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningL import { Paperclip } from "@phosphor-icons/react"; import { Flex, IconButton, Tooltip } from "@radix-ui/themes"; import { useRef } from "react"; -import type { MentionChip } from "../utils/content"; +import type { FileAttachment } from "../utils/content"; interface EditorToolbarProps { disabled?: boolean; taskId?: string; adapter?: "claude" | "codex"; - onInsertChip: (chip: MentionChip) => void; + onAddAttachment: (attachment: FileAttachment) => void; onAttachFiles?: (files: File[]) => void; attachTooltip?: string; iconSize?: number; @@ -21,7 +21,7 @@ export function EditorToolbar({ disabled = false, taskId, adapter, - onInsertChip, + onAddAttachment, onAttachFiles, attachTooltip = "Attach file", iconSize = 14, @@ -35,11 +35,7 @@ export function EditorToolbar({ const fileArray = Array.from(files); for (const file of fileArray) { const filePath = (file as File & { path?: string }).path || file.name; - onInsertChip({ - type: "file", - id: filePath, - label: file.name, - }); + onAddAttachment({ id: filePath, label: file.name }); } onAttachFiles?.(fileArray); } diff --git a/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx b/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx new file mode 100644 index 000000000..742389c3d --- /dev/null +++ b/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx @@ -0,0 +1,148 @@ +import { X } from "@phosphor-icons/react"; +import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; +import { trpcVanilla } from "@renderer/trpc/client"; +import { useEffect, useState } from "react"; +import type { FileAttachment } from "../utils/content"; +import { isImageFile } from "../utils/imageUtils"; + +function useDataUrl(filePath: string) { + const [dataUrl, setDataUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + trpcVanilla.os.readFileAsDataUrl + .query({ filePath }) + .then((url) => { + if (!cancelled) setDataUrl(url); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [filePath]); + + return dataUrl; +} + +function ImageThumbnail({ + attachment, + onRemove, +}: { + attachment: FileAttachment; + onRemove: () => void; +}) { + const dataUrl = useDataUrl(attachment.id); + + return ( + +
+ + + + { + e.stopPropagation(); + onRemove(); + }} + > + + +
+ + + {attachment.label} + + {dataUrl ? ( + {attachment.label} + ) : ( + + Unable to load image preview + + )} + +
+ ); +} + +function FileChip({ + attachment, + onRemove, +}: { + attachment: FileAttachment; + onRemove: () => void; +}) { + return ( +
+ + @{attachment.label} + + { + e.stopPropagation(); + onRemove(); + }} + > + + +
+ ); +} + +interface AttachmentsBarProps { + attachments: FileAttachment[]; + onRemove: (id: string) => void; +} + +export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) { + if (attachments.length === 0) return null; + + return ( + + {attachments.map((att) => + isImageFile(att.label) ? ( + onRemove(att.id)} + /> + ) : ( + onRemove(att.id)} + /> + ), + )} + + ); +} diff --git a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx index 92cd3b942..9ab28857c 100644 --- a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -11,6 +11,7 @@ import { useTiptapEditor } from "../tiptap/useTiptapEditor"; import type { EditorHandle } from "../types"; import type { EditorContent as EditorContentType } from "../utils/content"; import { AdapterIndicator } from "./AdapterIndicator"; +import { AttachmentsBar } from "./ImageAttachmentsBar"; import { DiffStatsIndicator } from "./DiffStatsIndicator"; import { EditorToolbar } from "./EditorToolbar"; import { ModeIndicatorInput } from "./ModeIndicatorInput"; @@ -76,6 +77,9 @@ export const MessageEditor = forwardRef( getContent, setContent, insertChip, + attachments, + addAttachment, + removeAttachment, } = useTiptapEditor({ sessionId, taskId, @@ -103,6 +107,8 @@ export const MessageEditor = forwardRef( getText, setContent, insertChip, + addAttachment, + removeAttachment, }), [ focus, @@ -113,6 +119,8 @@ export const MessageEditor = forwardRef( getText, setContent, insertChip, + addAttachment, + removeAttachment, ], ); @@ -151,6 +159,8 @@ export const MessageEditor = forwardRef( onClick={handleContainerClick} style={{ cursor: "text" }} > + +
@@ -160,7 +170,7 @@ export const MessageEditor = forwardRef( {isBashMode && ( diff --git a/apps/twig/src/renderer/features/message-editor/components/message-editor.css b/apps/twig/src/renderer/features/message-editor/components/message-editor.css index d97fa0d8d..28cfa610b 100644 --- a/apps/twig/src/renderer/features/message-editor/components/message-editor.css +++ b/apps/twig/src/renderer/features/message-editor/components/message-editor.css @@ -6,3 +6,8 @@ float: left; height: 0; } + +/* Ensure Tiptap NodeView wrappers render inline for mention chips */ +.cli-editor [data-node-view-wrapper] { + display: inline; +} diff --git a/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipNode.ts b/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipNode.ts index d2a2408cc..85fb9f26c 100644 --- a/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipNode.ts +++ b/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipNode.ts @@ -1,4 +1,6 @@ import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { MentionChipView } from "./MentionChipView"; export type ChipType = | "file" @@ -60,6 +62,12 @@ export const MentionChipNode = Node.create({ ]; }, + addNodeView() { + return ReactNodeViewRenderer(MentionChipView, { + contentDOMElementTag: "span", + }); + }, + addCommands() { return { insertMentionChip: diff --git a/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipView.tsx b/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipView.tsx new file mode 100644 index 000000000..94a71f485 --- /dev/null +++ b/apps/twig/src/renderer/features/message-editor/tiptap/MentionChipView.tsx @@ -0,0 +1,33 @@ +import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import type { MentionChipAttrs } from "./MentionChipNode"; + +function DefaultChip({ + type, + label, +}: { + type: string; + label: string; +}) { + const isCommand = type === "command"; + const prefix = isCommand ? "/" : "@"; + + return ( + + {prefix} + {label} + + ); +} + +export function MentionChipView({ node }: NodeViewProps) { + const { type, label } = node.attrs as MentionChipAttrs; + + return ( + + + + ); +} diff --git a/apps/twig/src/renderer/features/message-editor/tiptap/useDraftSync.ts b/apps/twig/src/renderer/features/message-editor/tiptap/useDraftSync.ts index 20c85d689..bd6454b07 100644 --- a/apps/twig/src/renderer/features/message-editor/tiptap/useDraftSync.ts +++ b/apps/twig/src/renderer/features/message-editor/tiptap/useDraftSync.ts @@ -1,7 +1,11 @@ import type { Editor, JSONContent } from "@tiptap/core"; import { useCallback, useLayoutEffect, useRef } from "react"; import { useDraftStore } from "../stores/draftStore"; -import { type EditorContent, isContentEmpty } from "../utils/content"; +import { + type EditorContent, + type FileAttachment, + isContentEmpty, +} from "../utils/content"; function tiptapJsonToEditorContent(json: JSONContent): EditorContent { const segments: EditorContent["segments"] = []; @@ -161,30 +165,60 @@ export function useDraftSync( draftActions.clearPendingContent(sessionId); }, [editor, pendingContent, sessionId, draftActions]); + // Extract restored attachments from draft on first restore + const restoredAttachmentsRef = useRef([]); + useLayoutEffect(() => { + if (!draft || typeof draft === "string") return; + if (draft.attachments && draft.attachments.length > 0) { + restoredAttachmentsRef.current = draft.attachments; + } + }, [draft]); + + const attachmentsRef = useRef([]); + const saveDraft = useCallback( - (e: Editor) => { + (e: Editor, attachments?: FileAttachment[]) => { // Don't save until store has hydrated from storage // This prevents overwriting stored drafts with empty content before restoration if (!hasHydrated) return; + if (attachments !== undefined) { + attachmentsRef.current = attachments; + } + const json = e.getJSON(); const content = tiptapJsonToEditorContent(json); + const withAttachments: EditorContent = + attachmentsRef.current.length > 0 + ? { ...content, attachments: attachmentsRef.current } + : content; draftActions.setDraft( sessionId, - isContentEmpty(content) ? null : content, + isContentEmpty(withAttachments) ? null : withAttachments, ); }, [sessionId, draftActions, hasHydrated], ); const clearDraft = useCallback(() => { + attachmentsRef.current = []; draftActions.setDraft(sessionId, null); }, [sessionId, draftActions]); - const getContent = useCallback((): EditorContent => { - if (!editorRef.current) return { segments: [] }; - return tiptapJsonToEditorContent(editorRef.current.getJSON()); - }, []); + const getContent = useCallback( + (attachments?: FileAttachment[]): EditorContent => { + if (!editorRef.current) return { segments: [] }; + const content = tiptapJsonToEditorContent(editorRef.current.getJSON()); + const atts = attachments ?? attachmentsRef.current; + return atts.length > 0 ? { ...content, attachments: atts } : content; + }, + [], + ); - return { saveDraft, clearDraft, getContent }; + return { + saveDraft, + clearDraft, + getContent, + restoredAttachments: restoredAttachmentsRef.current, + }; } diff --git a/apps/twig/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/twig/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 1ef455a61..f59029511 100644 --- a/apps/twig/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/twig/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -3,9 +3,9 @@ import { trpcVanilla } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { useSettingsStore } from "@stores/settingsStore"; import { useEditor } from "@tiptap/react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { usePromptHistoryStore } from "../stores/promptHistoryStore"; -import type { MentionChip } from "../utils/content"; +import type { FileAttachment, MentionChip } from "../utils/content"; import { contentToXml, isContentEmpty } from "../utils/content"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -90,6 +90,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const historyActions = usePromptHistoryStore.getState(); const [isEmptyState, setIsEmptyState] = useState(true); const [isReady, setIsReady] = useState(false); + const [attachments, setAttachments] = useState([]); + const attachmentsRef = useRef([]); const handleCommandSubmit = useCallback((text: string) => { callbackRefs.current.onSubmit?.(text); @@ -195,56 +197,36 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return false; }, - handleDrop: (view, event, _slice, moved) => { + handleDrop: (_view, event, _slice, moved) => { if (moved) return false; const files = event.dataTransfer?.files; if (!files || files.length === 0) return false; - const paths: { path: string; name: string }[] = []; + const newAttachments: FileAttachment[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; // In Electron, File objects have a 'path' property // eslint-disable-next-line @typescript-eslint/no-explicit-any const path = (file as any).path; if (path) { - paths.push({ path, name: file.name }); + newAttachments.push({ id: path, label: file.name }); } } - if (paths.length > 0) { + if (newAttachments.length > 0) { event.preventDefault(); - - // Insert file mention chips for each dropped file - const { tr } = view.state; - const coordinates = view.posAtCoords({ - left: event.clientX, - top: event.clientY, + setAttachments((prev) => { + const existing = new Set(prev.map((a) => a.id)); + const unique = newAttachments.filter((a) => !existing.has(a.id)); + return unique.length > 0 ? [...prev, ...unique] : prev; }); - let pos = coordinates ? coordinates.pos : view.state.selection.from; - - for (const { path, name } of paths) { - const chipNode = view.state.schema.nodes.mentionChip?.create({ - type: "file", - id: path, - label: name, - }); - if (chipNode) { - tr.insert(pos, chipNode); - pos += chipNode.nodeSize; - // Add space after chip - tr.insertText(" ", pos); - pos += 1; - } - } - - view.dispatch(tr); return true; } return false; }, - handlePaste: (view, event) => { + handlePaste: (_view, event) => { const items = event.clipboardData?.items; if (!items) return false; @@ -280,19 +262,10 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { originalName: file.name, }); - const chipNode = view.state.schema.nodes.mentionChip?.create({ - type: "file", - id: result.path, - label: result.name, + setAttachments((prev) => { + if (prev.some((a) => a.id === result.path)) return prev; + return [...prev, { id: result.path, label: result.name }]; }); - - if (chipNode) { - const { tr } = view.state; - const pos = view.state.selection.from; - tr.insert(pos, chipNode); - tr.insertText(" ", pos + chipNode.nodeSize); - view.dispatch(tr); - } } catch (_error) { toast.error("Failed to paste image"); } @@ -319,8 +292,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { callbackRefs.current.onBashModeChange?.(newBashMode); } - draftRef.current?.saveDraft(e); - const content = draftRef.current?.getContent(); + draftRef.current?.saveDraft(e, attachmentsRef.current); + const content = draftRef.current?.getContent(attachmentsRef.current); const newIsEmpty = isContentEmpty(content ?? null); setIsEmptyState(newIsEmpty); @@ -344,11 +317,30 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const draft = useDraftSync(editor, sessionId, context); draftRef.current = draft; + // Keep attachmentsRef in sync with state (synchronous, no effect needed) + attachmentsRef.current = attachments; + + // Re-save draft when attachments change so persistence stays up to date + useEffect(() => { + if (editor) { + draftRef.current?.saveDraft(editor, attachments); + } + }, [attachments, editor]); + + // Restore attachments from draft on mount + useEffect(() => { + if (draft.restoredAttachments.length > 0) { + setAttachments(draft.restoredAttachments); + } + // Only run on mount / session change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]); + const submit = useCallback(() => { if (!editor) return; if (disabled || submitDisabled) return; - const content = draft.getContent(); + const content = draft.getContent(attachments); if (isContentEmpty(content)) return; const text = editor.getText().trim(); @@ -369,9 +361,10 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (clearOnSubmit) { editor.commands.clearContent(); prevBashModeRef.current = false; + setAttachments([]); draft.clearDraft(); } - }, [editor, disabled, submitDisabled, isLoading, draft, clearOnSubmit]); + }, [editor, disabled, submitDisabled, isLoading, draft, clearOnSubmit, attachments]); submitRef.current = submit; @@ -384,6 +377,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const clear = useCallback(() => { editor?.commands.clearContent(); prevBashModeRef.current = false; + setAttachments([]); draft.clearDraft(); }, [editor, draft]); const getText = useCallback(() => editor?.getText() ?? "", [editor]); @@ -392,9 +386,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (!editor) return; editor.commands.setContent(text); editor.commands.focus("end"); - draft.saveDraft(editor); + draft.saveDraft(editor, attachments); }, - [editor, draft], + [editor, draft, attachments], ); const insertChip = useCallback( (chip: MentionChip) => { @@ -404,12 +398,23 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { id: chip.id, label: chip.label, }); - draft.saveDraft(editor); + draft.saveDraft(editor, attachments); }, - [editor, draft], + [editor, draft, attachments], ); - const isEmpty = !editor || isEmptyState; + const addAttachment = useCallback((attachment: FileAttachment) => { + setAttachments((prev) => { + if (prev.some((a) => a.id === attachment.id)) return prev; + return [...prev, attachment]; + }); + }, []); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); + + const isEmpty = !editor || (isEmptyState && attachments.length === 0); const isBashMode = enableBashMode && (editor?.getText().trimStart().startsWith("!") ?? false); @@ -426,5 +431,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { getContent: draft.getContent, setContent, insertChip, + attachments, + addAttachment, + removeAttachment, }; } diff --git a/apps/twig/src/renderer/features/message-editor/types.ts b/apps/twig/src/renderer/features/message-editor/types.ts index eb292588b..65685efea 100644 --- a/apps/twig/src/renderer/features/message-editor/types.ts +++ b/apps/twig/src/renderer/features/message-editor/types.ts @@ -1,5 +1,5 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; -import type { EditorContent, MentionChip } from "./utils/content"; +import type { EditorContent, FileAttachment, MentionChip } from "./utils/content"; export interface EditorHandle { focus: () => void; @@ -10,6 +10,8 @@ export interface EditorHandle { getText: () => string; setContent: (text: string) => void; insertChip: (chip: MentionChip) => void; + addAttachment: (attachment: FileAttachment) => void; + removeAttachment: (id: string) => void; } export interface SuggestionItem { diff --git a/apps/twig/src/renderer/features/message-editor/utils/content.ts b/apps/twig/src/renderer/features/message-editor/utils/content.ts index 49374aef4..76d435c3e 100644 --- a/apps/twig/src/renderer/features/message-editor/utils/content.ts +++ b/apps/twig/src/renderer/features/message-editor/utils/content.ts @@ -10,10 +10,16 @@ export interface MentionChip { label: string; } +export interface FileAttachment { + id: string; + label: string; +} + export interface EditorContent { segments: Array< { type: "text"; text: string } | { type: "chip"; chip: MentionChip } >; + attachments?: FileAttachment[]; } export function contentToPlainText(content: EditorContent): string { @@ -29,28 +35,39 @@ export function contentToPlainText(content: EditorContent): string { } export function contentToXml(content: EditorContent): string { - return content.segments - .map((seg) => { - if (seg.type === "text") return seg.text; - const chip = seg.chip; - switch (chip.type) { - case "file": - return ``; - case "command": - return `/${chip.label}`; - case "error": - return ``; - case "experiment": - return ``; - case "insight": - return ``; - case "feature_flag": - return ``; - default: - return `@${chip.label}`; + const inlineFilePaths = new Set(); + const parts = content.segments.map((seg) => { + if (seg.type === "text") return seg.text; + const chip = seg.chip; + switch (chip.type) { + case "file": + inlineFilePaths.add(chip.id); + return ``; + case "command": + return `/${chip.label}`; + case "error": + return ``; + case "experiment": + return ``; + case "insight": + return ``; + case "feature_flag": + return ``; + default: + return `@${chip.label}`; + } + }); + + // Append file tags for attachments not already referenced inline + if (content.attachments) { + for (const att of content.attachments) { + if (!inlineFilePaths.has(att.id)) { + parts.push(``); } - }) - .join(""); + } + } + + return parts.join(""); } export function isContentEmpty( @@ -58,8 +75,32 @@ export function isContentEmpty( ): boolean { if (!content) return true; if (typeof content === "string") return !content.trim(); + if (content.attachments && content.attachments.length > 0) return false; if (!content.segments) return true; return content.segments.every( (seg) => seg.type === "text" && !seg.text.trim(), ); } + +export function extractFilePaths(content: EditorContent): string[] { + const filePaths: string[] = []; + const seen = new Set(); + + for (const seg of content.segments) { + if (seg.type === "chip" && seg.chip.type === "file" && !seen.has(seg.chip.id)) { + seen.add(seg.chip.id); + filePaths.push(seg.chip.id); + } + } + + if (content.attachments) { + for (const att of content.attachments) { + if (!seen.has(att.id)) { + seen.add(att.id); + filePaths.push(att.id); + } + } + } + + return filePaths; +} diff --git a/apps/twig/src/renderer/features/message-editor/utils/imageUtils.ts b/apps/twig/src/renderer/features/message-editor/utils/imageUtils.ts new file mode 100644 index 000000000..95dc95933 --- /dev/null +++ b/apps/twig/src/renderer/features/message-editor/utils/imageUtils.ts @@ -0,0 +1,17 @@ +const IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + "svg", + "tiff", + "tif", +]); + +export function isImageFile(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase(); + return !!ext && IMAGE_EXTENSIONS.has(ext); +} diff --git a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx index f26f8044c..83fb72168 100644 --- a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx @@ -299,6 +299,9 @@ export function SessionView({ dragCounterRef.current = 0; setIsDraggingFile(false); + // If dropped on the editor, Tiptap's handleDrop already handled it + if ((e.target as HTMLElement).closest(".ProseMirror")) return; + const files = e.dataTransfer.files; if (!files || files.length === 0) return; @@ -306,8 +309,7 @@ export function SessionView({ const file = files[i]; const filePath = (file as File & { path?: string }).path; if (filePath) { - editorRef.current?.insertChip({ - type: "file", + editorRef.current?.addAttachment({ id: filePath, label: file.name, }); diff --git a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx index e59eb8b50..94ad120eb 100644 --- a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx @@ -2,6 +2,7 @@ import { TorchGlow } from "@components/TorchGlow"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; +import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; import { getSessionService } from "@features/sessions/service/service"; import { cycleModeOption, @@ -36,9 +37,11 @@ export function TaskInput() { const editorRef = useRef(null); const containerRef = useRef(null); + const dragCounterRef = useRef(0); const runMode = "local"; const [editorIsEmpty, setEditorIsEmpty] = useState(true); + const [isDraggingFile, setIsDraggingFile] = useState(false); const selectedDirectory = lastUsedDirectory || ""; const workspaceMode = lastUsedLocalWorkspaceMode || "worktree"; @@ -105,6 +108,55 @@ export function TaskInput() { } }, [modeOption, allowBypassPermissions, previewTaskId]); + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current++; + if (e.dataTransfer.types.includes("Files")) { + setIsDraggingFile(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setIsDraggingFile(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setIsDraggingFile(false); + + // If dropped on the editor, Tiptap's handleDrop already handled it + if ((e.target as HTMLElement).closest(".ProseMirror")) return; + + const files = e.dataTransfer.files; + if (!files || files.length === 0) return; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const filePath = (file as File & { path?: string }).path; + if (filePath) { + editorRef.current?.addAttachment({ + id: filePath, + label: file.name, + }); + } + } + + editorRef.current?.focus(); + }, []); + return (
+ + + void; } -function contentToXml(content: EditorContent): string { - return content.segments - .map((seg) => { - if (seg.type === "text") return seg.text; - const chip = seg.chip; - switch (chip.type) { - case "file": - return ``; - case "command": - return `/${chip.label}`; - case "error": - return ``; - case "experiment": - return ``; - case "insight": - return ``; - case "feature_flag": - return ``; - default: - return `@${chip.label}`; - } - }) - .join(""); -} - -function extractFileMentionsFromContent(content: EditorContent): string[] { - const filePaths: string[] = []; - for (const seg of content.segments) { - if (seg.type === "chip" && seg.chip.type === "file") { - if (!filePaths.includes(seg.chip.id)) { - filePaths.push(seg.chip.id); - } - } - } - return filePaths; -} - function prepareTaskInput( - content: EditorContent, + content: Parameters[0], options: { selectedDirectory: string; selectedRepository?: string | null; @@ -87,7 +53,7 @@ function prepareTaskInput( ): TaskCreationInput { return { content: contentToXml(content).trim(), - filePaths: extractFileMentionsFromContent(content), + filePaths: extractFilePaths(content), repoPath: options.selectedDirectory, repository: options.selectedRepository, githubIntegrationId: options.githubIntegrationId, From 6ee6c5aa072c3e995c661731e21fee6fab3f1fe9 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 18 Feb 2026 02:54:27 -0800 Subject: [PATCH 2/6] Support files outside the repo in panels and prompt builder --- apps/twig/src/main/services/fs/schemas.ts | 4 ++ apps/twig/src/main/services/fs/service.ts | 11 ++++ apps/twig/src/main/trpc/routers/fs.ts | 6 ++ .../renderer/components/ui/PanelMessage.tsx | 20 +++++- .../components/CodeEditorPanel.tsx | 64 +++++++++++++++---- .../features/editor/utils/prompt-builder.ts | 28 ++++++-- .../panels/hooks/usePanelLayoutHooks.tsx | 3 +- 7 files changed, 114 insertions(+), 22 deletions(-) diff --git a/apps/twig/src/main/services/fs/schemas.ts b/apps/twig/src/main/services/fs/schemas.ts index ff3d649a6..fec9d26df 100644 --- a/apps/twig/src/main/services/fs/schemas.ts +++ b/apps/twig/src/main/services/fs/schemas.ts @@ -11,6 +11,10 @@ export const readRepoFileInput = z.object({ filePath: z.string(), }); +export const readAbsoluteFileInput = z.object({ + filePath: z.string(), +}); + export const writeRepoFileInput = z.object({ repoPath: z.string(), filePath: z.string(), diff --git a/apps/twig/src/main/services/fs/service.ts b/apps/twig/src/main/services/fs/service.ts index c817a2a01..ee104f1b8 100644 --- a/apps/twig/src/main/services/fs/service.ts +++ b/apps/twig/src/main/services/fs/service.ts @@ -97,6 +97,17 @@ export class FsService { } } + async readAbsoluteFile(filePath: string): Promise { + try { + return await fs.promises.readFile(path.resolve(filePath), "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + log.error(`Failed to read file ${filePath}:`, error); + } + return null; + } + } + async writeRepoFile( repoPath: string, filePath: string, diff --git a/apps/twig/src/main/trpc/routers/fs.ts b/apps/twig/src/main/trpc/routers/fs.ts index d35c7696a..45e5fc4a4 100644 --- a/apps/twig/src/main/trpc/routers/fs.ts +++ b/apps/twig/src/main/trpc/routers/fs.ts @@ -3,6 +3,7 @@ import { MAIN_TOKENS } from "../../di/tokens.js"; import { listRepoFilesInput, listRepoFilesOutput, + readAbsoluteFileInput, readRepoFileInput, readRepoFileOutput, writeRepoFileInput, @@ -27,6 +28,11 @@ export const fsRouter = router({ getService().readRepoFile(input.repoPath, input.filePath), ), + readAbsoluteFile: publicProcedure + .input(readAbsoluteFileInput) + .output(readRepoFileOutput) + .query(({ input }) => getService().readAbsoluteFile(input.filePath)), + writeRepoFile: publicProcedure .input(writeRepoFileInput) .mutation(({ input }) => diff --git a/apps/twig/src/renderer/components/ui/PanelMessage.tsx b/apps/twig/src/renderer/components/ui/PanelMessage.tsx index 1b4a16935..a0f68e38f 100644 --- a/apps/twig/src/renderer/components/ui/PanelMessage.tsx +++ b/apps/twig/src/renderer/components/ui/PanelMessage.tsx @@ -2,16 +2,32 @@ import { Box, Flex, Text } from "@radix-ui/themes"; interface PanelMessageProps { children: React.ReactNode; + detail?: string; color?: "gray" | "red"; } -export function PanelMessage({ children, color = "gray" }: PanelMessageProps) { +export function PanelMessage({ + children, + detail, + color = "gray", +}: PanelMessageProps) { return ( - + {children} + {detail && ( + + {detail} + + )} ); diff --git a/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index bb695aa3e..270e27fd8 100644 --- a/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -2,10 +2,26 @@ import { PanelMessage } from "@components/ui/PanelMessage"; import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor"; import { getRelativePath } from "@features/code-editor/utils/pathUtils"; import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { Box } from "@radix-ui/themes"; +import { Box, Flex } from "@radix-ui/themes"; import { trpcReact } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; +const IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "bmp", + "ico", +]); + +function isImageFile(filePath: string): boolean { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + return IMAGE_EXTENSIONS.has(ext); +} + interface CodeEditorPanelProps { taskId: string; task: Task; @@ -18,20 +34,42 @@ export function CodeEditorPanel({ absolutePath, }: CodeEditorPanelProps) { const repoPath = useCwd(taskId); + const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath); const filePath = getRelativePath(absolutePath, repoPath); + const isImage = isImageFile(absolutePath); + + const repoQuery = trpcReact.fs.readRepoFile.useQuery( + { repoPath: repoPath ?? "", filePath }, + { enabled: isInsideRepo && !isImage, staleTime: Infinity }, + ); - const { - data: fileContent, - isLoading, - error, - } = trpcReact.fs.readRepoFile.useQuery( - { repoPath: repoPath ?? "", filePath: filePath ?? "" }, - { - enabled: !!repoPath && !!filePath, - staleTime: Infinity, - }, + const absoluteQuery = trpcReact.fs.readAbsoluteFile.useQuery( + { filePath: absolutePath }, + { enabled: !isInsideRepo && !isImage, staleTime: Infinity }, ); + const { data: fileContent, isLoading, error } = isInsideRepo + ? repoQuery + : absoluteQuery; + + if (isImage) { + return ( + + {filePath} + + ); + } + if (!repoPath) { return No repository path available; } @@ -41,7 +79,9 @@ export function CodeEditorPanel({ } if (error || fileContent == null) { - return Failed to load file; + return ( + Failed to load file + ); } // If we ever allow editing in the CodeMirrorEditor, this can be removed diff --git a/apps/twig/src/renderer/features/editor/utils/prompt-builder.ts b/apps/twig/src/renderer/features/editor/utils/prompt-builder.ts index eef6740bc..cea8af5e0 100644 --- a/apps/twig/src/renderer/features/editor/utils/prompt-builder.ts +++ b/apps/twig/src/renderer/features/editor/utils/prompt-builder.ts @@ -18,6 +18,20 @@ function getMimeType(filePath: string): string { return mimeTypes[ext ?? ""] ?? "text/plain"; } +function isAbsolutePath(filePath: string): boolean { + return filePath.startsWith("/") || /^[a-zA-Z]:\\/.test(filePath); +} + +async function readFileContent( + filePath: string, + repoPath: string, +): Promise { + if (isAbsolutePath(filePath)) { + return trpcVanilla.fs.readAbsoluteFile.query({ filePath }); + } + return trpcVanilla.fs.readRepoFile.query({ repoPath, filePath }); +} + export async function buildPromptBlocks( textContent: string, filePaths: string[], @@ -41,18 +55,18 @@ export async function buildPromptBlocks( blocks.push({ type: "text", text: textContent }); - for (const relativePath of filePaths) { + for (const filePath of filePaths) { try { - const fileContent = await trpcVanilla.fs.readRepoFile.query({ - repoPath, - filePath: relativePath, - }); + const fileContent = await readFileContent(filePath, repoPath); if (fileContent) { + const uri = isAbsolutePath(filePath) + ? `file://${filePath}` + : `file://${repoPath}/${filePath}`; blocks.push({ type: "resource", resource: { - uri: `file://${repoPath}/${relativePath}`, - mimeType: getMimeType(relativePath), + uri, + mimeType: getMimeType(filePath), text: fileContent, }, }); diff --git a/apps/twig/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx b/apps/twig/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx index e4d1ad7df..01af1167c 100644 --- a/apps/twig/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/apps/twig/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx @@ -84,7 +84,8 @@ export function useTabInjection( tabs.map((tab) => { let updatedData = tab.data; if (tab.data.type === "file" || tab.data.type === "diff") { - const absolutePath = `${repoPath}/${tab.data.relativePath}`; + const rp = tab.data.relativePath; + const absolutePath = rp.startsWith("/") ? rp : `${repoPath}/${rp}`; updatedData = { ...tab.data, absolutePath, From 42cf9215e6d5001d8a6af8558160deb74f73996b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 18 Feb 2026 03:03:51 -0800 Subject: [PATCH 3/6] lint --- apps/twig/src/main/trpc/routers/os.ts | 5 ++++- .../code-editor/components/CodeEditorPanel.tsx | 8 +++++--- .../components/ImageAttachmentsBar.tsx | 4 ++-- .../message-editor/components/MessageEditor.tsx | 2 +- .../message-editor/tiptap/MentionChipView.tsx | 10 ++-------- .../message-editor/tiptap/useTiptapEditor.ts | 12 ++++++++++-- .../src/renderer/features/message-editor/types.ts | 6 +++++- .../features/message-editor/utils/content.ts | 6 +++++- .../features/task-detail/components/TaskInput.tsx | 1 + .../task-detail/components/TaskInputEditor.tsx | 5 ++++- 10 files changed, 39 insertions(+), 20 deletions(-) diff --git a/apps/twig/src/main/trpc/routers/os.ts b/apps/twig/src/main/trpc/routers/os.ts index 33a222c50..e4f0c6398 100644 --- a/apps/twig/src/main/trpc/routers/os.ts +++ b/apps/twig/src/main/trpc/routers/os.ts @@ -158,7 +158,10 @@ export const osRouter = router({ .input( z.object({ filePath: z.string(), - maxSizeBytes: z.number().optional().default(10 * 1024 * 1024), + maxSizeBytes: z + .number() + .optional() + .default(10 * 1024 * 1024), }), ) .query(async ({ input }) => { diff --git a/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index 270e27fd8..4a045d7ad 100644 --- a/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/twig/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -48,9 +48,11 @@ export function CodeEditorPanel({ { enabled: !isInsideRepo && !isImage, staleTime: Infinity }, ); - const { data: fileContent, isLoading, error } = isInsideRepo - ? repoQuery - : absoluteQuery; + const { + data: fileContent, + isLoading, + error, + } = isInsideRepo ? repoQuery : absoluteQuery; if (isImage) { return ( diff --git a/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx b/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx index 742389c3d..c49994f52 100644 --- a/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/ImageAttachmentsBar.tsx @@ -39,7 +39,7 @@ function ImageThumbnail({