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 @@...@@ -1,11 +1,42 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it, vi } from "vitest";
22
3import {3import {
4 CodexAppServerManager,
4 classifyCodexStderrLine,5 classifyCodexStderrLine,
5 isRecoverableThreadResumeError,6 isRecoverableThreadResumeError,
6 normalizeCodexModelSlug,7 normalizeCodexModelSlug,
7} from "./codexAppServerManager";8} 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
9describe("classifyCodexStderrLine", () => {40describe("classifyCodexStderrLine", () => {
10 it("ignores empty lines", () => {41 it("ignores empty lines", () => {
11 expect(classifyCodexStderrLine(" ")).toBeNull();42 expect(classifyCodexStderrLine(" ")).toBeNull();
...@@ -75,3 +106,88 @@ describe("isRecoverableThreadResumeError", () => {...@@ -75,3 +106,88 @@ describe("isRecoverableThreadResumeError", () => {
75 ).toBe(false);106 ).toBe(false);
76 });107 });
77});108});
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...@@ -257,20 +257,40 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
257 throw new Error("Session is missing a thread id.");257 throw new Error("Session is missing a thread id.");
258 }258 }
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
260 const turnStartParams: {283 const turnStartParams: {
261 threadId: string;284 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 >;
263 model?: string;289 model?: string;
264 effort?: string;290 effort?: string;
265 } = {291 } = {
266 threadId: context.session.threadId,292 threadId: context.session.threadId,
267 input: [293 input: turnInput,
268 {
269 type: "text",
270 text: input.input,
271 text_elements: [],
272 },
273 ],
274 };294 };
275 const normalizedModel = normalizeCodexModelSlug(input.model);295 const normalizedModel = normalizeCodexModelSlug(input.model);
276 if (normalizedModel) {296 if (normalizedModel) {
apps/web/src/components/ChatView.tsx+361-24
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import {1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5 type ProviderSendTurnAttachmentInput,
3 type ProviderApprovalDecision,6 type ProviderApprovalDecision,
4 type ProviderEvent,7 type ProviderEvent,
5} from "@t3tools/contracts";8} from "@t3tools/contracts";
6import {9import {
10 type ClipboardEvent,
11 type DragEvent,
7 type FormEvent,12 type FormEvent,
8 Fragment,13 Fragment,
9 type KeyboardEvent,14 type KeyboardEvent,
...@@ -33,6 +38,7 @@ import {...@@ -33,6 +38,7 @@ import {
33 formatTimestamp,38 formatTimestamp,
34} from "../session-logic";39} from "../session-logic";
35import { useStore } from "../store";40import { useStore } from "../store";
41import type { ChatImageAttachment } from "../types";
36import BranchToolbar from "./BranchToolbar";42import BranchToolbar from "./BranchToolbar";
37import GitActionsControl from "./GitActionsControl";43import GitActionsControl from "./GitActionsControl";
38import { isTerminalToggleShortcut } from "../terminal-shortcuts";44import { isTerminalToggleShortcut } from "../terminal-shortcuts";
...@@ -57,6 +63,9 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {...@@ -57,6 +63,9 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {
5763
58const LAST_EDITOR_KEY = "t3code:last-editor";64const LAST_EDITOR_KEY = "t3code:last-editor";
59const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;65const 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
61function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {70function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
62 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";71 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
...@@ -80,6 +89,16 @@ interface EnsuredSessionInfo {...@@ -80,6 +89,16 @@ interface EnsuredSessionInfo {
80 continuityState: SessionContinuityState;89 continuityState: SessionContinuityState;
81}90}
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
83function asRecord(value: unknown): Record<string, unknown> | undefined {102function asRecord(value: unknown): Record<string, unknown> | undefined {
84 if (!value || typeof value !== "object") return undefined;103 if (!value || typeof value !== "object") return undefined;
85 return value as Record<string, unknown>;104 return value as Record<string, unknown>;
...@@ -131,10 +150,30 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]...@@ -131,10 +150,30 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]
131 return Array.from(pending.values());150 return Array.from(pending.values());
132}151}
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
134export default function ChatView() {170export default function ChatView() {
135 const { state, dispatch } = useStore();171 const { state, dispatch } = useStore();
136 const api = useNativeApi();172 const api = useNativeApi();
137 const [prompt, setPrompt] = useState("");173 const [prompt, setPrompt] = useState("");
174 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
175 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
176 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
138 const [isSending, setIsSending] = useState(false);177 const [isSending, setIsSending] = useState(false);
139 const [isConnecting, setIsConnecting] = useState(false);178 const [isConnecting, setIsConnecting] = useState(false);
140 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);179 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
...@@ -152,6 +191,8 @@ export default function ChatView() {...@@ -152,6 +191,8 @@ export default function ChatView() {
152 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);191 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
153 const messagesEndRef = useRef<HTMLDivElement>(null);192 const messagesEndRef = useRef<HTMLDivElement>(null);
154 const textareaRef = useRef<HTMLTextAreaElement>(null);193 const textareaRef = useRef<HTMLTextAreaElement>(null);
194 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
195 const dragDepthRef = useRef(0);
155 const modelMenuRef = useRef<HTMLDivElement>(null);196 const modelMenuRef = useRef<HTMLDivElement>(null);
156 const editorMenuRef = useRef<HTMLDivElement>(null);197 const editorMenuRef = useRef<HTMLDivElement>(null);
157 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});198 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
...@@ -264,7 +305,15 @@ export default function ChatView() {...@@ -264,7 +305,15 @@ export default function ChatView() {
264 (activeThread.messages.length > 0 ||305 (activeThread.messages.length > 0 ||
265 (activeThread.session !== null && activeThread.session.status !== "closed")),306 (activeThread.session !== null && activeThread.session.status !== "closed")),
266 );307 );
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 }, []);
268 const focusComposer = useCallback(() => {317 const focusComposer = useCallback(() => {
269 const textarea = textareaRef.current;318 const textarea = textareaRef.current;
270 if (!textarea) return;319 if (!textarea) return;
...@@ -319,6 +368,44 @@ export default function ChatView() {...@@ -319,6 +368,44 @@ export default function ChatView() {
319 setExpandedWorkGroups({});368 setExpandedWorkGroups({});
320 }, [activeThread?.id]);369 }, [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
322 const activeWorktreePath = activeThread?.worktreePath;409 const activeWorktreePath = activeThread?.worktreePath;
323410
324 useEffect(() => {411 useEffect(() => {
...@@ -431,6 +518,125 @@ export default function ChatView() {...@@ -431,6 +518,125 @@ export default function ChatView() {
431 setIsEditorMenuOpen(false);518 setIsEditorMenuOpen(false);
432 };519 };
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
434 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {640 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
435 if (!api || !activeThread || !activeProject) return null;641 if (!api || !activeThread || !activeProject) return null;
436 if (activeThread.session && activeThread.session.status !== "closed") {642 if (activeThread.session && activeThread.session.status !== "closed") {
...@@ -492,8 +698,9 @@ export default function ChatView() {...@@ -492,8 +698,9 @@ export default function ChatView() {
492 e.preventDefault();698 e.preventDefault();
493 if (!api || !activeThread || isSending || isConnecting) return;699 if (!api || !activeThread || isSending || isConnecting) return;
494 const trimmed = prompt.trim();700 const trimmed = prompt.trim();
495 if (!trimmed) return;701 if (!trimmed && composerImages.length === 0) return;
496 if (!activeProject) return;702 if (!activeProject) return;
703 const composerImagesSnapshot = [...composerImages];
497704
498 // On first message: lock in branch + create worktree if needed.705 // On first message: lock in branch + create worktree if needed.
499 let sessionCwd: string | undefined;706 let sessionCwd: string | undefined;
...@@ -529,7 +736,12 @@ export default function ChatView() {...@@ -529,7 +736,12 @@ export default function ChatView() {
529736
530 // Auto-title from first message737 // Auto-title from first message
531 if (activeThread.messages.length === 0) {738 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;
533 dispatch({745 dispatch({
534 type: "SET_THREAD_TITLE",746 type: "SET_THREAD_TITLE",
535 threadId: activeThread.id,747 threadId: activeThread.id,
...@@ -537,43 +749,63 @@ export default function ChatView() {...@@ -537,43 +749,63 @@ export default function ChatView() {
537 });749 });
538 }750 }
539751
540 dispatch({752 setThreadError(activeThread.id, null);
541 type: "SET_ERROR",753 const messageAttachments: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({
542 threadId: activeThread.id,754 type: "image",
543 error: null,755 id: image.id,
544 });756 name: image.name,
757 mimeType: image.mimeType,
758 sizeBytes: image.sizeBytes,
759 previewUrl: image.previewUrl,
760 }));
545 dispatch({761 dispatch({
546 type: "PUSH_USER_MESSAGE",762 type: "PUSH_USER_MESSAGE",
547 threadId: activeThread.id,763 threadId: activeThread.id,
548 id: crypto.randomUUID(),764 id: crypto.randomUUID(),
549 text: trimmed,765 text: trimmed,
766 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
550 });767 });
551 const previousMessages = activeThread.messages;768 const previousMessages = activeThread.messages;
552 setPrompt("");769 setPrompt("");
770 setComposerImages([]);
553771
554 const sessionInfo = await ensureSession(sessionCwd);772 const sessionInfo = await ensureSession(sessionCwd);
555 if (!sessionInfo) return;773 if (!sessionInfo) return;
556774
557 setIsSending(true);775 setIsSending(true);
558 try {776 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 );
559 const shouldBootstrap =786 const shouldBootstrap =
560 previousMessages.length > 0 &&787 previousMessages.length > 0 &&
561 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");788 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
789 const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
562 const input = shouldBootstrap790 const input = shouldBootstrap
563 ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text791 ? buildBootstrapInput(
564 : trimmed;792 previousMessages,
793 latestPromptForBootstrap,
794 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
795 ).text
796 : (trimmed || undefined);
565 await api.providers.sendTurn({797 await api.providers.sendTurn({
566 sessionId: sessionInfo.sessionId,798 sessionId: sessionInfo.sessionId,
567 input,799 ...(input ? { input } : {}),
800 ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}),
568 model: selectedModel || undefined,801 model: selectedModel || undefined,
569 effort: selectedEffort || undefined,802 effort: selectedEffort || undefined,
570 });803 });
571 } catch (err) {804 } catch (err) {
572 dispatch({805 setThreadError(
573 type: "SET_ERROR",806 activeThread.id,
574 threadId: activeThread.id,807 err instanceof Error ? err.message : "Failed to send message.",
575 error: err instanceof Error ? err.message : "Failed to send message.",808 );
576 });
577 } finally {809 } finally {
578 setIsSending(false);810 setIsSending(false);
579 }811 }
...@@ -631,7 +863,11 @@ export default function ChatView() {...@@ -631,7 +863,11 @@ export default function ChatView() {
631 if (!activeThread) {863 if (!activeThread) {
632 return (864 return (
633 <div className="flex min-h-0 flex-1 flex-col bg-background text-muted-foreground/40">865 <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 )}
635 <div className="flex flex-1 items-center justify-center">871 <div className="flex flex-1 items-center justify-center">
636 <div className="text-center">872 <div className="text-center">
637 <p className="text-sm">Select a thread or create a new one to get started.</p>873 <p className="text-sm">Select a thread or create a new one to get started.</p>
...@@ -645,7 +881,7 @@ export default function ChatView() {...@@ -645,7 +881,7 @@ export default function ChatView() {
645 <div className="flex min-h-0 flex-1 flex-col bg-background">881 <div className="flex min-h-0 flex-1 flex-col bg-background">
646 {/* Top bar */}882 {/* Top bar */}
647 <header883 <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"}`}
649 >885 >
650 <div className="flex items-center gap-3">886 <div className="flex items-center gap-3">
651 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>887 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
...@@ -876,13 +1112,39 @@ export default function ChatView() {...@@ -876,13 +1112,39 @@ export default function ChatView() {
876 }1112 }
8771113
878 if (timelineEntry.message.role === "user") {1114 if (timelineEntry.message.role === "user") {
1115 const userImages = timelineEntry.message.attachments ?? [];
879 return (1116 return (
880 <Fragment key={timelineEntry.id}>1117 <Fragment key={timelineEntry.id}>
881 <div className="flex justify-end">1118 <div className="flex justify-end">
882 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">1119 <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">1120 {userImages.length > 0 && (
884 {timelineEntry.message.text}1121 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
885 </pre>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 )}
886 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">1148 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
887 {formatTimestamp(timelineEntry.message.createdAt)}1149 {formatTimestamp(timelineEntry.message.createdAt)}
888 </p>1150 </p>
...@@ -945,9 +1207,48 @@ export default function ChatView() {...@@ -945,9 +1207,48 @@ export default function ChatView() {
945 {/* Input bar */}1207 {/* Input bar */}
946 <div className="px-5 pb-1 pt-2">1208 <div className="px-5 pb-1 pt-2">
947 <form onSubmit={onSend} className="mx-auto max-w-3xl">1209 <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 >
949 {/* Textarea area */}1219 {/* Textarea area */}
950 <div className="px-4 pt-4 pb-2">1220 <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 )}
951 <textarea1252 <textarea
952 ref={textareaRef}1253 ref={textareaRef}
953 className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none"1254 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() {...@@ -955,8 +1256,11 @@ export default function ChatView() {
955 value={prompt}1256 value={prompt}
956 onChange={(e) => setPrompt(e.target.value)}1257 onChange={(e) => setPrompt(e.target.value)}
957 onKeyDown={onKeyDown}1258 onKeyDown={onKeyDown}
1259 onPaste={onComposerPaste}
958 placeholder={1260 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..."
960 }1264 }
961 disabled={isSending || isConnecting}1265 disabled={isSending || isConnecting}
962 />1266 />
...@@ -1146,7 +1450,7 @@ export default function ChatView() {...@@ -1146,7 +1450,7 @@ export default function ChatView() {
1146 <button1450 <button
1147 type="submit"1451 type="submit"
1148 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"1452 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)}
1150 aria-label={1454 aria-label={
1151 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"1455 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
1152 }1456 }
...@@ -1221,6 +1525,39 @@ export default function ChatView() {...@@ -1221,6 +1525,39 @@ export default function ChatView() {
1221 }1525 }
1222 />1526 />
1223 )}1527 )}
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 )}
1224 </div>1561 </div>
1225 );1562 );
1226}1563}
apps/web/src/components/DiffPanel.tsx+1-1
...@@ -8,7 +8,7 @@ export default function DiffPanel() {...@@ -8,7 +8,7 @@ export default function DiffPanel() {
8 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">8 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
9 {/* Header */}9 {/* Header */}
10 <div10 <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"}`}
12 >12 >
13 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>13 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
14 <button14 <button
apps/web/src/components/Sidebar.tsx+3-3
...@@ -237,10 +237,10 @@ export default function Sidebar() {...@@ -237,10 +237,10 @@ export default function Sidebar() {
237237
238 return (238 return (
239 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">239 <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" />}
242 {/* Branding */}240 {/* 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 >
244 <span className="flex-1 text-sm font-semibold tracking-tight text-foreground">244 <span className="flex-1 text-sm font-semibold tracking-tight text-foreground">
245 T3 <span className="font-normal text-muted-foreground">Code</span>245 T3 <span className="font-normal text-muted-foreground">Code</span>
246 </span>246 </span>
apps/web/src/historyBootstrap.test.ts+28
...@@ -91,4 +91,32 @@ describe("buildBootstrapInput", () => {...@@ -91,4 +91,32 @@ describe("buildBootstrapInput", () => {
91 expect(result.omittedCount).toBe(1);91 expect(result.omittedCount).toBe(1);
92 expect(result.truncated).toBe(true);92 expect(result.truncated).toBe(true);
93 });93 });
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 });
94});122});
apps/web/src/historyBootstrap.ts+29-3
...@@ -18,8 +18,34 @@ function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {...@@ -18,8 +18,34 @@ function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {
18 return message.role === "assistant" ? "ASSISTANT" : "USER";18 return message.role === "assistant" ? "ASSISTANT" : "USER";
19}19}
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
21function buildMessageBlock(message: ChatMessage): string {35function 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)`;
23}49}
2450
25function finalizeWithPrompt(51function finalizeWithPrompt(
...@@ -68,7 +94,7 @@ export function buildBootstrapInput(...@@ -68,7 +94,7 @@ export function buildBootstrapInput(
68 let includedNewestFirst: string[] = [];94 let includedNewestFirst: string[] = [];
69 for (const block of newestFirstBlocks) {95 for (const block of newestFirstBlocks) {
70 const nextNewestFirst = [...includedNewestFirst, block];96 const nextNewestFirst = [...includedNewestFirst, block];
71 const nextChronological = [...nextNewestFirst].reverse();97 const nextChronological = nextNewestFirst.toReversed();
72 const omittedCount = newestFirstBlocks.length - nextChronological.length;98 const omittedCount = newestFirstBlocks.length - nextChronological.length;
73 const transcriptBody =99 const transcriptBody =
74 omittedCount > 0100 omittedCount > 0
...@@ -80,7 +106,7 @@ export function buildBootstrapInput(...@@ -80,7 +106,7 @@ export function buildBootstrapInput(
80 includedNewestFirst = nextNewestFirst;106 includedNewestFirst = nextNewestFirst;
81 }107 }
82108
83 let includedChronological = [...includedNewestFirst].reverse();109 let includedChronological = includedNewestFirst.toReversed();
84 while (true) {110 while (true) {
85 const omittedCount = newestFirstBlocks.length - includedChronological.length;111 const omittedCount = newestFirstBlocks.length - includedChronological.length;
86 const transcriptBody =112 const transcriptBody =
apps/web/src/persistenceSchema.test.ts+28-1
...@@ -200,6 +200,16 @@ describe("toPersistedState", () => {...@@ -200,6 +200,16 @@ describe("toPersistedState", () => {
200 id: "m-1",200 id: "m-1",
201 role: "user",201 role: "user",
202 text: "Hi",202 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 ],
203 createdAt: "2026-02-08T10:00:00.000Z",213 createdAt: "2026-02-08T10:00:00.000Z",
204 streaming: false,214 streaming: false,
205 },215 },
...@@ -236,7 +246,24 @@ describe("toPersistedState", () => {...@@ -236,7 +246,24 @@ describe("toPersistedState", () => {
236 model: "gpt-5.3-codex",246 model: "gpt-5.3-codex",
237 terminalOpen: true,247 terminalOpen: true,
238 terminalHeight: 320,248 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 ],
240 createdAt: thread.createdAt,267 createdAt: thread.createdAt,
241 branch: null,268 branch: null,
242 worktreePath: null,269 worktreePath: null,
apps/web/src/persistenceSchema.ts+42-5
...@@ -23,6 +23,17 @@ const persistedMessageSchema = z.object({...@@ -23,6 +23,17 @@ const persistedMessageSchema = z.object({
23 id: z.string().min(1),23 id: z.string().min(1),
24 role: z.enum(["user", "assistant"]),24 role: z.enum(["user", "assistant"]),
25 text: z.string(),25 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(),
26 createdAt: z.string().min(1),37 createdAt: z.string().min(1),
27 streaming: z.boolean(),38 streaming: z.boolean(),
28});39});
...@@ -126,10 +137,19 @@ function hydrateThread(...@@ -126,10 +137,19 @@ function hydrateThread(
126 terminalOpen: thread.terminalOpen ?? false,137 terminalOpen: thread.terminalOpen ?? false,
127 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,138 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
128 session: null,139 session: null,
129 messages: thread.messages.map((message) => ({140 messages: thread.messages.map((message) => {
130 ...message,141 const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));
131 streaming: false,142 return {
132 })),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 }),
133 events: [],153 events: [],
134 error: null,154 error: null,
135 createdAt: thread.createdAt,155 createdAt: thread.createdAt,
...@@ -189,7 +209,24 @@ export function toPersistedState(...@@ -189,7 +209,24 @@ export function toPersistedState(
189 model: thread.model,209 model: thread.model,
190 terminalOpen: thread.terminalOpen,210 terminalOpen: thread.terminalOpen,
191 terminalHeight: thread.terminalHeight,211 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 })),
193 createdAt: thread.createdAt,230 createdAt: thread.createdAt,
194 branch: thread.branch,231 branch: thread.branch,
195 worktreePath: thread.worktreePath,232 worktreePath: thread.worktreePath,
apps/web/src/store.ts+11-1
...@@ -13,6 +13,7 @@ import { resolveModelSlug } from "./model-logic";...@@ -13,6 +13,7 @@ import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import {15import {
16 type ChatAttachment,
16 DEFAULT_RUNTIME_MODE,17 DEFAULT_RUNTIME_MODE,
17 type Project,18 type Project,
18 type RuntimeMode,19 type RuntimeMode,
...@@ -37,7 +38,13 @@ type Action =...@@ -37,7 +38,13 @@ type Action =
37 activeAssistantItemRef: { current: string | null };38 activeAssistantItemRef: { current: string | null };
38 }39 }
39 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }40 | { 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 }
41 | { type: "SET_ERROR"; threadId: string; error: string | null }48 | { type: "SET_ERROR"; threadId: string; error: string | null }
42 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }49 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
43 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }50 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
...@@ -380,6 +387,9 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -380,6 +387,9 @@ export function reducer(state: AppState, action: Action): AppState {
380 id: action.id,387 id: action.id,
381 role: "user" as const,388 role: "user" as const,
382 text: action.text,389 text: action.text,
390 ...(action.attachments && action.attachments.length > 0
391 ? { attachments: action.attachments }
392 : {}),
383 createdAt: new Date().toISOString(),393 createdAt: new Date().toISOString(),
384 streaming: false,394 streaming: false,
385 },395 },
apps/web/src/types.ts+12
...@@ -5,10 +5,22 @@ export type RuntimeMode = "approval-required" | "full-access";...@@ -5,10 +5,22 @@ export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;6export 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
8export interface ChatMessage {19export interface ChatMessage {
9 id: string;20 id: string;
10 role: "user" | "assistant";21 role: "user" | "assistant";
11 text: string;22 text: string;
23 attachments?: ChatAttachment[];
12 createdAt: string;24 createdAt: string;
13 streaming: boolean;25 streaming: boolean;
14}26}
packages/contracts/src/model.ts+8-1
...@@ -1,10 +1,17 @@...@@ -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
3export const DEFAULT_MODEL = "gpt-5.3-codex";8export const DEFAULT_MODEL = "gpt-5.3-codex";
49
5export const MODEL_SLUG_ALIASES: Record<string, string> = {10export const MODEL_SLUG_ALIASES: Record<string, string> = {
6 "5.3": "gpt-5.3-codex",11 "5.3": "gpt-5.3-codex",
7 "gpt-5.3": "gpt-5.3-codex",12 "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",
8};15};
916
10export function normalizeModelSlug(model: string | null | undefined): string | null {17export function normalizeModelSlug(model: string | null | undefined): string | null {
packages/contracts/src/provider.test.ts+59
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 providerEventSchema,5 providerEventSchema,
5 providerRespondToRequestInputSchema,6 providerRespondToRequestInputSchema,
6 providerSendTurnInputSchema,7 providerSendTurnInputSchema,
...@@ -40,9 +41,67 @@ describe("providerSendTurnInputSchema", () => {...@@ -40,9 +41,67 @@ describe("providerSendTurnInputSchema", () => {
40 effort: " high ",41 effort: " high ",
41 });42 });
42 expect(parsed.input).toBe("summarize this repo");43 expect(parsed.input).toBe("summarize this repo");
44 expect(parsed.attachments).toEqual([]);
43 expect(parsed.model).toBe("gpt-5.2-codex");45 expect(parsed.model).toBe("gpt-5.2-codex");
44 expect(parsed.effort).toBe("high");46 expect(parsed.effort).toBe("high");
45 });47 });
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 });
46});105});
47106
48describe("providerEventSchema", () => {107describe("providerEventSchema", () => {
packages/contracts/src/provider.ts+43-6
...@@ -55,14 +55,48 @@ export const providerSessionStartInputSchema = z.object({...@@ -55,14 +55,48 @@ export const providerSessionStartInputSchema = z.object({
55});55});
5656
57export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;57export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
5858export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
59export const providerSendTurnInputSchema = z.object({59export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
60 sessionId: z.string().min(1),60export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
61 input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS),61
62 model: z.string().trim().min(1).optional(),62export const providerSendTurnImageAttachmentSchema = z.object({
63 effort: z.string().trim().min(1).optional(),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),
64});73});
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
66export const providerTurnStartResultSchema = z.object({100export const providerTurnStartResultSchema = z.object({
67 threadId: z.string().min(1),101 threadId: z.string().min(1),
68 turnId: z.string().min(1),102 turnId: z.string().min(1),
...@@ -110,6 +144,9 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc...@@ -110,6 +144,9 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc
110export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;144export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
111export type ProviderSession = z.infer<typeof providerSessionSchema>;145export type ProviderSession = z.infer<typeof providerSessionSchema>;
112export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;146export 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>;
113export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;150export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
114export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;151export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
115export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;152export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;