History

Add image paste drag drop dialog

Julius Marminge committed 9 months ago

Commit 3cf58f81 parent 44f3b3c11 files · +725 −42Browse files

Unified · Split

11 files
  1. apps/server/src/codexAppServerManager.test.ts +115 −1
  2. apps/server/src/codexAppServerManager.ts +26 −8
  3. apps/web/src/components/ChatView.tsx +352 −22
  4. apps/web/src/historyBootstrap.test.ts +27 −0
  5. apps/web/src/historyBootstrap.ts +28 −3
  6. apps/web/src/persistenceSchema.test.ts +26 −1
  7. apps/web/src/persistenceSchema.ts +40 −5
  8. apps/web/src/store.ts +9 −1
  9. apps/web/src/types.ts +9 −0
  10. packages/contracts/src/provider.test.ts +56 −0
  11. packages/contracts/src/provider.ts +37 −1
apps/server/src/codexAppServerManager.test.ts+115−1
@@ -1,11 +1,42 @@
1−import { describe, expect, it } from "vitest";
1+import { describe, expect, it, vi } from "vitest";
22
33 import {
4+ CodexAppServerManager,
45 classifyCodexStderrLine,
56 isRecoverableThreadResumeError,
67 normalizeCodexModelSlug,
78 } from "./codexAppServerManager";
89
10+function createSendTurnHarness() {
11+ const manager = new CodexAppServerManager();
12+ const context = {
13+ session: {
14+ sessionId: "sess_1",
15+ provider: "codex",
16+ status: "ready",
17+ threadId: "thread_1",
18+ createdAt: "2026-02-10T00:00:00.000Z",
19+ updatedAt: "2026-02-10T00:00:00.000Z",
20+ },
21+ };
22+
23+ const requireSession = vi
24+ .spyOn(manager as unknown as { requireSession: (sessionId: string) => unknown }, "requireSession")
25+ .mockReturnValue(context);
26+ const sendRequest = vi
27+ .spyOn(manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> }, "sendRequest")
28+ .mockResolvedValue({
29+ turn: {
30+ id: "turn_1",
31+ },
32+ });
33+ const updateSession = vi
34+ .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
35+ .mockImplementation(() => {});
36+
37+ return { manager, context, requireSession, sendRequest, updateSession };
38+}
39+
940 describe("classifyCodexStderrLine", () => {
1041 it("ignores empty lines", () => {
1142 expect(classifyCodexStderrLine(" ")).toBeNull();
@@ -75,3 +106,86 @@ describe("isRecoverableThreadResumeError", () => { · 63 unmodified lines
75106 ).toBe(false);
76107 });
77108 });
109+
110+describe("sendTurn", () => {
111+ it("sends text and image user input items to turn/start", async () => {
112+ const { manager, context, requireSession, sendRequest, updateSession } = createSendTurnHarness();
113+
114+ const result = await manager.sendTurn({
115+ sessionId: "sess_1",
116+ input: "Inspect this image",
117+ images: [
118+ {
119+ name: "error.png",
120+ mimeType: "image/png",
121+ sizeBytes: 1_024,
122+ dataUrl: "data:image/png;base64,AAAA",
123+ },
124+ ],
125+ model: "gpt-5.3",
126+ effort: "high",
127+ });
128+
129+ expect(result).toEqual({
130+ threadId: "thread_1",
131+ turnId: "turn_1",
132+ });
133+ expect(requireSession).toHaveBeenCalledWith("sess_1");
134+ expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
135+ threadId: "thread_1",
136+ input: [
137+ {
138+ type: "text",
139+ text: "Inspect this image",
140+ text_elements: [],
141+ },
142+ {
143+ type: "image",
144+ url: "data:image/png;base64,AAAA",
145+ },
146+ ],
147+ model: "gpt-5.3-codex",
148+ effort: "high",
149+ });
150+ expect(updateSession).toHaveBeenCalledWith(context, {
151+ status: "running",
152+ activeTurnId: "turn_1",
153+ });
154+ });
155+
156+ it("supports image-only turns", async () => {
157+ const { manager, context, sendRequest } = createSendTurnHarness();
158+
159+ await manager.sendTurn({
160+ sessionId: "sess_1",
161+ images: [
162+ {
163+ name: "diagram.png",
164+ mimeType: "image/png",
165+ sizeBytes: 256,
166+ dataUrl: "data:image/png;base64,BBBB",
167+ },
168+ ],
169+ });
170+
171+ expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
172+ threadId: "thread_1",
173+ input: [
174+ {
175+ type: "image",
176+ url: "data:image/png;base64,BBBB",
177+ },
178+ ],
179+ });
180+ });
181+
182+ it("rejects empty turn input", async () => {
183+ const { manager } = createSendTurnHarness();
184+
185+ await expect(
186+ manager.sendTurn({
187+ sessionId: "sess_1",
188+ }),
189+ ).rejects.toThrow("Turn input must include text or image attachments.");
190+ });
191+});
apps/server/src/codexAppServerManager.ts+26−8
@@ -257,20 +257,38 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 256 unmodified lines
257257 throw new Error("Session is missing a thread id.");
258258 }
259259
260+ const turnInput: Array<
261+ | { type: "text"; text: string; text_elements: [] }
262+ | { type: "image"; url: string }
263+ > = [];
264+ if (input.input) {
265+ turnInput.push({
266+ type: "text",
267+ text: input.input,
268+ text_elements: [],
269+ });
270+ }
271+ for (const image of input.images ?? []) {
272+ turnInput.push({
273+ type: "image",
274+ url: image.dataUrl,
275+ });
276+ }
277+ if (turnInput.length === 0) {
278+ throw new Error("Turn input must include text or image attachments.");
279+ }
280+
260281 const turnStartParams: {
261282 threadId: string;
262− input: Array<{ type: "text"; text: string; text_elements: [] }>;
283+ input: Array<
284+ | { type: "text"; text: string; text_elements: [] }
285+ | { type: "image"; url: string }
286+ >;
263287 model?: string;
264288 effort?: string;
265289 } = {
266290 threadId: context.session.threadId,
267− input: [
268− {
269− type: "text",
270− text: input.input,
271− text_elements: [],
272− },
273− ],
291+ input: turnInput,
274292 };
275293 const normalizedModel = normalizeCodexModelSlug(input.model);
276294 if (normalizedModel) {
apps/web/src/components/ChatView.tsx+352−22
@@ -1,9 +1,14 @@
11 import {
22 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3+ PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4+ PROVIDER_SEND_TURN_MAX_IMAGES,
5+ type ProviderSendTurnImageInput,
36 type ProviderApprovalDecision,
47 type ProviderEvent,
58 } from "@t3tools/contracts";
69 import {
10+ type ClipboardEvent,
11+ type DragEvent,
712 type FormEvent,
813 Fragment,
914 type KeyboardEvent,
@@ -33,6 +38,7 @@ import { · 23 unmodified lines
3338 formatTimestamp,
3439 } from "../session-logic";
3540 import { useStore } from "../store";
41+import type { ChatImageAttachment } from "../types";
3642 import BranchToolbar from "./BranchToolbar";
3743 import GitActionsControl from "./GitActionsControl";
3844 import { isTerminalToggleShortcut } from "../terminal-shortcuts";
@@ -57,6 +63,9 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { · 18 unmodified lines
5763
5864 const LAST_EDITOR_KEY = "t3code:last-editor";
5965 const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
66+const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
67+const IMAGE_ONLY_BOOTSTRAP_PROMPT =
68+ "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
6069
6170 function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
6271 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
@@ -80,6 +89,16 @@ interface EnsuredSessionInfo { · 17 unmodified lines
8089 continuityState: SessionContinuityState;
8190 }
8291
92+interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
93+ previewUrl: string;
94+ file: File;
95+}
96+
97+interface ExpandedImagePreview {
98+ src: string;
99+ name: string;
100+}
101+
83102 function asRecord(value: unknown): Record<string, unknown> | undefined {
84103 if (!value || typeof value !== "object") return undefined;
85104 return value as Record<string, unknown>;
@@ -131,10 +150,30 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] · 45 unmodified lines
131150 return Array.from(pending.values());
132151 }
133152
153+function readFileAsDataUrl(file: File): Promise<string> {
154+ return new Promise((resolve, reject) => {
155+ const reader = new FileReader();
156+ reader.addEventListener("load", () => {
157+ if (typeof reader.result === "string") {
158+ resolve(reader.result);
159+ return;
160+ }
161+ reject(new Error("Could not read image data."));
162+ });
163+ reader.addEventListener("error", () => {
164+ reject(reader.error ?? new Error("Failed to read image."));
165+ });
166+ reader.readAsDataURL(file);
167+ });
168+}
169+
134170 export default function ChatView() {
135171 const { state, dispatch } = useStore();
136172 const api = useNativeApi();
137173 const [prompt, setPrompt] = useState("");
174+ const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
175+ const [isDragOverComposer, setIsDragOverComposer] = useState(false);
176+ const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
138177 const [isSending, setIsSending] = useState(false);
139178 const [isConnecting, setIsConnecting] = useState(false);
140179 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
@@ -152,6 +191,8 @@ export default function ChatView() { · 11 unmodified lines
152191 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
153192 const messagesEndRef = useRef<HTMLDivElement>(null);
154193 const textareaRef = useRef<HTMLTextAreaElement>(null);
194+ const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
195+ const dragDepthRef = useRef(0);
155196 const modelMenuRef = useRef<HTMLDivElement>(null);
156197 const editorMenuRef = useRef<HTMLDivElement>(null);
157198 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
@@ -264,7 +305,15 @@ export default function ChatView() { · 106 unmodified lines
264305 (activeThread.messages.length > 0 ||
265306 (activeThread.session !== null && activeThread.session.status !== "closed")),
266307 );
267− const terminalShortcutHint = navigator.platform.includes("Mac") ? "\u2318J" : "Ctrl+J";
308+ const terminalShortcutHint = navigator.platform.includes("Mac")
309+ ? "\u2318J"
310+ : "Ctrl+J";
311+ const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
312+ for (const image of images) {
313+ if (!image.previewUrl) continue;
314+ URL.revokeObjectURL(image.previewUrl);
315+ }
316+ }, []);
268317 const focusComposer = useCallback(() => {
269318 const textarea = textareaRef.current;
270319 if (!textarea) return;
@@ -319,6 +368,44 @@ export default function ChatView() { · 48 unmodified lines
319368 setExpandedWorkGroups({});
320369 }, [activeThread?.id]);
321370
371+ useEffect(() => {
372+ composerImagesRef.current = composerImages;
373+ }, [composerImages]);
374+
375+ useEffect(() => {
376+ setComposerImages((existing) => {
377+ revokePreviewUrls(existing);
378+ return [];
379+ });
380+ dragDepthRef.current = 0;
381+ setIsDragOverComposer(false);
382+ setExpandedImage(null);
383+ }, [activeThread?.id, revokePreviewUrls]);
384+
385+ useEffect(() => {
386+ return () => {
387+ revokePreviewUrls(composerImagesRef.current);
388+ };
389+ }, [revokePreviewUrls]);
390+
391+ useEffect(() => {
392+ if (!expandedImage) {
393+ return;
394+ }
395+
396+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
397+ if (event.key !== "Escape") {
398+ return;
399+ }
400+ setExpandedImage(null);
401+ };
402+
403+ window.addEventListener("keydown", onKeyDown);
404+ return () => {
405+ window.removeEventListener("keydown", onKeyDown);
406+ };
407+ }, [expandedImage]);
408+
322409 const activeWorktreePath = activeThread?.worktreePath;
323410
324411 useEffect(() => {
@@ -431,6 +518,124 @@ export default function ChatView() { · 106 unmodified lines
431518 setIsEditorMenuOpen(false);
432519 };
433520
521+ const setThreadError = (threadId: string | null, error: string | null) => {
522+ if (!threadId) return;
523+ dispatch({
524+ type: "SET_ERROR",
525+ threadId,
526+ error,
527+ });
528+ };
529+
530+ const addComposerImages = (files: File[]) => {
531+ if (!activeThreadId || files.length === 0) return;
532+
533+ const nextImages = [...composerImagesRef.current];
534+ let error: string | null = null;
535+ for (const file of files) {
536+ if (!file.type.startsWith("image/")) {
537+ error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
538+ continue;
539+ }
540+ if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
541+ error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
542+ continue;
543+ }
544+ if (nextImages.length >= PROVIDER_SEND_TURN_MAX_IMAGES) {
545+ error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_IMAGES} images per message.`;
546+ break;
547+ }
548+
549+ const previewUrl = URL.createObjectURL(file);
550+ nextImages.push({
551+ id: crypto.randomUUID(),
552+ name: file.name || "image",
553+ mimeType: file.type,
554+ sizeBytes: file.size,
555+ previewUrl,
556+ file,
557+ });
558+ }
559+
560+ setComposerImages(nextImages);
561+ setThreadError(activeThreadId, error);
562+ };
563+
564+ const removeComposerImage = (imageId: string) => {
565+ setComposerImages((existing) => {
566+ const match = existing.find((image) => image.id === imageId);
567+ if (match?.previewUrl) {
568+ URL.revokeObjectURL(match.previewUrl);
569+ }
570+ return existing.filter((image) => image.id !== imageId);
571+ });
572+ };
573+
574+ const openImageDialog = (src: string | undefined, name: string) => {
575+ if (!src) {
576+ return;
577+ }
578+ setExpandedImage({ src, name });
579+ };
580+
581+ const onComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
582+ const files = Array.from(event.clipboardData.files);
583+ if (files.length === 0) {
584+ return;
585+ }
586+ const imageFiles = files.filter((file) => file.type.startsWith("image/"));
587+ if (imageFiles.length === 0) {
588+ return;
589+ }
590+ event.preventDefault();
591+ addComposerImages(imageFiles);
592+ };
593+
594+ const onComposerDragEnter = (event: DragEvent<HTMLDivElement>) => {
595+ if (!event.dataTransfer.types.includes("Files")) {
596+ return;
597+ }
598+ event.preventDefault();
599+ dragDepthRef.current += 1;
600+ setIsDragOverComposer(true);
601+ };
602+
603+ const onComposerDragOver = (event: DragEvent<HTMLDivElement>) => {
604+ if (!event.dataTransfer.types.includes("Files")) {
605+ return;
606+ }
607+ event.preventDefault();
608+ event.dataTransfer.dropEffect = "copy";
609+ setIsDragOverComposer(true);
610+ };
611+
612+ const onComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
613+ if (!event.dataTransfer.types.includes("Files")) {
614+ return;
615+ }
616+ event.preventDefault();
617+ const nextTarget = event.relatedTarget;
618+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
619+ return;
620+ }
621+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
622+ if (dragDepthRef.current === 0) {
623+ setIsDragOverComposer(false);
624+ }
625+ };
626+
627+ const onComposerDrop = (event: DragEvent<HTMLDivElement>) => {
628+ if (!event.dataTransfer.types.includes("Files")) {
629+ return;
630+ }
631+ event.preventDefault();
632+ dragDepthRef.current = 0;
633+ setIsDragOverComposer(false);
634+ const files = Array.from(event.dataTransfer.files);
635+ addComposerImages(files);
636+ focusComposer();
637+ };
638+
434639 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
435640 if (!api || !activeThread || !activeProject) return null;
436641 if (activeThread.session && activeThread.session.status !== "closed") {
@@ -492,8 +697,9 @@ export default function ChatView() { · 55 unmodified lines
492697 e.preventDefault();
493698 if (!api || !activeThread || isSending || isConnecting) return;
494699 const trimmed = prompt.trim();
495− if (!trimmed) return;
700+ if (!trimmed && composerImages.length === 0) return;
496701 if (!activeProject) return;
702+ const composerImagesSnapshot = [...composerImages];
497703
498704 // On first message: lock in branch + create worktree if needed.
499705 let sessionCwd: string | undefined;
@@ -529,7 +735,12 @@ export default function ChatView() { · 29 unmodified lines
529735
530736 // Auto-title from first message
531737 if (activeThread.messages.length === 0) {
532− const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed;
738+ const titleSeed =
739+ trimmed ||
740+ (composerImagesSnapshot.length > 0
741+ ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
742+ : "New thread");
743+ const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed;
533744 dispatch({
534745 type: "SET_THREAD_TITLE",
535746 threadId: activeThread.id,
@@ -537,43 +748,61 @@ export default function ChatView() { · 1 unmodified line
537748 });
538749 }
539750
540− dispatch({
541− type: "SET_ERROR",
542− threadId: activeThread.id,
543− error: null,
544− });
751+ setThreadError(activeThread.id, null);
752+ const messageImages: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({
753+ id: image.id,
754+ name: image.name,
755+ mimeType: image.mimeType,
756+ sizeBytes: image.sizeBytes,
757+ previewUrl: image.previewUrl,
758+ }));
545759 dispatch({
546760 type: "PUSH_USER_MESSAGE",
547761 threadId: activeThread.id,
548762 id: crypto.randomUUID(),
549763 text: trimmed,
764+ ...(messageImages.length > 0 ? { images: messageImages } : {}),
550765 });
551766 const previousMessages = activeThread.messages;
552767 setPrompt("");
768+ setComposerImages([]);
553769
554770 const sessionInfo = await ensureSession(sessionCwd);
555771 if (!sessionInfo) return;
556772
557773 setIsSending(true);
558774 try {
775+ const turnImages = await Promise.all(
776+ composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnImageInput> => ({
777+ name: image.name,
778+ mimeType: image.mimeType,
779+ sizeBytes: image.sizeBytes,
780+ dataUrl: await readFileAsDataUrl(image.file),
781+ })),
782+ );
559783 const shouldBootstrap =
560784 previousMessages.length > 0 &&
561785 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
786+ const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
562787 const input = shouldBootstrap
563− ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text
564− : trimmed;
788+ ? buildBootstrapInput(
789+ previousMessages,
790+ latestPromptForBootstrap,
791+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
792+ ).text
793+ : (trimmed || undefined);
565794 await api.providers.sendTurn({
566795 sessionId: sessionInfo.sessionId,
567− input,
796+ ...(input ? { input } : {}),
797+ ...(turnImages.length > 0 ? { images: turnImages } : {}),
568798 model: selectedModel || undefined,
569799 effort: selectedEffort || undefined,
570800 });
571801 } catch (err) {
572− dispatch({
573− type: "SET_ERROR",
574− threadId: activeThread.id,
575− error: err instanceof Error ? err.message : "Failed to send message.",
576− });
802+ setThreadError(
803+ activeThread.id,
804+ err instanceof Error ? err.message : "Failed to send message.",
805+ );
577806 } finally {
578807 setIsSending(false);
579808 }
@@ -876,13 +1105,39 @@ export default function ChatView() { · 296 unmodified lines
8761105 }
8771106
8781107 if (timelineEntry.message.role === "user") {
1108+ const userImages = timelineEntry.message.images ?? [];
8791109 return (
8801110 <Fragment key={timelineEntry.id}>
8811111 <div className="flex justify-end">
8821112 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
883− <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
884− {timelineEntry.message.text}
885− </pre>
1113+ {userImages.length > 0 && (
1114+ <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1115+ {userImages.map((image) => (
1116+ <div
1117+ key={image.id}
1118+ className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1119+ >
1120+ {image.previewUrl ? (
1121+ <img
1122+ src={image.previewUrl}
1123+ alt={image.name}
1124+ className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1125+ onClick={() => openImageDialog(image.previewUrl, image.name)}
1126+ />
1127+ ) : (
1128+ <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1129+ {image.name}
1130+ </div>
1131+ )}
1132+ </div>
1133+ ))}
1134+ </div>
1135+ )}
1136+ {timelineEntry.message.text && (
1137+ <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
1138+ {timelineEntry.message.text}
1139+ </pre>
1140+ )}
8861141 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
8871142 {formatTimestamp(timelineEntry.message.createdAt)}
8881143 </p>
@@ -945,9 +1200,48 @@ export default function ChatView() { · 56 unmodified lines
9451200 {/* Input bar */}
9461201 <div className="px-5 pb-1 pt-2">
9471202 <form onSubmit={onSend} className="mx-auto max-w-3xl">
948− <div className="group rounded-[20px] border border-border bg-card transition-colors duration-200 focus-within:border-ring">
1203+ <div
1204+ className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
1205+ isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
1206+ }`}
1207+ onDragEnter={onComposerDragEnter}
1208+ onDragOver={onComposerDragOver}
1209+ onDragLeave={onComposerDragLeave}
1210+ onDrop={onComposerDrop}
1211+ >
9491212 {/* Textarea area */}
9501213 <div className="px-4 pt-4 pb-2">
1214+ {composerImages.length > 0 && (
1215+ <div className="mb-3 flex flex-wrap gap-2">
1216+ {composerImages.map((image) => (
1217+ <div
1218+ key={image.id}
1219+ className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
1220+ >
1221+ {image.previewUrl ? (
1222+ <img
1223+ src={image.previewUrl}
1224+ alt={image.name}
1225+ className="h-full w-full cursor-zoom-in object-cover"
1226+ onClick={() => openImageDialog(image.previewUrl, image.name)}
1227+ />
1228+ ) : (
1229+ <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
1230+ {image.name}
1231+ </div>
1232+ )}
1233+ <button
1234+ type="button"
1235+ className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-background/80 text-[11px] leading-none text-foreground transition-colors duration-150 hover:bg-background"
1236+ onClick={() => removeComposerImage(image.id)}
1237+ aria-label={`Remove ${image.name}`}
1238+ >
1239+ ×
1240+ </button>
1241+ </div>
1242+ ))}
1243+ </div>
1244+ )}
9511245 <textarea
9521246 ref={textareaRef}
9531247 className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none"
@@ -955,8 +1249,11 @@ export default function ChatView() { · 1 unmodified line
9551249 value={prompt}
9561250 onChange={(e) => setPrompt(e.target.value)}
9571251 onKeyDown={onKeyDown}
1252+ onPaste={onComposerPaste}
9581253 placeholder={
959− phase === "disconnected" ? "Ask for follow-up changes" : "Ask anything..."
1254+ phase === "disconnected"
1255+ ? "Ask for follow-up changes or attach images"
1256+ : "Ask anything, or attach images..."
9601257 }
9611258 disabled={isSending || isConnecting}
9621259 />
@@ -1146,7 +1443,7 @@ export default function ChatView() { · 183 unmodified lines
11461443 <button
11471444 type="submit"
11481445 className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100"
1149− disabled={isSending || isConnecting || !prompt.trim()}
1446+ disabled={isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)}
11501447 aria-label={
11511448 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
11521449 }
@@ -1221,6 +1518,39 @@ export default function ChatView() { · 68 unmodified lines
12211518 }
12221519 />
12231520 )}
1521+
1522+ {expandedImage && (
1523+ <div
1524+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
1525+ role="dialog"
1526+ aria-modal="true"
1527+ aria-label="Expanded image preview"
1528+ onClick={() => setExpandedImage(null)}
1529+ >
1530+ <div
1531+ className="relative isolate max-h-[92vh] max-w-[92vw]"
1532+ onClick={(event) => event.stopPropagation()}
1533+ >
1534+ <button
1535+ type="button"
1536+ className="absolute right-2 top-2 z-30 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-black/60 text-xl leading-none text-white transition-colors duration-150 hover:bg-black/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [-webkit-app-region:no-drag]"
1537+ onClick={() => setExpandedImage(null)}
1538+ aria-label="Close image preview"
1539+ >
1540+ <span className="pointer-events-none">×</span>
1541+ </button>
1542+ <img
1543+ src={expandedImage.src}
1544+ alt={expandedImage.name}
1545+ className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
1546+ draggable={false}
1547+ />
1548+ <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
1549+ {expandedImage.name}
1550+ </p>
1551+ </div>
1552+ </div>
1553+ )}
12241554 </div>
12251555 );
12261556 }
apps/web/src/historyBootstrap.test.ts+27−0
@@ -91,4 +91,31 @@ describe("buildBootstrapInput", () => { · 90 unmodified lines
9191 expect(result.omittedCount).toBe(1);
9292 expect(result.truncated).toBe(true);
9393 });
94+
95+ it("captures user image attachment context in transcript blocks", () => {
96+ const result = buildBootstrapInput(
97+ [
98+ {
99+ id: "u-image",
100+ role: "user",
101+ text: "",
102+ images: [
103+ {
104+ id: "img-1",
105+ name: "screenshot.png",
106+ mimeType: "image/png",
107+ sizeBytes: 2_048,
108+ },
109+ ],
110+ createdAt: "2026-02-09T00:00:00.000Z",
111+ streaming: false,
112+ },
113+ ],
114+ "What does this error mean?",
115+ 1_500,
116+ );
117+
118+ expect(result.text).toContain("Attached image");
119+ expect(result.text).toContain("screenshot.png");
120+ });
94121 });
apps/web/src/historyBootstrap.ts+28−3
@@ -18,8 +18,33 @@ function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { · 17 unmodified lines
1818 return message.role === "assistant" ? "ASSISTANT" : "USER";
1919 }
2020
21+function imageSummary(message: ChatMessage): string | null {
22+ const count = message.images?.length ?? 0;
23+ if (count === 0) {
24+ return null;
25+ }
26+
27+ const names = message.images?.slice(0, 3).map((image) => image.name) ?? [];
28+ const namesSummary = names.join(", ");
29+ const extraCount = count - names.length;
30+ const extraSummary = extraCount > 0 ? ` (+${extraCount} more)` : "";
31+ return `[Attached image${count === 1 ? "" : "s"}: ${namesSummary}${extraSummary}]`;
32+}
33+
2134 function buildMessageBlock(message: ChatMessage): string {
22− return `${messageRoleLabel(message)}:\n${message.text}`;
35+ const text = message.text;
36+ const images = imageSummary(message);
37+
38+ if (text && images) {
39+ return `${messageRoleLabel(message)}:\n${text}\n${images}`;
40+ }
41+ if (text) {
42+ return `${messageRoleLabel(message)}:\n${text}`;
43+ }
44+ if (images) {
45+ return `${messageRoleLabel(message)}:\n${images}`;
46+ }
47+ return `${messageRoleLabel(message)}:\n(empty message)`;
2348 }
2449
2550 function finalizeWithPrompt(
@@ -68,7 +93,7 @@ export function buildBootstrapInput( · 42 unmodified lines
6893 let includedNewestFirst: string[] = [];
6994 for (const block of newestFirstBlocks) {
7095 const nextNewestFirst = [...includedNewestFirst, block];
71− const nextChronological = [...nextNewestFirst].reverse();
96+ const nextChronological = nextNewestFirst.toReversed();
7297 const omittedCount = newestFirstBlocks.length - nextChronological.length;
7398 const transcriptBody =
7499 omittedCount > 0
@@ -80,7 +105,7 @@ export function buildBootstrapInput( · 5 unmodified lines
80105 includedNewestFirst = nextNewestFirst;
81106 }
82107
83− let includedChronological = [...includedNewestFirst].reverse();
108+ let includedChronological = includedNewestFirst.toReversed();
84109 while (true) {
85110 const omittedCount = newestFirstBlocks.length - includedChronological.length;
86111 const transcriptBody =
apps/web/src/persistenceSchema.test.ts+26−1
@@ -200,6 +200,15 @@ describe("toPersistedState", () => { · 199 unmodified lines
200200 id: "m-1",
201201 role: "user",
202202 text: "Hi",
203+ images: [
204+ {
205+ id: "img-1",
206+ name: "diagram.png",
207+ mimeType: "image/png",
208+ sizeBytes: 4_096,
209+ previewUrl: "blob:preview-1",
210+ },
211+ ],
203212 createdAt: "2026-02-08T10:00:00.000Z",
204213 streaming: false,
205214 },
@@ -236,7 +245,23 @@ describe("toPersistedState", () => { · 30 unmodified lines
236245 model: "gpt-5.3-codex",
237246 terminalOpen: true,
238247 terminalHeight: 320,
239− messages: thread.messages,
248+ messages: [
249+ {
250+ id: "m-1",
251+ role: "user",
252+ text: "Hi",
253+ images: [
254+ {
255+ id: "img-1",
256+ name: "diagram.png",
257+ mimeType: "image/png",
258+ sizeBytes: 4_096,
259+ },
260+ ],
261+ createdAt: "2026-02-08T10:00:00.000Z",
262+ streaming: false,
263+ },
264+ ],
240265 createdAt: thread.createdAt,
241266 branch: null,
242267 worktreePath: null,
apps/web/src/persistenceSchema.ts+40−5
@@ -23,6 +23,16 @@ const persistedMessageSchema = z.object({ · 22 unmodified lines
2323 id: z.string().min(1),
2424 role: z.enum(["user", "assistant"]),
2525 text: z.string(),
26+ images: z
27+ .array(
28+ z.object({
29+ id: z.string().min(1),
30+ name: z.string().min(1),
31+ mimeType: z.string().min(1),
32+ sizeBytes: z.number().int().min(1),
33+ }),
34+ )
35+ .optional(),
2636 createdAt: z.string().min(1),
2737 streaming: z.boolean(),
2838 });
@@ -126,10 +136,19 @@ function hydrateThread( · 97 unmodified lines
126136 terminalOpen: thread.terminalOpen ?? false,
127137 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
128138 session: null,
129− messages: thread.messages.map((message) => ({
130− ...message,
131− streaming: false,
132− })),
139+ messages: thread.messages.map((message) => {
140+ const hydratedImages = message.images?.map((image) => ({
141+ ...image,
142+ }));
143+ return {
144+ id: message.id,
145+ role: message.role,
146+ text: message.text,
147+ ...(hydratedImages && hydratedImages.length > 0 ? { images: hydratedImages } : {}),
148+ createdAt: message.createdAt,
149+ streaming: false,
150+ };
151+ }),
133152 events: [],
134153 error: null,
135154 createdAt: thread.createdAt,
@@ -189,7 +208,23 @@ export function toPersistedState( · 53 unmodified lines
189208 model: thread.model,
190209 terminalOpen: thread.terminalOpen,
191210 terminalHeight: thread.terminalHeight,
192− messages: thread.messages,
211+ messages: thread.messages.map((message) => ({
212+ id: message.id,
213+ role: message.role,
214+ text: message.text,
215+ ...(message.images && message.images.length > 0
216+ ? {
217+ images: message.images.map((image) => ({
218+ id: image.id,
219+ name: image.name,
220+ mimeType: image.mimeType,
221+ sizeBytes: image.sizeBytes,
222+ })),
223+ }
224+ : {}),
225+ createdAt: message.createdAt,
226+ streaming: message.streaming,
227+ })),
193228 createdAt: thread.createdAt,
194229 branch: thread.branch,
195230 worktreePath: thread.worktreePath,
apps/web/src/store.ts+9−1
@@ -13,6 +13,7 @@ import { resolveModelSlug } from "./model-logic"; · 12 unmodified lines
1313 import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414 import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
1515 import {
16+ type ChatImageAttachment,
1617 DEFAULT_RUNTIME_MODE,
1718 type Project,
1819 type RuntimeMode,
@@ -37,7 +38,13 @@ type Action = · 18 unmodified lines
3738 activeAssistantItemRef: { current: string | null };
3839 }
3940 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
40− | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
41+ | {
42+ type: "PUSH_USER_MESSAGE";
43+ threadId: string;
44+ id: string;
45+ text: string;
46+ images?: ChatImageAttachment[];
47+ }
4148 | { type: "SET_ERROR"; threadId: string; error: string | null }
4249 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
4350 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
@@ -380,6 +387,7 @@ export function reducer(state: AppState, action: Action): AppState { · 336 unmodified lines
380387 id: action.id,
381388 role: "user" as const,
382389 text: action.text,
390+ ...(action.images && action.images.length > 0 ? { images: action.images } : {}),
383391 createdAt: new Date().toISOString(),
384392 streaming: false,
385393 },
apps/web/src/types.ts+9−0
@@ -5,10 +5,19 @@ export type RuntimeMode = "approval-required" | "full-access"; · 4 unmodified lines
55 export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
66 export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
77
8+export interface ChatImageAttachment {
9+ id: string;
10+ name: string;
11+ mimeType: string;
12+ sizeBytes: number;
13+ previewUrl?: string;
14+}
15+
816 export interface ChatMessage {
917 id: string;
1018 role: "user" | "assistant";
1119 text: string;
20+ images?: ChatImageAttachment[];
1221 createdAt: string;
1322 streaming: boolean;
1423 }
packages/contracts/src/provider.test.ts+56−0
@@ -1,6 +1,7 @@
11 import { describe, expect, it } from "vitest";
22
33 import {
4+ PROVIDER_SEND_TURN_MAX_IMAGES,
45 providerEventSchema,
56 providerRespondToRequestInputSchema,
67 providerSendTurnInputSchema,
@@ -40,9 +41,64 @@ describe("providerSendTurnInputSchema", () => { · 33 unmodified lines
4041 effort: " high ",
4142 });
4243 expect(parsed.input).toBe("summarize this repo");
44+ expect(parsed.images).toEqual([]);
4345 expect(parsed.model).toBe("gpt-5.2-codex");
4446 expect(parsed.effort).toBe("high");
4547 });
48+
49+ it("accepts image-only turns", () => {
50+ const parsed = providerSendTurnInputSchema.parse({
51+ sessionId: "sess_1",
52+ images: [
53+ {
54+ name: "diagram.png",
55+ mimeType: "image/png",
56+ sizeBytes: 1_024,
57+ dataUrl: "data:image/png;base64,AAAA",
58+ },
59+ ],
60+ });
61+ expect(parsed.input).toBeUndefined();
62+ expect(parsed.images).toHaveLength(1);
63+ });
64+
65+ it("rejects turns with neither text nor images", () => {
66+ expect(() =>
67+ providerSendTurnInputSchema.parse({
68+ sessionId: "sess_1",
69+ }),
70+ ).toThrow();
71+ });
72+
73+ it("rejects non-image data urls", () => {
74+ expect(() =>
75+ providerSendTurnInputSchema.parse({
76+ sessionId: "sess_1",
77+ images: [
78+ {
79+ name: "not-image.txt",
80+ mimeType: "text/plain",
81+ sizeBytes: 25,
82+ dataUrl: "data:text/plain;base64,QQ==",
83+ },
84+ ],
85+ }),
86+ ).toThrow();
87+ });
88+
89+ it("rejects more than the max image count", () => {
90+ expect(() =>
91+ providerSendTurnInputSchema.parse({
92+ sessionId: "sess_1",
93+ images: Array.from({ length: PROVIDER_SEND_TURN_MAX_IMAGES + 1 }, (_, index) => ({
94+ name: `image-${index}.png`,
95+ mimeType: "image/png",
96+ sizeBytes: 1_024,
97+ dataUrl: "data:image/png;base64,AAAA",
98+ })),
99+ }),
100+ ).toThrow();
101+ });
46102 });
47103
48104 describe("providerEventSchema", () => {
packages/contracts/src/provider.ts+37−1
@@ -55,12 +55,46 @@ export const providerSessionStartInputSchema = z.object({ · 54 unmodified lines
5555 });
5656
5757 export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
58+export const PROVIDER_SEND_TURN_MAX_IMAGES = 8;
59+export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
60+export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
61+
62+export const providerSendTurnImageInputSchema = z
63+ .object({
64+ name: z.string().trim().min(1).max(255),
65+ mimeType: z
66+ .string()
67+ .trim()
68+ .min(1)
69+ .max(100)
70+ .regex(/^image\//i, "mimeType must be an image/* MIME type"),
71+ sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
72+ dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
73+ })
74+ .superRefine((value, ctx) => {
75+ if (!value.dataUrl.startsWith("data:image/")) {
76+ ctx.addIssue({
77+ code: z.ZodIssueCode.custom,
78+ message: "Image dataUrl must start with data:image/",
79+ path: ["dataUrl"],
80+ });
81+ }
82+ });
5883
5984 export const providerSendTurnInputSchema = z.object({
6085 sessionId: z.string().min(1),
61− input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
86+ input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(),
87+ images: z.array(providerSendTurnImageInputSchema).max(PROVIDER_SEND_TURN_MAX_IMAGES).default([]),
6288 model: z.string().trim().min(1).optional(),
6389 effort: z.string().trim().min(1).optional(),
90+}).superRefine((value, ctx) => {
91+ if (!value.input && value.images.length === 0) {
92+ ctx.addIssue({
93+ code: z.ZodIssueCode.custom,
94+ message: "Either input text or at least one image is required",
95+ path: ["input"],
96+ });
97+ }
6498 });
6599
66100 export const providerTurnStartResultSchema = z.object({
@@ -110,6 +144,8 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc · 43 unmodified lines
110144 export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
111145 export type ProviderSession = z.infer<typeof providerSessionSchema>;
112146 export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
147+export type ProviderSendTurnImage = z.infer<typeof providerSendTurnImageInputSchema>;
148+export type ProviderSendTurnImageInput = z.input<typeof providerSendTurnImageInputSchema>;
113149 export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
114150 export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
115151 export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;