authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 15:52:03-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-12 15:52:03-08:00
log1dca7012b5e34e8cbc7bfd8cc01d83fbaa4dff83
treebcae8215b3dea28c859cac261c8da04e23791aa2
parent44f3b3cb7c82c6a2cfd33dad92bb76260f9e32ff
parentea16f6fdd7a0197fa268032f26dad58213df66a5
signature Signed by PGP key B5690EEEBB952194

Merge pull request #15 from pingdotgg/images


14 files changed, 770 insertions(+), 54 deletions(-)

apps/server/src/codexAppServerManager.test.ts+117-1
......@@ -1,11 +1,42 @@
1import { describe, expect, it } from "vitest";
1import { describe, expect, it, vi } from "vitest";
22
33import {
4 CodexAppServerManager,
45 classifyCodexStderrLine,
56 isRecoverableThreadResumeError,
67 normalizeCodexModelSlug,
78} from "./codexAppServerManager";
89
10function 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
940describe("classifyCodexStderrLine", () => {
1041 it("ignores empty lines", () => {
1142 expect(classifyCodexStderrLine(" ")).toBeNull();
......@@ -75,3 +106,88 @@ describe("isRecoverableThreadResumeError", () => {
75106 ).toBe(false);
76107 });
77108});
109
110describe("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
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 @@
11import {
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";
69import {
10 type ClipboardEvent,
11 type DragEvent,
712 type FormEvent,
813 Fragment,
914 type KeyboardEvent,
......@@ -33,6 +38,7 @@ import {
3338 formatTimestamp,
3439} from "../session-logic";
3540import { useStore } from "../store";
41import type { ChatImageAttachment } from "../types";
3642import BranchToolbar from "./BranchToolbar";
3743import GitActionsControl from "./GitActionsControl";
3844import { isTerminalToggleShortcut } from "../terminal-shortcuts";
......@@ -57,6 +63,9 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {
5763
5864const LAST_EDITOR_KEY = "t3code:last-editor";
5965const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
66const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
67const 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
6170function 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 {
8089 continuityState: SessionContinuityState;
8190}
8291
92interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
93 previewUrl: string;
94 file: File;
95}
96
97interface ExpandedImagePreview {
98 src: string;
99 name: string;
100}
101
83102function 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[]
131150 return Array.from(pending.values());
132151}
133152
153function 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
134170export 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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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
......@@ -91,4 +91,32 @@ describe("buildBootstrapInput", () => {
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" {
1818 return message.role === "assistant" ? "ASSISTANT" : "USER";
1919}
2020
21function 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
2135function 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
2551function finalizeWithPrompt(
......@@ -68,7 +94,7 @@ export function buildBootstrapInput(
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(
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", () => {
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", () => {
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({
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(
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(
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";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
1515import {
16 type ChatAttachment,
1617 DEFAULT_RUNTIME_MODE,
1718 type Project,
1819 type RuntimeMode,
......@@ -37,7 +38,13 @@ type Action =
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 {
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
......@@ -5,10 +5,22 @@ export type RuntimeMode = "approval-required" | "full-access";
55export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
66export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
77
8export interface ChatImageAttachment {
9 type: "image";
10 id: string;
11 name: string;
12 mimeType: string;
13 sizeBytes: number;
14 previewUrl?: string;
15}
16
17export type ChatAttachment = ChatImageAttachment;
18
819export 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 @@
1export const MODEL_OPTIONS = ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"] as const;
1export 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
38export const DEFAULT_MODEL = "gpt-5.3-codex";
49
510export 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
1017export function normalizeModelSlug(model: string | null | undefined): string | null {
packages/contracts/src/provider.test.ts+59
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
45 providerEventSchema,
56 providerRespondToRequestInputSchema,
67 providerSendTurnInputSchema,
......@@ -40,9 +41,67 @@ describe("providerSendTurnInputSchema", () => {
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
48107describe("providerEventSchema", () => {
packages/contracts/src/provider.ts+43-6
......@@ -55,14 +55,48 @@ export const providerSessionStartInputSchema = z.object({
5555});
5656
5757export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
58
59export 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(),
58export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
59export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
60export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
61
62export 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
75export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [
76 providerSendTurnImageAttachmentSchema,
77]);
78
79export 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
66100export 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
110144export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
111145export type ProviderSession = z.infer<typeof providerSessionSchema>;
112146export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
147export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>;
148export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>;
149export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>;
113150export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
114151export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
115152export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;