| ... | ... | @@ -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 { |
| 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,124 @@ 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_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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | } |