| author | |
| committer | |
| log | 1dca7012b5e34e8cbc7bfd8cc01d83fbaa4dff83 |
| tree | bcae8215b3dea28c859cac261c8da04e23791aa2 |
| parent | 44f3b3cb7c82c6a2cfd33dad92bb76260f9e32ff |
| parent | ea16f6fdd7a0197fa268032f26dad58213df66a5 |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 770 insertions(+), 54 deletions(-)
apps/server/src/codexAppServerManager.test.ts+117-1| ... | ... | @@ -1,11 +1,42 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | CodexAppServerManager, | |
| 4 | 5 | classifyCodexStderrLine, |
| 5 | 6 | isRecoverableThreadResumeError, |
| 6 | 7 | normalizeCodexModelSlug, |
| 7 | 8 | } from "./codexAppServerManager"; |
| 8 | 9 | |
| 10 | function createSendTurnHarness() { | |
| 11 | const manager = new CodexAppServerManager(); | |
| 12 | const context = { | |
| 13 | session: { | |
| 14 | sessionId: "sess_1", | |
| 15 | provider: "codex", | |
| 16 | status: "ready", | |
| 17 | threadId: "thread_1", | |
| 18 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 19 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 20 | }, | |
| 21 | }; | |
| 22 | ||
| 23 | const requireSession = vi | |
| 24 | .spyOn(manager as unknown as { requireSession: (sessionId: string) => unknown }, "requireSession") | |
| 25 | .mockReturnValue(context); | |
| 26 | const sendRequest = vi | |
| 27 | .spyOn(manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> }, "sendRequest") | |
| 28 | .mockResolvedValue({ | |
| 29 | turn: { | |
| 30 | id: "turn_1", | |
| 31 | }, | |
| 32 | }); | |
| 33 | const updateSession = vi | |
| 34 | .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") | |
| 35 | .mockImplementation(() => {}); | |
| 36 | ||
| 37 | return { manager, context, requireSession, sendRequest, updateSession }; | |
| 38 | } | |
| 39 | ||
| 9 | 40 | describe("classifyCodexStderrLine", () => { |
| 10 | 41 | it("ignores empty lines", () => { |
| 11 | 42 | expect(classifyCodexStderrLine(" ")).toBeNull(); |
| ... | ... | @@ -75,3 +106,88 @@ describe("isRecoverableThreadResumeError", () => { |
| 75 | 106 | ).toBe(false); |
| 76 | 107 | }); |
| 77 | 108 | }); |
| 109 | ||
| 110 | describe("sendTurn", () => { | |
| 111 | it("sends text and image user input items to turn/start", async () => { | |
| 112 | const { manager, context, requireSession, sendRequest, updateSession } = createSendTurnHarness(); | |
| 113 | ||
| 114 | const result = await manager.sendTurn({ | |
| 115 | sessionId: "sess_1", | |
| 116 | input: "Inspect this image", | |
| 117 | attachments: [ | |
| 118 | { | |
| 119 | type: "image", | |
| 120 | name: "error.png", | |
| 121 | mimeType: "image/png", | |
| 122 | sizeBytes: 1_024, | |
| 123 | dataUrl: "data:image/png;base64,AAAA", | |
| 124 | }, | |
| 125 | ], | |
| 126 | model: "gpt-5.3", | |
| 127 | effort: "high", | |
| 128 | }); | |
| 129 | ||
| 130 | expect(result).toEqual({ | |
| 131 | threadId: "thread_1", | |
| 132 | turnId: "turn_1", | |
| 133 | }); | |
| 134 | expect(requireSession).toHaveBeenCalledWith("sess_1"); | |
| 135 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { | |
| 136 | threadId: "thread_1", | |
| 137 | input: [ | |
| 138 | { | |
| 139 | type: "text", | |
| 140 | text: "Inspect this image", | |
| 141 | text_elements: [], | |
| 142 | }, | |
| 143 | { | |
| 144 | type: "image", | |
| 145 | url: "data:image/png;base64,AAAA", | |
| 146 | }, | |
| 147 | ], | |
| 148 | model: "gpt-5.3-codex", | |
| 149 | effort: "high", | |
| 150 | }); | |
| 151 | expect(updateSession).toHaveBeenCalledWith(context, { | |
| 152 | status: "running", | |
| 153 | activeTurnId: "turn_1", | |
| 154 | }); | |
| 155 | }); | |
| 156 | ||
| 157 | it("supports image-only turns", async () => { | |
| 158 | const { manager, context, sendRequest } = createSendTurnHarness(); | |
| 159 | ||
| 160 | await manager.sendTurn({ | |
| 161 | sessionId: "sess_1", | |
| 162 | attachments: [ | |
| 163 | { | |
| 164 | type: "image", | |
| 165 | name: "diagram.png", | |
| 166 | mimeType: "image/png", | |
| 167 | sizeBytes: 256, | |
| 168 | dataUrl: "data:image/png;base64,BBBB", | |
| 169 | }, | |
| 170 | ], | |
| 171 | }); | |
| 172 | ||
| 173 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { | |
| 174 | threadId: "thread_1", | |
| 175 | input: [ | |
| 176 | { | |
| 177 | type: "image", | |
| 178 | url: "data:image/png;base64,BBBB", | |
| 179 | }, | |
| 180 | ], | |
| 181 | }); | |
| 182 | }); | |
| 183 | ||
| 184 | it("rejects empty turn input", async () => { | |
| 185 | const { manager } = createSendTurnHarness(); | |
| 186 | ||
| 187 | await expect( | |
| 188 | manager.sendTurn({ | |
| 189 | sessionId: "sess_1", | |
| 190 | }), | |
| 191 | ).rejects.toThrow("Turn input must include text or attachments."); | |
| 192 | }); | |
| 193 | }); |
apps/server/src/codexAppServerManager.ts+28-8| ... | ... | @@ -257,20 +257,40 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 257 | 257 | throw new Error("Session is missing a thread id."); |
| 258 | 258 | } |
| 259 | 259 | |
| 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 | 283 | const turnStartParams: { |
| 261 | 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 | 289 | model?: string; |
| 264 | 290 | effort?: string; |
| 265 | 291 | } = { |
| 266 | 292 | threadId: context.session.threadId, |
| 267 | input: [ | |
| 268 | { | |
| 269 | type: "text", | |
| 270 | text: input.input, | |
| 271 | text_elements: [], | |
| 272 | }, | |
| 273 | ], | |
| 293 | input: turnInput, | |
| 274 | 294 | }; |
| 275 | 295 | const normalizedModel = normalizeCodexModelSlug(input.model); |
| 276 | 296 | if (normalizedModel) { |
apps/web/src/components/ChatView.tsx+361-24| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | 1 | import { |
| 2 | 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 5 | type ProviderSendTurnAttachmentInput, | |
| 3 | 6 | type ProviderApprovalDecision, |
| 4 | 7 | type ProviderEvent, |
| 5 | 8 | } from "@t3tools/contracts"; |
| 6 | 9 | import { |
| 10 | type ClipboardEvent, | |
| 11 | type DragEvent, | |
| 7 | 12 | type FormEvent, |
| 8 | 13 | Fragment, |
| 9 | 14 | type KeyboardEvent, |
| ... | ... | @@ -33,6 +38,7 @@ import { |
| 33 | 38 | formatTimestamp, |
| 34 | 39 | } from "../session-logic"; |
| 35 | 40 | import { useStore } from "../store"; |
| 41 | import type { ChatImageAttachment } from "../types"; | |
| 36 | 42 | import BranchToolbar from "./BranchToolbar"; |
| 37 | 43 | import GitActionsControl from "./GitActionsControl"; |
| 38 | 44 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; |
| ... | ... | @@ -57,6 +63,9 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { |
| 57 | 63 | |
| 58 | 64 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 59 | 65 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 66 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | |
| 67 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | |
| 68 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | |
| 60 | 69 | |
| 61 | 70 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 62 | 71 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | ... | @@ -80,6 +89,16 @@ interface EnsuredSessionInfo { |
| 80 | 89 | continuityState: SessionContinuityState; |
| 81 | 90 | } |
| 82 | 91 | |
| 92 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { | |
| 93 | previewUrl: string; | |
| 94 | file: File; | |
| 95 | } | |
| 96 | ||
| 97 | interface ExpandedImagePreview { | |
| 98 | src: string; | |
| 99 | name: string; | |
| 100 | } | |
| 101 | ||
| 83 | 102 | function asRecord(value: unknown): Record<string, unknown> | undefined { |
| 84 | 103 | if (!value || typeof value !== "object") return undefined; |
| 85 | 104 | return value as Record<string, unknown>; |
| ... | ... | @@ -131,10 +150,30 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] |
| 131 | 150 | return Array.from(pending.values()); |
| 132 | 151 | } |
| 133 | 152 | |
| 153 | function readFileAsDataUrl(file: File): Promise<string> { | |
| 154 | return new Promise((resolve, reject) => { | |
| 155 | const reader = new FileReader(); | |
| 156 | reader.addEventListener("load", () => { | |
| 157 | if (typeof reader.result === "string") { | |
| 158 | resolve(reader.result); | |
| 159 | return; | |
| 160 | } | |
| 161 | reject(new Error("Could not read image data.")); | |
| 162 | }); | |
| 163 | reader.addEventListener("error", () => { | |
| 164 | reject(reader.error ?? new Error("Failed to read image.")); | |
| 165 | }); | |
| 166 | reader.readAsDataURL(file); | |
| 167 | }); | |
| 168 | } | |
| 169 | ||
| 134 | 170 | export default function ChatView() { |
| 135 | 171 | const { state, dispatch } = useStore(); |
| 136 | 172 | const api = useNativeApi(); |
| 137 | 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 | 177 | const [isSending, setIsSending] = useState(false); |
| 139 | 178 | const [isConnecting, setIsConnecting] = useState(false); |
| 140 | 179 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| ... | ... | @@ -152,6 +191,8 @@ export default function ChatView() { |
| 152 | 191 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 153 | 192 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 154 | 193 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 194 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); | |
| 195 | const dragDepthRef = useRef(0); | |
| 155 | 196 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 156 | 197 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 157 | 198 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | ... | @@ -264,7 +305,15 @@ export default function ChatView() { |
| 264 | 305 | (activeThread.messages.length > 0 || |
| 265 | 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 | 317 | const focusComposer = useCallback(() => { |
| 269 | 318 | const textarea = textareaRef.current; |
| 270 | 319 | if (!textarea) return; |
| ... | ... | @@ -319,6 +368,44 @@ export default function ChatView() { |
| 319 | 368 | setExpandedWorkGroups({}); |
| 320 | 369 | }, [activeThread?.id]); |
| 321 | 370 | |
| 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 | 409 | const activeWorktreePath = activeThread?.worktreePath; |
| 323 | 410 | |
| 324 | 411 | useEffect(() => { |
| ... | ... | @@ -431,6 +518,125 @@ export default function ChatView() { |
| 431 | 518 | setIsEditorMenuOpen(false); |
| 432 | 519 | }; |
| 433 | 520 | |
| 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 | 640 | const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { |
| 435 | 641 | if (!api || !activeThread || !activeProject) return null; |
| 436 | 642 | if (activeThread.session && activeThread.session.status !== "closed") { |
| ... | ... | @@ -492,8 +698,9 @@ export default function ChatView() { |
| 492 | 698 | e.preventDefault(); |
| 493 | 699 | if (!api || !activeThread || isSending || isConnecting) return; |
| 494 | 700 | const trimmed = prompt.trim(); |
| 495 | if (!trimmed) return; | |
| 701 | if (!trimmed && composerImages.length === 0) return; | |
| 496 | 702 | if (!activeProject) return; |
| 703 | const composerImagesSnapshot = [...composerImages]; | |
| 497 | 704 | |
| 498 | 705 | // On first message: lock in branch + create worktree if needed. |
| 499 | 706 | let sessionCwd: string | undefined; |
| ... | ... | @@ -529,7 +736,12 @@ export default function ChatView() { |
| 529 | 736 | |
| 530 | 737 | // Auto-title from first message |
| 531 | 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 | 745 | dispatch({ |
| 534 | 746 | type: "SET_THREAD_TITLE", |
| 535 | 747 | threadId: activeThread.id, |
| ... | ... | @@ -537,43 +749,63 @@ export default function ChatView() { |
| 537 | 749 | }); |
| 538 | 750 | } |
| 539 | 751 | |
| 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 | })); | |
| 545 | 761 | dispatch({ |
| 546 | 762 | type: "PUSH_USER_MESSAGE", |
| 547 | 763 | threadId: activeThread.id, |
| 548 | 764 | id: crypto.randomUUID(), |
| 549 | 765 | text: trimmed, |
| 766 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), | |
| 550 | 767 | }); |
| 551 | 768 | const previousMessages = activeThread.messages; |
| 552 | 769 | setPrompt(""); |
| 770 | setComposerImages([]); | |
| 553 | 771 | |
| 554 | 772 | const sessionInfo = await ensureSession(sessionCwd); |
| 555 | 773 | if (!sessionInfo) return; |
| 556 | 774 | |
| 557 | 775 | setIsSending(true); |
| 558 | 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 | 786 | const shouldBootstrap = |
| 560 | 787 | previousMessages.length > 0 && |
| 561 | 788 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); |
| 789 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; | |
| 562 | 790 | 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); | |
| 565 | 797 | await api.providers.sendTurn({ |
| 566 | 798 | sessionId: sessionInfo.sessionId, |
| 567 | input, | |
| 799 | ...(input ? { input } : {}), | |
| 800 | ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), | |
| 568 | 801 | model: selectedModel || undefined, |
| 569 | 802 | effort: selectedEffort || undefined, |
| 570 | 803 | }); |
| 571 | 804 | } 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 | ); | |
| 577 | 809 | } finally { |
| 578 | 810 | setIsSending(false); |
| 579 | 811 | } |
| ... | ... | @@ -631,7 +863,11 @@ export default function ChatView() { |
| 631 | 863 | if (!activeThread) { |
| 632 | 864 | return ( |
| 633 | 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 | 871 | <div className="flex flex-1 items-center justify-center"> |
| 636 | 872 | <div className="text-center"> |
| 637 | 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 | 881 | <div className="flex min-h-0 flex-1 flex-col bg-background"> |
| 646 | 882 | {/* Top bar */} |
| 647 | 883 | <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 | 886 | <div className="flex items-center gap-3"> |
| 651 | 887 | <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2> |
| ... | ... | @@ -876,13 +1112,39 @@ export default function ChatView() { |
| 876 | 1112 | } |
| 877 | 1113 | |
| 878 | 1114 | if (timelineEntry.message.role === "user") { |
| 1115 | const userImages = timelineEntry.message.attachments ?? []; | |
| 879 | 1116 | return ( |
| 880 | 1117 | <Fragment key={timelineEntry.id}> |
| 881 | 1118 | <div className="flex justify-end"> |
| 882 | 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"> | |
| 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 | )} | |
| 886 | 1148 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> |
| 887 | 1149 | {formatTimestamp(timelineEntry.message.createdAt)} |
| 888 | 1150 | </p> |
| ... | ... | @@ -945,9 +1207,48 @@ export default function ChatView() { |
| 945 | 1207 | {/* Input bar */} |
| 946 | 1208 | <div className="px-5 pb-1 pt-2"> |
| 947 | 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 | 1219 | {/* Textarea area */} |
| 950 | 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 | 1252 | <textarea |
| 952 | 1253 | ref={textareaRef} |
| 953 | 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 | 1256 | value={prompt} |
| 956 | 1257 | onChange={(e) => setPrompt(e.target.value)} |
| 957 | 1258 | onKeyDown={onKeyDown} |
| 1259 | onPaste={onComposerPaste} | |
| 958 | 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 | 1265 | disabled={isSending || isConnecting} |
| 962 | 1266 | /> |
| ... | ... | @@ -1146,7 +1450,7 @@ export default function ChatView() { |
| 1146 | 1450 | <button |
| 1147 | 1451 | type="submit" |
| 1148 | 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 | 1454 | aria-label={ |
| 1151 | 1455 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |
| 1152 | 1456 | } |
| ... | ... | @@ -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 | 1561 | </div> |
| 1225 | 1562 | ); |
| 1226 | 1563 | } |
apps/web/src/components/DiffPanel.tsx+1-1| ... | ... | @@ -8,7 +8,7 @@ export default function DiffPanel() { |
| 8 | 8 | <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card"> |
| 9 | 9 | {/* Header */} |
| 10 | 10 | <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 | 13 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> |
| 14 | 14 | <button |
apps/web/src/components/Sidebar.tsx+3-3| ... | ... | @@ -237,10 +237,10 @@ export default function Sidebar() { |
| 237 | 237 | |
| 238 | 238 | return ( |
| 239 | 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 | 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 | 244 | <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> |
| 245 | 245 | T3 <span className="font-normal text-muted-foreground">Code</span> |
| 246 | 246 | </span> |
apps/web/src/historyBootstrap.test.ts+28| ... | ... | @@ -91,4 +91,32 @@ describe("buildBootstrapInput", () => { |
| 91 | 91 | expect(result.omittedCount).toBe(1); |
| 92 | 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 | 18 | return message.role === "assistant" ? "ASSISTANT" : "USER"; |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | function attachmentSummary(message: ChatMessage): string | null { | |
| 22 | const imageAttachments = message.attachments?.filter((attachment) => attachment.type === "image"); | |
| 23 | const count = imageAttachments?.length ?? 0; | |
| 24 | if (count === 0) { | |
| 25 | return null; | |
| 26 | } | |
| 27 | ||
| 28 | const names = imageAttachments?.slice(0, 3).map((image) => image.name) ?? []; | |
| 29 | const namesSummary = names.join(", "); | |
| 30 | const extraCount = count - names.length; | |
| 31 | const extraSummary = extraCount > 0 ? ` (+${extraCount} more)` : ""; | |
| 32 | return `[Attached image${count === 1 ? "" : "s"}: ${namesSummary}${extraSummary}]`; | |
| 33 | } | |
| 34 | ||
| 21 | 35 | function buildMessageBlock(message: ChatMessage): string { |
| 22 | return `${messageRoleLabel(message)}:\n${message.text}`; | |
| 36 | const text = message.text; | |
| 37 | const attachments = attachmentSummary(message); | |
| 38 | ||
| 39 | if (text && attachments) { | |
| 40 | return `${messageRoleLabel(message)}:\n${text}\n${attachments}`; | |
| 41 | } | |
| 42 | if (text) { | |
| 43 | return `${messageRoleLabel(message)}:\n${text}`; | |
| 44 | } | |
| 45 | if (attachments) { | |
| 46 | return `${messageRoleLabel(message)}:\n${attachments}`; | |
| 47 | } | |
| 48 | return `${messageRoleLabel(message)}:\n(empty message)`; | |
| 23 | 49 | } |
| 24 | 50 | |
| 25 | 51 | function finalizeWithPrompt( |
| ... | ... | @@ -68,7 +94,7 @@ export function buildBootstrapInput( |
| 68 | 94 | let includedNewestFirst: string[] = []; |
| 69 | 95 | for (const block of newestFirstBlocks) { |
| 70 | 96 | const nextNewestFirst = [...includedNewestFirst, block]; |
| 71 | const nextChronological = [...nextNewestFirst].reverse(); | |
| 97 | const nextChronological = nextNewestFirst.toReversed(); | |
| 72 | 98 | const omittedCount = newestFirstBlocks.length - nextChronological.length; |
| 73 | 99 | const transcriptBody = |
| 74 | 100 | omittedCount > 0 |
| ... | ... | @@ -80,7 +106,7 @@ export function buildBootstrapInput( |
| 80 | 106 | includedNewestFirst = nextNewestFirst; |
| 81 | 107 | } |
| 82 | 108 | |
| 83 | let includedChronological = [...includedNewestFirst].reverse(); | |
| 109 | let includedChronological = includedNewestFirst.toReversed(); | |
| 84 | 110 | while (true) { |
| 85 | 111 | const omittedCount = newestFirstBlocks.length - includedChronological.length; |
| 86 | 112 | const transcriptBody = |
apps/web/src/persistenceSchema.test.ts+28-1| ... | ... | @@ -200,6 +200,16 @@ describe("toPersistedState", () => { |
| 200 | 200 | id: "m-1", |
| 201 | 201 | role: "user", |
| 202 | 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 | 213 | createdAt: "2026-02-08T10:00:00.000Z", |
| 204 | 214 | streaming: false, |
| 205 | 215 | }, |
| ... | ... | @@ -236,7 +246,24 @@ describe("toPersistedState", () => { |
| 236 | 246 | model: "gpt-5.3-codex", |
| 237 | 247 | terminalOpen: true, |
| 238 | 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 | 267 | createdAt: thread.createdAt, |
| 241 | 268 | branch: null, |
| 242 | 269 | worktreePath: null, |
apps/web/src/persistenceSchema.ts+42-5| ... | ... | @@ -23,6 +23,17 @@ const persistedMessageSchema = z.object({ |
| 23 | 23 | id: z.string().min(1), |
| 24 | 24 | role: z.enum(["user", "assistant"]), |
| 25 | 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 | 37 | createdAt: z.string().min(1), |
| 27 | 38 | streaming: z.boolean(), |
| 28 | 39 | }); |
| ... | ... | @@ -126,10 +137,19 @@ function hydrateThread( |
| 126 | 137 | terminalOpen: thread.terminalOpen ?? false, |
| 127 | 138 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 128 | 139 | 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 | }), | |
| 133 | 153 | events: [], |
| 134 | 154 | error: null, |
| 135 | 155 | createdAt: thread.createdAt, |
| ... | ... | @@ -189,7 +209,24 @@ export function toPersistedState( |
| 189 | 209 | model: thread.model, |
| 190 | 210 | terminalOpen: thread.terminalOpen, |
| 191 | 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 | 230 | createdAt: thread.createdAt, |
| 194 | 231 | branch: thread.branch, |
| 195 | 232 | worktreePath: thread.worktreePath, |
apps/web/src/store.ts+11-1| ... | ... | @@ -13,6 +13,7 @@ import { resolveModelSlug } from "./model-logic"; |
| 13 | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; |
| 15 | 15 | import { |
| 16 | type ChatAttachment, | |
| 16 | 17 | DEFAULT_RUNTIME_MODE, |
| 17 | 18 | type Project, |
| 18 | 19 | type RuntimeMode, |
| ... | ... | @@ -37,7 +38,13 @@ type Action = |
| 37 | 38 | activeAssistantItemRef: { current: string | null }; |
| 38 | 39 | } |
| 39 | 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 | 48 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 42 | 49 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 43 | 50 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } |
| ... | ... | @@ -380,6 +387,9 @@ export function reducer(state: AppState, action: Action): AppState { |
| 380 | 387 | id: action.id, |
| 381 | 388 | role: "user" as const, |
| 382 | 389 | text: action.text, |
| 390 | ...(action.attachments && action.attachments.length > 0 | |
| 391 | ? { attachments: action.attachments } | |
| 392 | : {}), | |
| 383 | 393 | createdAt: new Date().toISOString(), |
| 384 | 394 | streaming: false, |
| 385 | 395 | }, |
apps/web/src/types.ts+12| ... | ... | @@ -5,10 +5,22 @@ export type RuntimeMode = "approval-required" | "full-access"; |
| 5 | 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | 7 | |
| 8 | export interface ChatImageAttachment { | |
| 9 | type: "image"; | |
| 10 | id: string; | |
| 11 | name: string; | |
| 12 | mimeType: string; | |
| 13 | sizeBytes: number; | |
| 14 | previewUrl?: string; | |
| 15 | } | |
| 16 | ||
| 17 | export type ChatAttachment = ChatImageAttachment; | |
| 18 | ||
| 8 | 19 | export interface ChatMessage { |
| 9 | 20 | id: string; |
| 10 | 21 | role: "user" | "assistant"; |
| 11 | 22 | text: string; |
| 23 | attachments?: ChatAttachment[]; | |
| 12 | 24 | createdAt: string; |
| 13 | 25 | streaming: boolean; |
| 14 | 26 | } |
packages/contracts/src/model.ts+8-1| ... | ... | @@ -1,10 +1,17 @@ |
| 1 | export const MODEL_OPTIONS = ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"] as const; | |
| 1 | export const MODEL_OPTIONS = [ | |
| 2 | "gpt-5.3-codex", | |
| 3 | "gpt-5.3-codex-spark", | |
| 4 | "gpt-5.2-codex", | |
| 5 | "gpt-5.2", | |
| 6 | ] as const; | |
| 2 | 7 | |
| 3 | 8 | export const DEFAULT_MODEL = "gpt-5.3-codex"; |
| 4 | 9 | |
| 5 | 10 | export const MODEL_SLUG_ALIASES: Record<string, string> = { |
| 6 | 11 | "5.3": "gpt-5.3-codex", |
| 7 | 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 | }; |
| 9 | 16 | |
| 10 | 17 | export function normalizeModelSlug(model: string | null | undefined): string | null { |
packages/contracts/src/provider.test.ts+59| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 4 | 5 | providerEventSchema, |
| 5 | 6 | providerRespondToRequestInputSchema, |
| 6 | 7 | providerSendTurnInputSchema, |
| ... | ... | @@ -40,9 +41,67 @@ describe("providerSendTurnInputSchema", () => { |
| 40 | 41 | effort: " high ", |
| 41 | 42 | }); |
| 42 | 43 | expect(parsed.input).toBe("summarize this repo"); |
| 44 | expect(parsed.attachments).toEqual([]); | |
| 43 | 45 | expect(parsed.model).toBe("gpt-5.2-codex"); |
| 44 | 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 | }); |
| 47 | 106 | |
| 48 | 107 | describe("providerEventSchema", () => { |
packages/contracts/src/provider.ts+43-6| ... | ... | @@ -55,14 +55,48 @@ export const providerSessionStartInputSchema = z.object({ |
| 55 | 55 | }); |
| 56 | 56 | |
| 57 | 57 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 58 | ||
| 59 | export const providerSendTurnInputSchema = z.object({ | |
| 60 | sessionId: z.string().min(1), | |
| 61 | input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), | |
| 62 | model: z.string().trim().min(1).optional(), | |
| 63 | effort: z.string().trim().min(1).optional(), | |
| 58 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 59 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | |
| 60 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | |
| 61 | ||
| 62 | export const providerSendTurnImageAttachmentSchema = z.object({ | |
| 63 | type: z.literal("image"), | |
| 64 | name: z.string().trim().min(1).max(255), | |
| 65 | mimeType: z | |
| 66 | .string() | |
| 67 | .trim() | |
| 68 | .min(1) | |
| 69 | .max(100) | |
| 70 | .regex(/^image\//i, "mimeType must be an image/* MIME type"), | |
| 71 | sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), | |
| 72 | dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | |
| 64 | 73 | }); |
| 65 | 74 | |
| 75 | export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ | |
| 76 | providerSendTurnImageAttachmentSchema, | |
| 77 | ]); | |
| 78 | ||
| 79 | export const providerSendTurnInputSchema = z | |
| 80 | .object({ | |
| 81 | sessionId: z.string().min(1), | |
| 82 | input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), | |
| 83 | attachments: z | |
| 84 | .array(providerSendTurnAttachmentInputSchema) | |
| 85 | .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS) | |
| 86 | .default([]), | |
| 87 | model: z.string().trim().min(1).optional(), | |
| 88 | effort: z.string().trim().min(1).optional(), | |
| 89 | }) | |
| 90 | .superRefine((value, ctx) => { | |
| 91 | if (!value.input && value.attachments.length === 0) { | |
| 92 | ctx.addIssue({ | |
| 93 | code: z.ZodIssueCode.custom, | |
| 94 | message: "Either input text or at least one attachment is required", | |
| 95 | path: ["input"], | |
| 96 | }); | |
| 97 | } | |
| 98 | }); | |
| 99 | ||
| 66 | 100 | export const providerTurnStartResultSchema = z.object({ |
| 67 | 101 | threadId: z.string().min(1), |
| 68 | 102 | turnId: z.string().min(1), |
| ... | ... | @@ -110,6 +144,9 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc |
| 110 | 144 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; |
| 111 | 145 | export type ProviderSession = z.infer<typeof providerSessionSchema>; |
| 112 | 146 | export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>; |
| 147 | export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>; | |
| 148 | export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>; | |
| 149 | export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>; | |
| 113 | 150 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; |
| 114 | 151 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; |
| 115 | 152 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; |