| 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,11 +1,42 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | CodexAppServerManager, | ||
| 4 | classifyCodexStderrLine, | 5 | classifyCodexStderrLine, |
| 5 | isRecoverableThreadResumeError, | 6 | isRecoverableThreadResumeError, |
| 6 | normalizeCodexModelSlug, | 7 | normalizeCodexModelSlug, |
| 7 | } from "./codexAppServerManager"; | 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 | describe("classifyCodexStderrLine", () => { | 40 | describe("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 | |||
| 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,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 | } |
| 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 | 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 6 | import { | 9 | import { |
| 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"; |
| 35 | import { useStore } from "../store"; | 40 | import { useStore } from "../store"; |
| 41 | import type { ChatImageAttachment } from "../types"; | ||
| 36 | import BranchToolbar from "./BranchToolbar"; | 42 | import BranchToolbar from "./BranchToolbar"; |
| 37 | import GitActionsControl from "./GitActionsControl"; | 43 | import GitActionsControl from "./GitActionsControl"; |
| 38 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; | 44 | import { 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 { |
| 57 | 63 | ||
| 58 | const LAST_EDITOR_KEY = "t3code:last-editor"; | 64 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 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 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 70 | function 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 | } |
| 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 | function asRecord(value: unknown): Record<string, unknown> | undefined { | 102 | function 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 | } |
| 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 | export default function ChatView() { | 170 | export 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]); |
| 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 | const activeWorktreePath = activeThread?.worktreePath; | 409 | const activeWorktreePath = activeThread?.worktreePath; |
| 323 | 410 | ||
| 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 | }; |
| 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 | 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]; | ||
| 497 | 704 | ||
| 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() { |
| 529 | 736 | ||
| 530 | // Auto-title from first message | 737 | // 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 | } |
| 539 | 751 | ||
| 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([]); | ||
| 553 | 771 | ||
| 554 | const sessionInfo = await ensureSession(sessionCwd); | 772 | const sessionInfo = await ensureSession(sessionCwd); |
| 555 | if (!sessionInfo) return; | 773 | if (!sessionInfo) return; |
| 556 | 774 | ||
| 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 = shouldBootstrap | 790 | const input = shouldBootstrap |
| 563 | ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text | 791 | ? 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 | <header | 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 | <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 | } |
| 877 | 1113 | ||
| 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 | <textarea | 1252 | <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 | <button | 1450 | <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 | <div | 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 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> | 13 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> |
| 14 | <button | 14 | <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() { |
| 237 | 237 | ||
| 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 | } |
| 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 | function buildMessageBlock(message: ChatMessage): string { | 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 | function finalizeWithPrompt( | 51 | function 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 > 0 | 100 | omittedCount > 0 |
| ... | @@ -80,7 +106,7 @@ export function buildBootstrapInput( | ... | @@ -80,7 +106,7 @@ export function buildBootstrapInput( |
| 80 | includedNewestFirst = nextNewestFirst; | 106 | includedNewestFirst = nextNewestFirst; |
| 81 | } | 107 | } |
| 82 | 108 | ||
| 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"; |
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; |
| 15 | import { | 15 | import { |
| 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"; |
| 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; | 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 | export interface ChatMessage { | 19 | export 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 @@ |
| 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 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | 8 | export const DEFAULT_MODEL = "gpt-5.3-codex"; |
| 4 | 9 | ||
| 5 | export const MODEL_SLUG_ALIASES: Record<string, string> = { | 10 | export 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 | }; |
| 9 | 16 | ||
| 10 | export function normalizeModelSlug(model: string | null | undefined): string | null { | 17 | export function normalizeModelSlug(model: string | null | undefined): string | null { |
packages/contracts/src/provider.test.ts+59| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 47 | 106 | ||
| 48 | describe("providerEventSchema", () => { | 107 | describe("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 | }); |
| 56 | 56 | ||
| 57 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | 57 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 58 | 58 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 59 | export const providerSendTurnInputSchema = z.object({ | 59 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 60 | sessionId: z.string().min(1), | 60 | export 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(), | 62 | export 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 | }); |
| 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 | export const providerTurnStartResultSchema = z.object({ | 100 | export 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 |
| 110 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; | 144 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; |
| 111 | export type ProviderSession = z.infer<typeof providerSessionSchema>; | 145 | export type ProviderSession = z.infer<typeof providerSessionSchema>; |
| 112 | export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>; | 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 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; | 150 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; |
| 114 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; | 151 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; |
| 115 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; | 152 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; |