History

Merge pull request #15 from pingdotgg/images

Julius Marminge committed 9 months ago

Commit 1dca7012 parents 44f3b3c ea16f6f14 files · +770 −54Browse files

Unified · Split

14 files
  1. apps/server/src/codexAppServerManager.test.ts +117 −1
  2. apps/server/src/codexAppServerManager.ts +28 −8
  3. apps/web/src/components/ChatView.tsx +361 −24
  4. apps/web/src/components/DiffPanel.tsx +1 −1
  5. apps/web/src/components/Sidebar.tsx +3 −3
  6. apps/web/src/historyBootstrap.test.ts +28 −0
  7. apps/web/src/historyBootstrap.ts +29 −3
  8. apps/web/src/persistenceSchema.test.ts +28 −1
  9. apps/web/src/persistenceSchema.ts +42 −5
  10. apps/web/src/store.ts +11 −1
  11. apps/web/src/types.ts +12 −0
  12. packages/contracts/src/model.ts +8 −1
  13. packages/contracts/src/provider.test.ts +59 −0
  14. packages/contracts/src/provider.ts +43 −6
apps/server/src/codexAppServerManager.test.ts+117−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,88 @@ 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+ attachments: [
118+ {
119+ type: "image",
120+ name: "error.png",
121+ mimeType: "image/png",
122+ sizeBytes: 1_024,
123+ dataUrl: "data:image/png;base64,AAAA",
124+ },
125+ ],
126+ model: "gpt-5.3",
127+ effort: "high",
128+ });
129+
130+ expect(result).toEqual({
131+ threadId: "thread_1",
132+ turnId: "turn_1",
133+ });
134+ expect(requireSession).toHaveBeenCalledWith("sess_1");
135+ expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
136+ threadId: "thread_1",
137+ input: [
138+ {
139+ type: "text",
140+ text: "Inspect this image",
141+ text_elements: [],
142+ },
143+ {
144+ type: "image",
145+ url: "data:image/png;base64,AAAA",
146+ },
147+ ],
148+ model: "gpt-5.3-codex",
149+ effort: "high",
150+ });
151+ expect(updateSession).toHaveBeenCalledWith(context, {
152+ status: "running",
153+ activeTurnId: "turn_1",
154+ });
155+ });
156+
157+ it("supports image-only turns", async () => {
158+ const { manager, context, sendRequest } = createSendTurnHarness();
159+
160+ await manager.sendTurn({
161+ sessionId: "sess_1",
162+ attachments: [
163+ {
164+ type: "image",
165+ name: "diagram.png",
166+ mimeType: "image/png",
167+ sizeBytes: 256,
168+ dataUrl: "data:image/png;base64,BBBB",
169+ },
170+ ],
171+ });
172+
173+ expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
174+ threadId: "thread_1",
175+ input: [
176+ {
177+ type: "image",
178+ url: "data:image/png;base64,BBBB",
179+ },
180+ ],
181+ });
182+ });
183+
184+ it("rejects empty turn input", async () => {
185+ const { manager } = createSendTurnHarness();
186+
187+ await expect(
188+ manager.sendTurn({
189+ sessionId: "sess_1",
190+ }),
191+ ).rejects.toThrow("Turn input must include text or attachments.");
192+ });
193+});
apps/server/src/codexAppServerManager.ts+28−8
@@ -257,20 +257,40 @@ 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 attachment of input.attachments ?? []) {
272+ if (attachment.type === "image") {
273+ turnInput.push({
274+ type: "image",
275+ url: attachment.dataUrl,
276+ });
277+ }
278+ }
279+ if (turnInput.length === 0) {
280+ throw new Error("Turn input must include text or attachments.");
281+ }
282+
260283 const turnStartParams: {
261284 threadId: string;
262− input: Array<{ type: "text"; text: string; text_elements: [] }>;
285+ input: Array<
286+ | { type: "text"; text: string; text_elements: [] }
287+ | { type: "image"; url: string }
288+ >;
263289 model?: string;
264290 effort?: string;
265291 } = {
266292 threadId: context.session.threadId,
267− input: [
268− {
269− type: "text",
270− text: input.input,
271− text_elements: [],
272− },
273− ],
293+ input: turnInput,
274294 };
275295 const normalizedModel = normalizeCodexModelSlug(input.model);
276296 if (normalizedModel) {
apps/web/src/components/ChatView.tsx+361−24
@@ -1,9 +1,14 @@
11 import {
22 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4+ PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5+ type ProviderSendTurnAttachmentInput,
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,125 @@ 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_ATTACHMENTS) {
545+ error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
546+ break;
547+ }
548+
549+ const previewUrl = URL.createObjectURL(file);
550+ nextImages.push({
551+ type: "image",
552+ id: crypto.randomUUID(),
553+ name: file.name || "image",
554+ mimeType: file.type,
555+ sizeBytes: file.size,
556+ previewUrl,
557+ file,
558+ });
559+ }
560+
561+ setComposerImages(nextImages);
562+ setThreadError(activeThreadId, error);
563+ };
564+
565+ const removeComposerImage = (imageId: string) => {
566+ setComposerImages((existing) => {
567+ const match = existing.find((image) => image.id === imageId);
568+ if (match?.previewUrl) {
569+ URL.revokeObjectURL(match.previewUrl);
570+ }
571+ return existing.filter((image) => image.id !== imageId);
572+ });
573+ };
574+
575+ const openImageDialog = (src: string | undefined, name: string) => {
576+ if (!src) {
577+ return;
578+ }
579+ setExpandedImage({ src, name });
580+ };
581+
582+ const onComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
583+ const files = Array.from(event.clipboardData.files);
584+ if (files.length === 0) {
585+ return;
586+ }
587+ const imageFiles = files.filter((file) => file.type.startsWith("image/"));
588+ if (imageFiles.length === 0) {
589+ return;
590+ }
591+ event.preventDefault();
592+ addComposerImages(imageFiles);
593+ };
594+
595+ const onComposerDragEnter = (event: DragEvent<HTMLDivElement>) => {
596+ if (!event.dataTransfer.types.includes("Files")) {
597+ return;
598+ }
599+ event.preventDefault();
600+ dragDepthRef.current += 1;
601+ setIsDragOverComposer(true);
602+ };
603+
604+ const onComposerDragOver = (event: DragEvent<HTMLDivElement>) => {
605+ if (!event.dataTransfer.types.includes("Files")) {
606+ return;
607+ }
608+ event.preventDefault();
609+ event.dataTransfer.dropEffect = "copy";
610+ setIsDragOverComposer(true);
611+ };
612+
613+ const onComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
614+ if (!event.dataTransfer.types.includes("Files")) {
615+ return;
616+ }
617+ event.preventDefault();
618+ const nextTarget = event.relatedTarget;
619+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
620+ return;
621+ }
622+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
623+ if (dragDepthRef.current === 0) {
624+ setIsDragOverComposer(false);
625+ }
626+ };
627+
628+ const onComposerDrop = (event: DragEvent<HTMLDivElement>) => {
629+ if (!event.dataTransfer.types.includes("Files")) {
630+ return;
631+ }
632+ event.preventDefault();
633+ dragDepthRef.current = 0;
634+ setIsDragOverComposer(false);
635+ const files = Array.from(event.dataTransfer.files);
636+ addComposerImages(files);
637+ focusComposer();
638+ };
639+
434640 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
435641 if (!api || !activeThread || !activeProject) return null;
436642 if (activeThread.session && activeThread.session.status !== "closed") {
@@ -492,8 +698,9 @@ export default function ChatView() { · 55 unmodified lines
492698 e.preventDefault();
493699 if (!api || !activeThread || isSending || isConnecting) return;
494700 const trimmed = prompt.trim();
495− if (!trimmed) return;
701+ if (!trimmed && composerImages.length === 0) return;
496702 if (!activeProject) return;
703+ const composerImagesSnapshot = [...composerImages];
497704
498705 // On first message: lock in branch + create worktree if needed.
499706 let sessionCwd: string | undefined;
@@ -529,7 +736,12 @@ export default function ChatView() { · 29 unmodified lines
529736
530737 // Auto-title from first message
531738 if (activeThread.messages.length === 0) {
532− const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed;
739+ const titleSeed =
740+ trimmed ||
741+ (composerImagesSnapshot.length > 0
742+ ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
743+ : "New thread");
744+ const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed;
533745 dispatch({
534746 type: "SET_THREAD_TITLE",
535747 threadId: activeThread.id,
@@ -537,43 +749,63 @@ export default function ChatView() { · 1 unmodified line
537749 });
538750 }
539751
540− dispatch({
541− type: "SET_ERROR",
542− threadId: activeThread.id,
543− error: null,
544− });
752+ setThreadError(activeThread.id, null);
753+ const messageAttachments: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({
754+ type: "image",
755+ id: image.id,
756+ name: image.name,
757+ mimeType: image.mimeType,
758+ sizeBytes: image.sizeBytes,
759+ previewUrl: image.previewUrl,
760+ }));
545761 dispatch({
546762 type: "PUSH_USER_MESSAGE",
547763 threadId: activeThread.id,
548764 id: crypto.randomUUID(),
549765 text: trimmed,
766+ ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
550767 });
551768 const previousMessages = activeThread.messages;
552769 setPrompt("");
770+ setComposerImages([]);
553771
554772 const sessionInfo = await ensureSession(sessionCwd);
555773 if (!sessionInfo) return;
556774
557775 setIsSending(true);
558776 try {
777+ const turnAttachments = await Promise.all(
778+ composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnAttachmentInput> => ({
779+ type: "image",
780+ name: image.name,
781+ mimeType: image.mimeType,
782+ sizeBytes: image.sizeBytes,
783+ dataUrl: await readFileAsDataUrl(image.file),
784+ })),
785+ );
559786 const shouldBootstrap =
560787 previousMessages.length > 0 &&
561788 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
789+ const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
562790 const input = shouldBootstrap
563− ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text
564− : trimmed;
791+ ? buildBootstrapInput(
792+ previousMessages,
793+ latestPromptForBootstrap,
794+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
795+ ).text
796+ : (trimmed || undefined);
565797 await api.providers.sendTurn({
566798 sessionId: sessionInfo.sessionId,
567− input,
799+ ...(input ? { input } : {}),
800+ ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}),
568801 model: selectedModel || undefined,
569802 effort: selectedEffort || undefined,
570803 });
571804 } catch (err) {
572− dispatch({
573− type: "SET_ERROR",
574− threadId: activeThread.id,
575− error: err instanceof Error ? err.message : "Failed to send message.",
576− });
805+ setThreadError(
806+ activeThread.id,
807+ err instanceof Error ? err.message : "Failed to send message.",
808+ );
577809 } finally {
578810 setIsSending(false);
579811 }
@@ -631,7 +863,11 @@ export default function ChatView() { · 51 unmodified lines
631863 if (!activeThread) {
632864 return (
633865 <div className="flex min-h-0 flex-1 flex-col bg-background text-muted-foreground/40">
634− {isElectron && <div className="drag-region h-[52px] shrink-0" />}
866+ {isElectron && (
867+ <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
868+ <span className="text-xs text-muted-foreground/50">No active thread</span>
869+ </div>
870+ )}
635871 <div className="flex flex-1 items-center justify-center">
636872 <div className="text-center">
637873 <p className="text-sm">Select a thread or create a new one to get started.</p>
@@ -645,7 +881,7 @@ export default function ChatView() { · 7 unmodified lines
645881 <div className="flex min-h-0 flex-1 flex-col bg-background">
646882 {/* Top bar */}
647883 <header
648− className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}
884+ className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
649885 >
650886 <div className="flex items-center gap-3">
651887 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
@@ -876,13 +1112,39 @@ export default function ChatView() { · 224 unmodified lines
8761112 }
8771113
8781114 if (timelineEntry.message.role === "user") {
1115+ const userImages = timelineEntry.message.attachments ?? [];
8791116 return (
8801117 <Fragment key={timelineEntry.id}>
8811118 <div className="flex justify-end">
8821119 <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>
1120+ {userImages.length > 0 && (
1121+ <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1122+ {userImages.map((image) => (
1123+ <div
1124+ key={image.id}
1125+ className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1126+ >
1127+ {image.previewUrl ? (
1128+ <img
1129+ src={image.previewUrl}
1130+ alt={image.name}
1131+ className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1132+ onClick={() => openImageDialog(image.previewUrl, image.name)}
1133+ />
1134+ ) : (
1135+ <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1136+ {image.name}
1137+ </div>
1138+ )}
1139+ </div>
1140+ ))}
1141+ </div>
1142+ )}
1143+ {timelineEntry.message.text && (
1144+ <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
1145+ {timelineEntry.message.text}
1146+ </pre>
1147+ )}
8861148 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
8871149 {formatTimestamp(timelineEntry.message.createdAt)}
8881150 </p>
@@ -945,9 +1207,48 @@ export default function ChatView() { · 56 unmodified lines
9451207 {/* Input bar */}
9461208 <div className="px-5 pb-1 pt-2">
9471209 <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">
1210+ <div
1211+ className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
1212+ isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
1213+ }`}
1214+ onDragEnter={onComposerDragEnter}
1215+ onDragOver={onComposerDragOver}
1216+ onDragLeave={onComposerDragLeave}
1217+ onDrop={onComposerDrop}
1218+ >
9491219 {/* Textarea area */}
9501220 <div className="px-4 pt-4 pb-2">
1221+ {composerImages.length > 0 && (
1222+ <div className="mb-3 flex flex-wrap gap-2">
1223+ {composerImages.map((image) => (
1224+ <div
1225+ key={image.id}
1226+ className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
1227+ >
1228+ {image.previewUrl ? (
1229+ <img
1230+ src={image.previewUrl}
1231+ alt={image.name}
1232+ className="h-full w-full cursor-zoom-in object-cover"
1233+ onClick={() => openImageDialog(image.previewUrl, image.name)}
1234+ />
1235+ ) : (
1236+ <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
1237+ {image.name}
1238+ </div>
1239+ )}
1240+ <button
1241+ type="button"
1242+ 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"
1243+ onClick={() => removeComposerImage(image.id)}
1244+ aria-label={`Remove ${image.name}`}
1245+ >
1246+ ×
1247+ </button>
1248+ </div>
1249+ ))}
1250+ </div>
1251+ )}
9511252 <textarea
9521253 ref={textareaRef}
9531254 className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none"
@@ -955,8 +1256,11 @@ export default function ChatView() { · 1 unmodified line
9551256 value={prompt}
9561257 onChange={(e) => setPrompt(e.target.value)}
9571258 onKeyDown={onKeyDown}
1259+ onPaste={onComposerPaste}
9581260 placeholder={
959− phase === "disconnected" ? "Ask for follow-up changes" : "Ask anything..."
1261+ phase === "disconnected"
1262+ ? "Ask for follow-up changes or attach images"
1263+ : "Ask anything, or attach images..."
9601264 }
9611265 disabled={isSending || isConnecting}
9621266 />
@@ -1146,7 +1450,7 @@ export default function ChatView() { · 183 unmodified lines
11461450 <button
11471451 type="submit"
11481452 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()}
1453+ disabled={isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)}
11501454 aria-label={
11511455 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
11521456 }
@@ -1221,6 +1525,39 @@ export default function ChatView() { · 68 unmodified lines
12211525 }
12221526 />
12231527 )}
1528+
1529+ {expandedImage && (
1530+ <div
1531+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
1532+ role="dialog"
1533+ aria-modal="true"
1534+ aria-label="Expanded image preview"
1535+ onClick={() => setExpandedImage(null)}
1536+ >
1537+ <div
1538+ className="relative isolate max-h-[92vh] max-w-[92vw]"
1539+ onClick={(event) => event.stopPropagation()}
1540+ >
1541+ <button
1542+ type="button"
1543+ 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]"
1544+ onClick={() => setExpandedImage(null)}
1545+ aria-label="Close image preview"
1546+ >
1547+ <span className="pointer-events-none">×</span>
1548+ </button>
1549+ <img
1550+ src={expandedImage.src}
1551+ alt={expandedImage.name}
1552+ className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
1553+ draggable={false}
1554+ />
1555+ <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
1556+ {expandedImage.name}
1557+ </p>
1558+ </div>
1559+ </div>
1560+ )}
12241561 </div>
12251562 );
12261563 }
apps/web/src/components/DiffPanel.tsx+1−1
@@ -8,7 +8,7 @@ export default function DiffPanel() { · 7 unmodified lines
88 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
99 {/* Header */}
1010 <div
11− className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}
11+ className={`flex items-center justify-between border-b border-border px-4 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
1212 >
1313 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
1414 <button
apps/web/src/components/Sidebar.tsx+3−3
@@ -237,10 +237,10 @@ export default function Sidebar() { · 236 unmodified lines
237237
238238 return (
239239 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
240− {/* Drag region / traffic light space (Electron only) */}
241− {isElectron && <div className="drag-region h-[52px] shrink-0" />}
242240 {/* Branding */}
243− <div className={`flex items-center gap-2.5 px-4 pb-4 ${isElectron ? "" : "pt-4"}`}>
241+ <div
242+ className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}
243+ >
244244 <span className="flex-1 text-sm font-semibold tracking-tight text-foreground">
245245 T3 <span className="font-normal text-muted-foreground">Code</span>
246246 </span>
apps/web/src/historyBootstrap.test.ts+28−0
@@ -91,4 +91,32 @@ 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+ attachments: [
103+ {
104+ type: "image",
105+ id: "img-1",
106+ name: "screenshot.png",
107+ mimeType: "image/png",
108+ sizeBytes: 2_048,
109+ },
110+ ],
111+ createdAt: "2026-02-09T00:00:00.000Z",
112+ streaming: false,
113+ },
114+ ],
115+ "What does this error mean?",
116+ 1_500,
117+ );
118+
119+ expect(result.text).toContain("Attached image");
120+ expect(result.text).toContain("screenshot.png");
121+ });
94122 });
apps/web/src/historyBootstrap.ts+29−3
@@ -18,8 +18,34 @@ function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { · 17 unmodified lines
1818 return message.role === "assistant" ? "ASSISTANT" : "USER";
1919 }
2020
21+function attachmentSummary(message: ChatMessage): string | null {
22+ const imageAttachments = message.attachments?.filter((attachment) => attachment.type === "image");
23+ const count = imageAttachments?.length ?? 0;
24+ if (count === 0) {
25+ return null;
26+ }
27+
28+ const names = imageAttachments?.slice(0, 3).map((image) => image.name) ?? [];
29+ const namesSummary = names.join(", ");
30+ const extraCount = count - names.length;
31+ const extraSummary = extraCount > 0 ? ` (+${extraCount} more)` : "";
32+ return `[Attached image${count === 1 ? "" : "s"}: ${namesSummary}${extraSummary}]`;
33+}
34+
2135 function buildMessageBlock(message: ChatMessage): string {
22− return `${messageRoleLabel(message)}:\n${message.text}`;
36+ const text = message.text;
37+ const attachments = attachmentSummary(message);
38+
39+ if (text && attachments) {
40+ return `${messageRoleLabel(message)}:\n${text}\n${attachments}`;
41+ }
42+ if (text) {
43+ return `${messageRoleLabel(message)}:\n${text}`;
44+ }
45+ if (attachments) {
46+ return `${messageRoleLabel(message)}:\n${attachments}`;
47+ }
48+ return `${messageRoleLabel(message)}:\n(empty message)`;
2349 }
2450
2551 function finalizeWithPrompt(
@@ -68,7 +94,7 @@ export function buildBootstrapInput( · 42 unmodified lines
6894 let includedNewestFirst: string[] = [];
6995 for (const block of newestFirstBlocks) {
7096 const nextNewestFirst = [...includedNewestFirst, block];
71− const nextChronological = [...nextNewestFirst].reverse();
97+ const nextChronological = nextNewestFirst.toReversed();
7298 const omittedCount = newestFirstBlocks.length - nextChronological.length;
7399 const transcriptBody =
74100 omittedCount > 0
@@ -80,7 +106,7 @@ export function buildBootstrapInput( · 5 unmodified lines
80106 includedNewestFirst = nextNewestFirst;
81107 }
82108
83− let includedChronological = [...includedNewestFirst].reverse();
109+ let includedChronological = includedNewestFirst.toReversed();
84110 while (true) {
85111 const omittedCount = newestFirstBlocks.length - includedChronological.length;
86112 const transcriptBody =
apps/web/src/persistenceSchema.test.ts+28−1
@@ -200,6 +200,16 @@ describe("toPersistedState", () => { · 199 unmodified lines
200200 id: "m-1",
201201 role: "user",
202202 text: "Hi",
203+ attachments: [
204+ {
205+ type: "image",
206+ id: "img-1",
207+ name: "diagram.png",
208+ mimeType: "image/png",
209+ sizeBytes: 4_096,
210+ previewUrl: "blob:preview-1",
211+ },
212+ ],
203213 createdAt: "2026-02-08T10:00:00.000Z",
204214 streaming: false,
205215 },
@@ -236,7 +246,24 @@ describe("toPersistedState", () => { · 30 unmodified lines
236246 model: "gpt-5.3-codex",
237247 terminalOpen: true,
238248 terminalHeight: 320,
239− messages: thread.messages,
249+ messages: [
250+ {
251+ id: "m-1",
252+ role: "user",
253+ text: "Hi",
254+ attachments: [
255+ {
256+ type: "image",
257+ id: "img-1",
258+ name: "diagram.png",
259+ mimeType: "image/png",
260+ sizeBytes: 4_096,
261+ },
262+ ],
263+ createdAt: "2026-02-08T10:00:00.000Z",
264+ streaming: false,
265+ },
266+ ],
240267 createdAt: thread.createdAt,
241268 branch: null,
242269 worktreePath: null,
apps/web/src/persistenceSchema.ts+42−5
@@ -23,6 +23,17 @@ const persistedMessageSchema = z.object({ · 22 unmodified lines
2323 id: z.string().min(1),
2424 role: z.enum(["user", "assistant"]),
2525 text: z.string(),
26+ attachments: z
27+ .array(
28+ z.object({
29+ type: z.literal("image"),
30+ id: z.string().min(1),
31+ name: z.string().min(1),
32+ mimeType: z.string().min(1),
33+ sizeBytes: z.number().int().min(1),
34+ }),
35+ )
36+ .optional(),
2637 createdAt: z.string().min(1),
2738 streaming: z.boolean(),
2839 });
@@ -126,10 +137,19 @@ function hydrateThread( · 97 unmodified lines
126137 terminalOpen: thread.terminalOpen ?? false,
127138 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
128139 session: null,
129− messages: thread.messages.map((message) => ({
130− ...message,
131− streaming: false,
132− })),
140+ messages: thread.messages.map((message) => {
141+ const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));
142+ return {
143+ id: message.id,
144+ role: message.role,
145+ text: message.text,
146+ ...(hydratedAttachments && hydratedAttachments.length > 0
147+ ? { attachments: hydratedAttachments }
148+ : {}),
149+ createdAt: message.createdAt,
150+ streaming: false,
151+ };
152+ }),
133153 events: [],
134154 error: null,
135155 createdAt: thread.createdAt,
@@ -189,7 +209,24 @@ export function toPersistedState( · 53 unmodified lines
189209 model: thread.model,
190210 terminalOpen: thread.terminalOpen,
191211 terminalHeight: thread.terminalHeight,
192− messages: thread.messages,
212+ messages: thread.messages.map((message) => ({
213+ id: message.id,
214+ role: message.role,
215+ text: message.text,
216+ ...(message.attachments && message.attachments.length > 0
217+ ? {
218+ attachments: message.attachments.map((attachment) => ({
219+ type: attachment.type,
220+ id: attachment.id,
221+ name: attachment.name,
222+ mimeType: attachment.mimeType,
223+ sizeBytes: attachment.sizeBytes,
224+ })),
225+ }
226+ : {}),
227+ createdAt: message.createdAt,
228+ streaming: message.streaming,
229+ })),
193230 createdAt: thread.createdAt,
194231 branch: thread.branch,
195232 worktreePath: thread.worktreePath,
apps/web/src/store.ts+11−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 ChatAttachment,
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+ attachments?: ChatAttachment[];
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,9 @@ 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.attachments && action.attachments.length > 0
391+ ? { attachments: action.attachments }
392+ : {}),
383393 createdAt: new Date().toISOString(),
384394 streaming: false,
385395 },
apps/web/src/types.ts+12−0
@@ -5,10 +5,22 @@ 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+ type: "image";
10+ id: string;
11+ name: string;
12+ mimeType: string;
13+ sizeBytes: number;
14+ previewUrl?: string;
15+}
16+
17+export type ChatAttachment = ChatImageAttachment;
18+
819 export interface ChatMessage {
920 id: string;
1021 role: "user" | "assistant";
1122 text: string;
23+ attachments?: ChatAttachment[];
1224 createdAt: string;
1325 streaming: boolean;
1426 }
packages/contracts/src/model.ts+8−1
@@ -1,10 +1,17 @@
1−export const MODEL_OPTIONS = ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"] as const;
1+export const MODEL_OPTIONS = [
2+ "gpt-5.3-codex",
3+ "gpt-5.3-codex-spark",
4+ "gpt-5.2-codex",
5+ "gpt-5.2",
6+] as const;
27
38 export const DEFAULT_MODEL = "gpt-5.3-codex";
49
510 export const MODEL_SLUG_ALIASES: Record<string, string> = {
611 "5.3": "gpt-5.3-codex",
712 "gpt-5.3": "gpt-5.3-codex",
13+ "5.3-spark": "gpt-5.3-codex-spark",
14+ "gpt-5.3-spark": "gpt-5.3-codex-spark",
815 };
916
1017 export function normalizeModelSlug(model: string | null | undefined): string | null {
packages/contracts/src/provider.test.ts+59−0
@@ -1,6 +1,7 @@
11 import { describe, expect, it } from "vitest";
22
33 import {
4+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
45 providerEventSchema,
56 providerRespondToRequestInputSchema,
67 providerSendTurnInputSchema,
@@ -40,9 +41,67 @@ describe("providerSendTurnInputSchema", () => { · 33 unmodified lines
4041 effort: " high ",
4142 });
4243 expect(parsed.input).toBe("summarize this repo");
44+ expect(parsed.attachments).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+ attachments: [
53+ {
54+ type: "image",
55+ name: "diagram.png",
56+ mimeType: "image/png",
57+ sizeBytes: 1_024,
58+ dataUrl: "data:image/png;base64,AAAA",
59+ },
60+ ],
61+ });
62+ expect(parsed.input).toBeUndefined();
63+ expect(parsed.attachments).toHaveLength(1);
64+ });
65+
66+ it("rejects turns with neither text nor attachments", () => {
67+ expect(() =>
68+ providerSendTurnInputSchema.parse({
69+ sessionId: "sess_1",
70+ }),
71+ ).toThrow();
72+ });
73+
74+ it("rejects non-image data urls", () => {
75+ expect(() =>
76+ providerSendTurnInputSchema.parse({
77+ sessionId: "sess_1",
78+ attachments: [
79+ {
80+ type: "image",
81+ name: "not-image.txt",
82+ mimeType: "text/plain",
83+ sizeBytes: 25,
84+ dataUrl: "data:text/plain;base64,QQ==",
85+ },
86+ ],
87+ }),
88+ ).toThrow();
89+ });
90+
91+ it("rejects more than the max attachment count", () => {
92+ expect(() =>
93+ providerSendTurnInputSchema.parse({
94+ sessionId: "sess_1",
95+ attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
96+ type: "image" as const,
97+ name: `image-${index}.png`,
98+ mimeType: "image/png",
99+ sizeBytes: 1_024,
100+ dataUrl: "data:image/png;base64,AAAA",
101+ })),
102+ }),
103+ ).toThrow();
104+ });
46105 });
47106
48107 describe("providerEventSchema", () => {
packages/contracts/src/provider.ts+43−6
@@ -55,14 +55,48 @@ export const providerSessionStartInputSchema = z.object({ · 54 unmodified lines
5555 });
5656
5757 export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
58−
59−export const providerSendTurnInputSchema = z.object({
60− sessionId: z.string().min(1),
61− input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
62− model: z.string().trim().min(1).optional(),
63− effort: z.string().trim().min(1).optional(),
58+export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 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 providerSendTurnImageAttachmentSchema = z.object({
63+ type: z.literal("image"),
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),
6473 });
6574
75+export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [
76+ providerSendTurnImageAttachmentSchema,
77+]);
78+
79+export const providerSendTurnInputSchema = z
80+ .object({
81+ sessionId: z.string().min(1),
82+ input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(),
83+ attachments: z
84+ .array(providerSendTurnAttachmentInputSchema)
85+ .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)
86+ .default([]),
87+ model: z.string().trim().min(1).optional(),
88+ effort: z.string().trim().min(1).optional(),
89+ })
90+ .superRefine((value, ctx) => {
91+ if (!value.input && value.attachments.length === 0) {
92+ ctx.addIssue({
93+ code: z.ZodIssueCode.custom,
94+ message: "Either input text or at least one attachment is required",
95+ path: ["input"],
96+ });
97+ }
98+ });
99+
66100 export const providerTurnStartResultSchema = z.object({
67101 threadId: z.string().min(1),
68102 turnId: z.string().min(1),
@@ -110,6 +144,9 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc · 41 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 ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>;
148+export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>;
149+export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>;
113150 export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
114151 export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
115152 export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;