Skip to content

Commit 7e1857f

Browse files
committed
Implement proper file attachments and attachment previews
1 parent 5686511 commit 7e1857f

File tree

16 files changed

+509
-134
lines changed

16 files changed

+509
-134
lines changed

apps/twig/src/main/trpc/routers/os.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,44 @@ export const osRouter = router({
150150
*/
151151
getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()),
152152

153+
/**
154+
* Read a file and return it as a base64 data URL
155+
* Used for image thumbnails in the editor
156+
*/
157+
readFileAsDataUrl: publicProcedure
158+
.input(
159+
z.object({
160+
filePath: z.string(),
161+
maxSizeBytes: z.number().optional().default(10 * 1024 * 1024),
162+
}),
163+
)
164+
.query(async ({ input }) => {
165+
try {
166+
const stat = await fsPromises.stat(input.filePath);
167+
if (stat.size > input.maxSizeBytes) return null;
168+
169+
const ext = path.extname(input.filePath).toLowerCase().slice(1);
170+
const mimeMap: Record<string, string> = {
171+
png: "image/png",
172+
jpg: "image/jpeg",
173+
jpeg: "image/jpeg",
174+
gif: "image/gif",
175+
webp: "image/webp",
176+
bmp: "image/bmp",
177+
ico: "image/x-icon",
178+
svg: "image/svg+xml",
179+
tiff: "image/tiff",
180+
tif: "image/tiff",
181+
};
182+
const mime = mimeMap[ext] ?? "application/octet-stream";
183+
184+
const buffer = await fsPromises.readFile(input.filePath);
185+
return `data:${mime};base64,${buffer.toString("base64")}`;
186+
} catch {
187+
return null;
188+
}
189+
}),
190+
153191
/**
154192
* Save clipboard image data to a temp file
155193
* Returns the file path for use as a file attachment

apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningL
33
import { Paperclip } from "@phosphor-icons/react";
44
import { Flex, IconButton, Tooltip } from "@radix-ui/themes";
55
import { useRef } from "react";
6-
import type { MentionChip } from "../utils/content";
6+
import type { FileAttachment } from "../utils/content";
77

88
interface EditorToolbarProps {
99
disabled?: boolean;
1010
taskId?: string;
1111
adapter?: "claude" | "codex";
12-
onInsertChip: (chip: MentionChip) => void;
12+
onAddAttachment: (attachment: FileAttachment) => void;
1313
onAttachFiles?: (files: File[]) => void;
1414
attachTooltip?: string;
1515
iconSize?: number;
@@ -21,7 +21,7 @@ export function EditorToolbar({
2121
disabled = false,
2222
taskId,
2323
adapter,
24-
onInsertChip,
24+
onAddAttachment,
2525
onAttachFiles,
2626
attachTooltip = "Attach file",
2727
iconSize = 14,
@@ -35,11 +35,7 @@ export function EditorToolbar({
3535
const fileArray = Array.from(files);
3636
for (const file of fileArray) {
3737
const filePath = (file as File & { path?: string }).path || file.name;
38-
onInsertChip({
39-
type: "file",
40-
id: filePath,
41-
label: file.name,
42-
});
38+
onAddAttachment({ id: filePath, label: file.name });
4339
}
4440
onAttachFiles?.(fileArray);
4541
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { X } from "@phosphor-icons/react";
2+
import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes";
3+
import { trpcVanilla } from "@renderer/trpc/client";
4+
import { useEffect, useState } from "react";
5+
import type { FileAttachment } from "../utils/content";
6+
import { isImageFile } from "../utils/imageUtils";
7+
8+
function useDataUrl(filePath: string) {
9+
const [dataUrl, setDataUrl] = useState<string | null>(null);
10+
11+
useEffect(() => {
12+
let cancelled = false;
13+
trpcVanilla.os.readFileAsDataUrl
14+
.query({ filePath })
15+
.then((url) => {
16+
if (!cancelled) setDataUrl(url);
17+
})
18+
.catch(() => {});
19+
return () => {
20+
cancelled = true;
21+
};
22+
}, [filePath]);
23+
24+
return dataUrl;
25+
}
26+
27+
function ImageThumbnail({
28+
attachment,
29+
onRemove,
30+
}: {
31+
attachment: FileAttachment;
32+
onRemove: () => void;
33+
}) {
34+
const dataUrl = useDataUrl(attachment.id);
35+
36+
return (
37+
<Dialog.Root>
38+
<div className="group relative flex-shrink-0">
39+
<Dialog.Trigger>
40+
<button
41+
type="button"
42+
className="inline-flex items-center gap-1 rounded-[var(--radius-1)] bg-[var(--gray-a3)] p-1 text-[10px] leading-tight font-medium text-[var(--gray-11)] hover:bg-[var(--gray-a4)]"
43+
>
44+
{dataUrl ? (
45+
<img
46+
src={dataUrl}
47+
alt={attachment.label}
48+
className="size-3.5 rounded-sm object-cover"
49+
/>
50+
) : (
51+
<span className="size-3.5 rounded-sm bg-[var(--gray-a5)]" />
52+
)}
53+
<span className="max-w-[80px] truncate">{attachment.label}</span>
54+
</button>
55+
</Dialog.Trigger>
56+
<IconButton
57+
size="1"
58+
variant="solid"
59+
color="gray"
60+
className="!absolute -top-1 -right-1 !size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
61+
onClick={(e) => {
62+
e.stopPropagation();
63+
onRemove();
64+
}}
65+
>
66+
<X size={8} weight="bold" />
67+
</IconButton>
68+
</div>
69+
<Dialog.Content maxWidth="90vw" style={{ padding: 16 }}>
70+
<Dialog.Title size="2" mb="2">
71+
{attachment.label}
72+
</Dialog.Title>
73+
{dataUrl ? (
74+
<img
75+
src={dataUrl}
76+
alt={attachment.label}
77+
style={{
78+
maxWidth: "85vw",
79+
maxHeight: "75vh",
80+
objectFit: "contain",
81+
}}
82+
/>
83+
) : (
84+
<Text size="2" color="gray">
85+
Unable to load image preview
86+
</Text>
87+
)}
88+
</Dialog.Content>
89+
</Dialog.Root>
90+
);
91+
}
92+
93+
function FileChip({
94+
attachment,
95+
onRemove,
96+
}: {
97+
attachment: FileAttachment;
98+
onRemove: () => void;
99+
}) {
100+
return (
101+
<div className="group relative flex-shrink-0">
102+
<span className="inline-flex items-center gap-1 rounded-[var(--radius-1)] bg-[var(--gray-a3)] p-1 text-[10px] leading-tight font-medium text-[var(--gray-11)]">
103+
<span className="max-w-[120px] truncate">@{attachment.label}</span>
104+
</span>
105+
<IconButton
106+
size="1"
107+
variant="solid"
108+
color="gray"
109+
className="!absolute -top-1 -right-1 !size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
110+
onClick={(e) => {
111+
e.stopPropagation();
112+
onRemove();
113+
}}
114+
>
115+
<X size={8} weight="bold" />
116+
</IconButton>
117+
</div>
118+
);
119+
}
120+
121+
interface AttachmentsBarProps {
122+
attachments: FileAttachment[];
123+
onRemove: (id: string) => void;
124+
}
125+
126+
export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) {
127+
if (attachments.length === 0) return null;
128+
129+
return (
130+
<Flex gap="1" align="center" className="mb-2 flex-wrap">
131+
{attachments.map((att) =>
132+
isImageFile(att.label) ? (
133+
<ImageThumbnail
134+
key={att.id}
135+
attachment={att}
136+
onRemove={() => onRemove(att.id)}
137+
/>
138+
) : (
139+
<FileChip
140+
key={att.id}
141+
attachment={att}
142+
onRemove={() => onRemove(att.id)}
143+
/>
144+
),
145+
)}
146+
</Flex>
147+
);
148+
}

apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useTiptapEditor } from "../tiptap/useTiptapEditor";
1111
import type { EditorHandle } from "../types";
1212
import type { EditorContent as EditorContentType } from "../utils/content";
1313
import { AdapterIndicator } from "./AdapterIndicator";
14+
import { AttachmentsBar } from "./ImageAttachmentsBar";
1415
import { DiffStatsIndicator } from "./DiffStatsIndicator";
1516
import { EditorToolbar } from "./EditorToolbar";
1617
import { ModeIndicatorInput } from "./ModeIndicatorInput";
@@ -76,6 +77,9 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
7677
getContent,
7778
setContent,
7879
insertChip,
80+
attachments,
81+
addAttachment,
82+
removeAttachment,
7983
} = useTiptapEditor({
8084
sessionId,
8185
taskId,
@@ -103,6 +107,8 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
103107
getText,
104108
setContent,
105109
insertChip,
110+
addAttachment,
111+
removeAttachment,
106112
}),
107113
[
108114
focus,
@@ -113,6 +119,8 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
113119
getText,
114120
setContent,
115121
insertChip,
122+
addAttachment,
123+
removeAttachment,
116124
],
117125
);
118126

@@ -151,6 +159,8 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
151159
onClick={handleContainerClick}
152160
style={{ cursor: "text" }}
153161
>
162+
<AttachmentsBar attachments={attachments} onRemove={removeAttachment} />
163+
154164
<div className="max-h-[200px] min-h-[50px] flex-1 overflow-y-auto font-mono text-sm">
155165
<EditorContent editor={editor} />
156166
</div>
@@ -160,7 +170,7 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
160170
<EditorToolbar
161171
disabled={disabled}
162172
taskId={taskId}
163-
onInsertChip={insertChip}
173+
onAddAttachment={addAttachment}
164174
onAttachFiles={onAttachFiles}
165175
/>
166176
{isBashMode && (

apps/twig/src/renderer/features/message-editor/components/message-editor.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@
66
float: left;
77
height: 0;
88
}
9+
10+
/* Ensure Tiptap NodeView wrappers render inline for mention chips */
11+
.cli-editor [data-node-view-wrapper] {
12+
display: inline;
13+
}

apps/twig/src/renderer/features/message-editor/tiptap/MentionChipNode.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { mergeAttributes, Node } from "@tiptap/core";
2+
import { ReactNodeViewRenderer } from "@tiptap/react";
3+
import { MentionChipView } from "./MentionChipView";
24

35
export type ChipType =
46
| "file"
@@ -60,6 +62,12 @@ export const MentionChipNode = Node.create({
6062
];
6163
},
6264

65+
addNodeView() {
66+
return ReactNodeViewRenderer(MentionChipView, {
67+
contentDOMElementTag: "span",
68+
});
69+
},
70+
6371
addCommands() {
6472
return {
6573
insertMentionChip:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
2+
import type { MentionChipAttrs } from "./MentionChipNode";
3+
4+
function DefaultChip({
5+
type,
6+
label,
7+
}: {
8+
type: string;
9+
label: string;
10+
}) {
11+
const isCommand = type === "command";
12+
const prefix = isCommand ? "/" : "@";
13+
14+
return (
15+
<span
16+
className={`${isCommand ? "cli-slash-command" : "cli-file-mention"} inline select-all cursor-default rounded-[var(--radius-1)] bg-[var(--accent-a3)] px-1 py-px text-xs font-medium text-[var(--accent-11)]`}
17+
contentEditable={false}
18+
>
19+
{prefix}
20+
{label}
21+
</span>
22+
);
23+
}
24+
25+
export function MentionChipView({ node }: NodeViewProps) {
26+
const { type, label } = node.attrs as MentionChipAttrs;
27+
28+
return (
29+
<NodeViewWrapper as="span" className="inline">
30+
<DefaultChip type={type} label={label} />
31+
</NodeViewWrapper>
32+
);
33+
}

0 commit comments

Comments
 (0)