| @@ -1,9 +1,14 @@ |
| 1 | 1 | import { |
| 2 | 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 4 | + PROVIDER_SEND_TURN_MAX_IMAGES, |
| 5 | + type ProviderSendTurnImageInput, |
| 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 { · 23 unmodified lines |
| 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 { · 18 unmodified lines |
| 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 { · 17 unmodified lines |
| 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[] · 45 unmodified lines |
| 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() { · 11 unmodified lines |
| 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() { · 106 unmodified lines |
| 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() { · 48 unmodified lines |
| 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,124 @@ export default function ChatView() { · 106 unmodified lines |
| 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_IMAGES) { |
| 545 | + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_IMAGES} images per message.`; |
| 546 | + break; |
| 547 | + } |
| 548 | + |
| 549 | + const previewUrl = URL.createObjectURL(file); |
| 550 | + nextImages.push({ |
| 551 | + id: crypto.randomUUID(), |
| 552 | + name: file.name || "image", |
| 553 | + mimeType: file.type, |
| 554 | + sizeBytes: file.size, |
| 555 | + previewUrl, |
| 556 | + file, |
| 557 | + }); |
| 558 | + } |
| 559 | + |
| 560 | + setComposerImages(nextImages); |
| 561 | + setThreadError(activeThreadId, error); |
| 562 | + }; |
| 563 | + |
| 564 | + const removeComposerImage = (imageId: string) => { |
| 565 | + setComposerImages((existing) => { |
| 566 | + const match = existing.find((image) => image.id === imageId); |
| 567 | + if (match?.previewUrl) { |
| 568 | + URL.revokeObjectURL(match.previewUrl); |
| 569 | + } |
| 570 | + return existing.filter((image) => image.id !== imageId); |
| 571 | + }); |
| 572 | + }; |
| 573 | + |
| 574 | + const openImageDialog = (src: string | undefined, name: string) => { |
| 575 | + if (!src) { |
| 576 | + return; |
| 577 | + } |
| 578 | + setExpandedImage({ src, name }); |
| 579 | + }; |
| 580 | + |
| 581 | + const onComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => { |
| 582 | + const files = Array.from(event.clipboardData.files); |
| 583 | + if (files.length === 0) { |
| 584 | + return; |
| 585 | + } |
| 586 | + const imageFiles = files.filter((file) => file.type.startsWith("image/")); |
| 587 | + if (imageFiles.length === 0) { |
| 588 | + return; |
| 589 | + } |
| 590 | + event.preventDefault(); |
| 591 | + addComposerImages(imageFiles); |
| 592 | + }; |
| 593 | + |
| 594 | + const onComposerDragEnter = (event: DragEvent<HTMLDivElement>) => { |
| 595 | + if (!event.dataTransfer.types.includes("Files")) { |
| 596 | + return; |
| 597 | + } |
| 598 | + event.preventDefault(); |
| 599 | + dragDepthRef.current += 1; |
| 600 | + setIsDragOverComposer(true); |
| 601 | + }; |
| 602 | + |
| 603 | + const onComposerDragOver = (event: DragEvent<HTMLDivElement>) => { |
| 604 | + if (!event.dataTransfer.types.includes("Files")) { |
| 605 | + return; |
| 606 | + } |
| 607 | + event.preventDefault(); |
| 608 | + event.dataTransfer.dropEffect = "copy"; |
| 609 | + setIsDragOverComposer(true); |
| 610 | + }; |
| 611 | + |
| 612 | + const onComposerDragLeave = (event: DragEvent<HTMLDivElement>) => { |
| 613 | + if (!event.dataTransfer.types.includes("Files")) { |
| 614 | + return; |
| 615 | + } |
| 616 | + event.preventDefault(); |
| 617 | + const nextTarget = event.relatedTarget; |
| 618 | + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { |
| 619 | + return; |
| 620 | + } |
| 621 | + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); |
| 622 | + if (dragDepthRef.current === 0) { |
| 623 | + setIsDragOverComposer(false); |
| 624 | + } |
| 625 | + }; |
| 626 | + |
| 627 | + const onComposerDrop = (event: DragEvent<HTMLDivElement>) => { |
| 628 | + if (!event.dataTransfer.types.includes("Files")) { |
| 629 | + return; |
| 630 | + } |
| 631 | + event.preventDefault(); |
| 632 | + dragDepthRef.current = 0; |
| 633 | + setIsDragOverComposer(false); |
| 634 | + const files = Array.from(event.dataTransfer.files); |
| 635 | + addComposerImages(files); |
| 636 | + focusComposer(); |
| 637 | + }; |
| 638 | + |
| 434 | 639 | const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { |
| 435 | 640 | if (!api || !activeThread || !activeProject) return null; |
| 436 | 641 | if (activeThread.session && activeThread.session.status !== "closed") { |
| @@ -492,8 +697,9 @@ export default function ChatView() { · 55 unmodified lines |
| 492 | 697 | e.preventDefault(); |
| 493 | 698 | if (!api || !activeThread || isSending || isConnecting) return; |
| 494 | 699 | const trimmed = prompt.trim(); |
| 495 | | − if (!trimmed) return; |
| 700 | + if (!trimmed && composerImages.length === 0) return; |
| 496 | 701 | if (!activeProject) return; |
| 702 | + const composerImagesSnapshot = [...composerImages]; |
| 497 | 703 | |
| 498 | 704 | // On first message: lock in branch + create worktree if needed. |
| 499 | 705 | let sessionCwd: string | undefined; |
| @@ -529,7 +735,12 @@ export default function ChatView() { · 29 unmodified lines |
| 529 | 735 | |
| 530 | 736 | // Auto-title from first message |
| 531 | 737 | if (activeThread.messages.length === 0) { |
| 532 | | − const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; |
| 738 | + const titleSeed = |
| 739 | + trimmed || |
| 740 | + (composerImagesSnapshot.length > 0 |
| 741 | + ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 742 | + : "New thread"); |
| 743 | + const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed; |
| 533 | 744 | dispatch({ |
| 534 | 745 | type: "SET_THREAD_TITLE", |
| 535 | 746 | threadId: activeThread.id, |
| @@ -537,43 +748,61 @@ export default function ChatView() { · 1 unmodified line |
| 537 | 748 | }); |
| 538 | 749 | } |
| 539 | 750 | |
| 540 | | − dispatch({ |
| 541 | | − type: "SET_ERROR", |
| 542 | | − threadId: activeThread.id, |
| 543 | | − error: null, |
| 544 | | − }); |
| 751 | + setThreadError(activeThread.id, null); |
| 752 | + const messageImages: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({ |
| 753 | + id: image.id, |
| 754 | + name: image.name, |
| 755 | + mimeType: image.mimeType, |
| 756 | + sizeBytes: image.sizeBytes, |
| 757 | + previewUrl: image.previewUrl, |
| 758 | + })); |
| 545 | 759 | dispatch({ |
| 546 | 760 | type: "PUSH_USER_MESSAGE", |
| 547 | 761 | threadId: activeThread.id, |
| 548 | 762 | id: crypto.randomUUID(), |
| 549 | 763 | text: trimmed, |
| 764 | + ...(messageImages.length > 0 ? { images: messageImages } : {}), |
| 550 | 765 | }); |
| 551 | 766 | const previousMessages = activeThread.messages; |
| 552 | 767 | setPrompt(""); |
| 768 | + setComposerImages([]); |
| 553 | 769 | |
| 554 | 770 | const sessionInfo = await ensureSession(sessionCwd); |
| 555 | 771 | if (!sessionInfo) return; |
| 556 | 772 | |
| 557 | 773 | setIsSending(true); |
| 558 | 774 | try { |
| 775 | + const turnImages = await Promise.all( |
| 776 | + composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnImageInput> => ({ |
| 777 | + name: image.name, |
| 778 | + mimeType: image.mimeType, |
| 779 | + sizeBytes: image.sizeBytes, |
| 780 | + dataUrl: await readFileAsDataUrl(image.file), |
| 781 | + })), |
| 782 | + ); |
| 559 | 783 | const shouldBootstrap = |
| 560 | 784 | previousMessages.length > 0 && |
| 561 | 785 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); |
| 786 | + const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; |
| 562 | 787 | const input = shouldBootstrap |
| 563 | | − ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text |
| 564 | | − : trimmed; |
| 788 | + ? buildBootstrapInput( |
| 789 | + previousMessages, |
| 790 | + latestPromptForBootstrap, |
| 791 | + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 792 | + ).text |
| 793 | + : (trimmed || undefined); |
| 565 | 794 | await api.providers.sendTurn({ |
| 566 | 795 | sessionId: sessionInfo.sessionId, |
| 567 | | − input, |
| 796 | + ...(input ? { input } : {}), |
| 797 | + ...(turnImages.length > 0 ? { images: turnImages } : {}), |
| 568 | 798 | model: selectedModel || undefined, |
| 569 | 799 | effort: selectedEffort || undefined, |
| 570 | 800 | }); |
| 571 | 801 | } catch (err) { |
| 572 | | − dispatch({ |
| 573 | | − type: "SET_ERROR", |
| 574 | | − threadId: activeThread.id, |
| 575 | | − error: err instanceof Error ? err.message : "Failed to send message.", |
| 576 | | − }); |
| 802 | + setThreadError( |
| 803 | + activeThread.id, |
| 804 | + err instanceof Error ? err.message : "Failed to send message.", |
| 805 | + ); |
| 577 | 806 | } finally { |
| 578 | 807 | setIsSending(false); |
| 579 | 808 | } |
| @@ -876,13 +1105,39 @@ export default function ChatView() { · 296 unmodified lines |
| 876 | 1105 | } |
| 877 | 1106 | |
| 878 | 1107 | if (timelineEntry.message.role === "user") { |
| 1108 | + const userImages = timelineEntry.message.images ?? []; |
| 879 | 1109 | return ( |
| 880 | 1110 | <Fragment key={timelineEntry.id}> |
| 881 | 1111 | <div className="flex justify-end"> |
| 882 | 1112 | <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> |
| 1113 | + {userImages.length > 0 && ( |
| 1114 | + <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 1115 | + {userImages.map((image) => ( |
| 1116 | + <div |
| 1117 | + key={image.id} |
| 1118 | + className="overflow-hidden rounded-lg border border-border/80 bg-background/70" |
| 1119 | + > |
| 1120 | + {image.previewUrl ? ( |
| 1121 | + <img |
| 1122 | + src={image.previewUrl} |
| 1123 | + alt={image.name} |
| 1124 | + className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" |
| 1125 | + onClick={() => openImageDialog(image.previewUrl, image.name)} |
| 1126 | + /> |
| 1127 | + ) : ( |
| 1128 | + <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> |
| 1129 | + {image.name} |
| 1130 | + </div> |
| 1131 | + )} |
| 1132 | + </div> |
| 1133 | + ))} |
| 1134 | + </div> |
| 1135 | + )} |
| 1136 | + {timelineEntry.message.text && ( |
| 1137 | + <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground"> |
| 1138 | + {timelineEntry.message.text} |
| 1139 | + </pre> |
| 1140 | + )} |
| 886 | 1141 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> |
| 887 | 1142 | {formatTimestamp(timelineEntry.message.createdAt)} |
| 888 | 1143 | </p> |
| @@ -945,9 +1200,48 @@ export default function ChatView() { · 56 unmodified lines |
| 945 | 1200 | {/* Input bar */} |
| 946 | 1201 | <div className="px-5 pb-1 pt-2"> |
| 947 | 1202 | <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"> |
| 1203 | + <div |
| 1204 | + className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ |
| 1205 | + isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border" |
| 1206 | + }`} |
| 1207 | + onDragEnter={onComposerDragEnter} |
| 1208 | + onDragOver={onComposerDragOver} |
| 1209 | + onDragLeave={onComposerDragLeave} |
| 1210 | + onDrop={onComposerDrop} |
| 1211 | + > |
| 949 | 1212 | {/* Textarea area */} |
| 950 | 1213 | <div className="px-4 pt-4 pb-2"> |
| 1214 | + {composerImages.length > 0 && ( |
| 1215 | + <div className="mb-3 flex flex-wrap gap-2"> |
| 1216 | + {composerImages.map((image) => ( |
| 1217 | + <div |
| 1218 | + key={image.id} |
| 1219 | + className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" |
| 1220 | + > |
| 1221 | + {image.previewUrl ? ( |
| 1222 | + <img |
| 1223 | + src={image.previewUrl} |
| 1224 | + alt={image.name} |
| 1225 | + className="h-full w-full cursor-zoom-in object-cover" |
| 1226 | + onClick={() => openImageDialog(image.previewUrl, image.name)} |
| 1227 | + /> |
| 1228 | + ) : ( |
| 1229 | + <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> |
| 1230 | + {image.name} |
| 1231 | + </div> |
| 1232 | + )} |
| 1233 | + <button |
| 1234 | + type="button" |
| 1235 | + 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" |
| 1236 | + onClick={() => removeComposerImage(image.id)} |
| 1237 | + aria-label={`Remove ${image.name}`} |
| 1238 | + > |
| 1239 | + × |
| 1240 | + </button> |
| 1241 | + </div> |
| 1242 | + ))} |
| 1243 | + </div> |
| 1244 | + )} |
| 951 | 1245 | <textarea |
| 952 | 1246 | ref={textareaRef} |
| 953 | 1247 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" |
| @@ -955,8 +1249,11 @@ export default function ChatView() { · 1 unmodified line |
| 955 | 1249 | value={prompt} |
| 956 | 1250 | onChange={(e) => setPrompt(e.target.value)} |
| 957 | 1251 | onKeyDown={onKeyDown} |
| 1252 | + onPaste={onComposerPaste} |
| 958 | 1253 | placeholder={ |
| 959 | | − phase === "disconnected" ? "Ask for follow-up changes" : "Ask anything..." |
| 1254 | + phase === "disconnected" |
| 1255 | + ? "Ask for follow-up changes or attach images" |
| 1256 | + : "Ask anything, or attach images..." |
| 960 | 1257 | } |
| 961 | 1258 | disabled={isSending || isConnecting} |
| 962 | 1259 | /> |
| @@ -1146,7 +1443,7 @@ export default function ChatView() { · 183 unmodified lines |
| 1146 | 1443 | <button |
| 1147 | 1444 | type="submit" |
| 1148 | 1445 | 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()} |
| 1446 | + disabled={isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)} |
| 1150 | 1447 | aria-label={ |
| 1151 | 1448 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |
| 1152 | 1449 | } |
| @@ -1221,6 +1518,39 @@ export default function ChatView() { · 68 unmodified lines |
| 1221 | 1518 | } |
| 1222 | 1519 | /> |
| 1223 | 1520 | )} |
| 1521 | + |
| 1522 | + {expandedImage && ( |
| 1523 | + <div |
| 1524 | + className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" |
| 1525 | + role="dialog" |
| 1526 | + aria-modal="true" |
| 1527 | + aria-label="Expanded image preview" |
| 1528 | + onClick={() => setExpandedImage(null)} |
| 1529 | + > |
| 1530 | + <div |
| 1531 | + className="relative isolate max-h-[92vh] max-w-[92vw]" |
| 1532 | + onClick={(event) => event.stopPropagation()} |
| 1533 | + > |
| 1534 | + <button |
| 1535 | + type="button" |
| 1536 | + 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]" |
| 1537 | + onClick={() => setExpandedImage(null)} |
| 1538 | + aria-label="Close image preview" |
| 1539 | + > |
| 1540 | + <span className="pointer-events-none">×</span> |
| 1541 | + </button> |
| 1542 | + <img |
| 1543 | + src={expandedImage.src} |
| 1544 | + alt={expandedImage.name} |
| 1545 | + className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" |
| 1546 | + draggable={false} |
| 1547 | + /> |
| 1548 | + <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 1549 | + {expandedImage.name} |
| 1550 | + </p> |
| 1551 | + </div> |
| 1552 | + </div> |
| 1553 | + )} |
| 1224 | 1554 | </div> |
| 1225 | 1555 | ); |
| 1226 | 1556 | } |