| ... | @@ -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_IMAGE_BYTES, |
| | 4 | PROVIDER_SEND_TURN_MAX_IMAGES, |
| | 5 | type ProviderSendTurnImageInput, |
| 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,124 @@ export default function ChatView() { | ... | @@ -431,6 +518,124 @@ 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_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 | const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | 639 | const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { |
| 435 | if (!api || !activeThread || !activeProject) return null; | 640 | if (!api || !activeThread || !activeProject) return null; |
| 436 | if (activeThread.session && activeThread.session.status !== "closed") { | 641 | if (activeThread.session && activeThread.session.status !== "closed") { |
| ... | @@ -492,8 +697,9 @@ export default function ChatView() { | ... | @@ -492,8 +697,9 @@ export default function ChatView() { |
| 492 | e.preventDefault(); | 697 | e.preventDefault(); |
| 493 | if (!api || !activeThread || isSending || isConnecting) return; | 698 | if (!api || !activeThread || isSending || isConnecting) return; |
| 494 | const trimmed = prompt.trim(); | 699 | const trimmed = prompt.trim(); |
| 495 | if (!trimmed) return; | 700 | if (!trimmed && composerImages.length === 0) return; |
| 496 | if (!activeProject) return; | 701 | if (!activeProject) return; |
| | 702 | const composerImagesSnapshot = [...composerImages]; |
| 497 | | 703 | |
| 498 | // On first message: lock in branch + create worktree if needed. | 704 | // On first message: lock in branch + create worktree if needed. |
| 499 | let sessionCwd: string | undefined; | 705 | let sessionCwd: string | undefined; |
| ... | @@ -529,7 +735,12 @@ export default function ChatView() { | ... | @@ -529,7 +735,12 @@ export default function ChatView() { |
| 529 | | 735 | |
| 530 | // Auto-title from first message | 736 | // Auto-title from first message |
| 531 | if (activeThread.messages.length === 0) { | 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 | dispatch({ | 744 | dispatch({ |
| 534 | type: "SET_THREAD_TITLE", | 745 | type: "SET_THREAD_TITLE", |
| 535 | threadId: activeThread.id, | 746 | threadId: activeThread.id, |
| ... | @@ -537,43 +748,61 @@ export default function ChatView() { | ... | @@ -537,43 +748,61 @@ export default function ChatView() { |
| 537 | }); | 748 | }); |
| 538 | } | 749 | } |
| 539 | | 750 | |
| 540 | dispatch({ | 751 | setThreadError(activeThread.id, null); |
| 541 | type: "SET_ERROR", | 752 | const messageImages: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({ |
| 542 | threadId: activeThread.id, | 753 | id: image.id, |
| 543 | error: null, | 754 | name: image.name, |
| 544 | }); | 755 | mimeType: image.mimeType, |
| | 756 | sizeBytes: image.sizeBytes, |
| | 757 | previewUrl: image.previewUrl, |
| | 758 | })); |
| 545 | dispatch({ | 759 | dispatch({ |
| 546 | type: "PUSH_USER_MESSAGE", | 760 | type: "PUSH_USER_MESSAGE", |
| 547 | threadId: activeThread.id, | 761 | threadId: activeThread.id, |
| 548 | id: crypto.randomUUID(), | 762 | id: crypto.randomUUID(), |
| 549 | text: trimmed, | 763 | text: trimmed, |
| | 764 | ...(messageImages.length > 0 ? { images: messageImages } : {}), |
| 550 | }); | 765 | }); |
| 551 | const previousMessages = activeThread.messages; | 766 | const previousMessages = activeThread.messages; |
| 552 | setPrompt(""); | 767 | setPrompt(""); |
| | 768 | setComposerImages([]); |
| 553 | | 769 | |
| 554 | const sessionInfo = await ensureSession(sessionCwd); | 770 | const sessionInfo = await ensureSession(sessionCwd); |
| 555 | if (!sessionInfo) return; | 771 | if (!sessionInfo) return; |
| 556 | | 772 | |
| 557 | setIsSending(true); | 773 | setIsSending(true); |
| 558 | try { | 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 | const shouldBootstrap = | 783 | const shouldBootstrap = |
| 560 | previousMessages.length > 0 && | 784 | previousMessages.length > 0 && |
| 561 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); | 785 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); |
| | 786 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; |
| 562 | const input = shouldBootstrap | 787 | const input = shouldBootstrap |
| 563 | ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text | 788 | ? buildBootstrapInput( |
| 564 | : trimmed; | 789 | previousMessages, |
| | 790 | latestPromptForBootstrap, |
| | 791 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| | 792 | ).text |
| | 793 | : (trimmed || undefined); |
| 565 | await api.providers.sendTurn({ | 794 | await api.providers.sendTurn({ |
| 566 | sessionId: sessionInfo.sessionId, | 795 | sessionId: sessionInfo.sessionId, |
| 567 | input, | 796 | ...(input ? { input } : {}), |
| | 797 | ...(turnImages.length > 0 ? { images: turnImages } : {}), |
| 568 | model: selectedModel || undefined, | 798 | model: selectedModel || undefined, |
| 569 | effort: selectedEffort || undefined, | 799 | effort: selectedEffort || undefined, |
| 570 | }); | 800 | }); |
| 571 | } catch (err) { | 801 | } catch (err) { |
| 572 | dispatch({ | 802 | setThreadError( |
| 573 | type: "SET_ERROR", | 803 | activeThread.id, |
| 574 | threadId: activeThread.id, | 804 | err instanceof Error ? err.message : "Failed to send message.", |
| 575 | error: err instanceof Error ? err.message : "Failed to send message.", | 805 | ); |
| 576 | }); | | |
| 577 | } finally { | 806 | } finally { |
| 578 | setIsSending(false); | 807 | setIsSending(false); |
| 579 | } | 808 | } |
| ... | @@ -876,13 +1105,39 @@ export default function ChatView() { | ... | @@ -876,13 +1105,39 @@ export default function ChatView() { |
| 876 | } | 1105 | } |
| 877 | | 1106 | |
| 878 | if (timelineEntry.message.role === "user") { | 1107 | if (timelineEntry.message.role === "user") { |
| | 1108 | const userImages = timelineEntry.message.images ?? []; |
| 879 | return ( | 1109 | return ( |
| 880 | <Fragment key={timelineEntry.id}> | 1110 | <Fragment key={timelineEntry.id}> |
| 881 | <div className="flex justify-end"> | 1111 | <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"> | 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"> | 1113 | {userImages.length > 0 && ( |
| 884 | {timelineEntry.message.text} | 1114 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 885 | </pre> | 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 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | 1141 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> |
| 887 | {formatTimestamp(timelineEntry.message.createdAt)} | 1142 | {formatTimestamp(timelineEntry.message.createdAt)} |
| 888 | </p> | 1143 | </p> |
| ... | @@ -945,9 +1200,48 @@ export default function ChatView() { | ... | @@ -945,9 +1200,48 @@ export default function ChatView() { |
| 945 | {/* Input bar */} | 1200 | {/* Input bar */} |
| 946 | <div className="px-5 pb-1 pt-2"> | 1201 | <div className="px-5 pb-1 pt-2"> |
| 947 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | 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 | {/* Textarea area */} | 1212 | {/* Textarea area */} |
| 950 | <div className="px-4 pt-4 pb-2"> | 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 | <textarea | 1245 | <textarea |
| 952 | ref={textareaRef} | 1246 | ref={textareaRef} |
| 953 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" | 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,8 +1249,11 @@ export default function ChatView() { |
| 955 | value={prompt} | 1249 | value={prompt} |
| 956 | onChange={(e) => setPrompt(e.target.value)} | 1250 | onChange={(e) => setPrompt(e.target.value)} |
| 957 | onKeyDown={onKeyDown} | 1251 | onKeyDown={onKeyDown} |
| | 1252 | onPaste={onComposerPaste} |
| 958 | placeholder={ | 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 | disabled={isSending || isConnecting} | 1258 | disabled={isSending || isConnecting} |
| 962 | /> | 1259 | /> |
| ... | @@ -1146,7 +1443,7 @@ export default function ChatView() { | ... | @@ -1146,7 +1443,7 @@ export default function ChatView() { |
| 1146 | <button | 1443 | <button |
| 1147 | type="submit" | 1444 | 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" | 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 | aria-label={ | 1447 | aria-label={ |
| 1151 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" | 1448 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |
| 1152 | } | 1449 | } |
| ... | @@ -1221,6 +1518,39 @@ export default function ChatView() { | ... | @@ -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 | </div> | 1554 | </div> |
| 1225 | ); | 1555 | ); |
| 1226 | } | 1556 | } |