From 844562749d2321146faf76c8b872de8158c59db2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 11 Feb 2026 19:28:22 +0200 Subject: [PATCH] feat(ui): enable drag-and-drop attachments and image previews in chat (#390) * feat(BottomTerminalDock): add close button next to the fullscreen toggle in the dock * style: replace hardcoded gradient with theme value in shine text variant * fix(header): adapt instance button for desktop only * refactor(chat): polish sticky turn UX and message action rows for better readability Switch to stable sticky-only turn behavior and redesign user/assistant action controls (placement, hover rules, ordering, spacing, selection-safe clamp) to reduce visual noise and improve interaction flow. * feat(chat/message): refactor buttons in messages footer * feat: enhance image preview functionality in chat messages - Added a new ImagePreviewDialog component to handle image previews with navigation support. - Updated ToolOutputDialog to utilize the new ImagePreviewDialog for displaying images. - Modified the ToolPopupContent type to include a gallery of images and an index for the current image. - Removed the old inline image display logic from ToolOutputDialog. - Improved file handling in the file store, including better MIME type guessing and handling of server paths. - Introduced a new API endpoint for handling large session message payloads, allowing for better management of multi-file attachments. - Updated the VSCode bridge to support session message requests with appropriate headers and body handling. * feat(proxy): implement SSE forwarding and enhance generic API request handling * feat(chat): support submitting only queued messages * feat: add image preview transition state * fix: default VSCode view to draft and fixed sessions list regression --- packages/desktop/src-tauri/src/main.rs | 60 +++ packages/desktop/src-tauri/tauri.conf.json | 1 + packages/ui/src/components/chat/ChatInput.tsx | 312 +++++++++++--- .../ui/src/components/chat/ChatMessage.tsx | 2 +- .../ui/src/components/chat/FileAttachment.tsx | 88 +++- .../ui/src/components/chat/MessageList.tsx | 131 +++++- .../components/chat/QueuedMessageChips.tsx | 1 - .../components/chat/message/MessageBody.tsx | 147 +++---- .../chat/message/ToolOutputDialog.tsx | 297 +++++++++++++- .../chat/message/parts/UserTextPart.tsx | 77 +++- .../ui/src/components/chat/message/types.ts | 7 + .../components/layout/BottomTerminalDock.tsx | 31 +- packages/ui/src/components/layout/Header.tsx | 12 +- .../ui/src/components/layout/VSCodeLayout.tsx | 10 +- .../src/components/session/SessionSidebar.tsx | 126 ++++-- packages/ui/src/components/ui/text.tsx | 14 +- packages/ui/src/hooks/useChatScrollManager.ts | 11 +- packages/ui/src/lib/opencode/client.ts | 71 +--- packages/ui/src/stores/fileStore.ts | 113 ++---- packages/ui/src/stores/messageStore.ts | 23 +- packages/ui/src/stores/utils/safeStorage.ts | 52 +-- packages/vscode/src/bridge.ts | 94 ++++- packages/vscode/webview/api/bridge.ts | 9 + packages/vscode/webview/main.tsx | 40 +- packages/web/server/index.js | 381 +++++++++++++++--- 25 files changed, 1621 insertions(+), 489 deletions(-) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index b8259000..9a1782c9 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1843,6 +1843,65 @@ fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), S create_window(&app, &url, &local_origin).map_err(|e| e.to_string()) } +/// Read a file and return its content as base64 with mime type detection. +/// Used for drag-drop file attachments in desktop app. +#[tauri::command] +fn desktop_read_file(path: String) -> Result { + use std::path::Path; + + let path = Path::new(&path); + + // Check file size (max 50MB) + let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {e}"))?; + let size = metadata.len(); + if size > 50 * 1024 * 1024 { + return Err("File is too large. Maximum size is 50MB.".to_string()); + } + + // Read file bytes + let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?; + + // Detect mime type from extension + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let mime = match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "pdf" => "application/pdf", + "txt" => "text/plain", + "md" => "text/markdown", + "json" => "application/json", + "js" => "text/javascript", + "ts" => "text/typescript", + "tsx" => "text/typescript-jsx", + "jsx" => "text/javascript-jsx", + "html" => "text/html", + "css" => "text/css", + "py" => "text/x-python", + _ => "application/octet-stream", + }; + + // Encode as base64 + let base64 = general_purpose::STANDARD.encode(&bytes); + + Ok(FileContent { + mime: mime.to_string(), + base64, + size: bytes.len(), + }) +} + +#[derive(Serialize)] +struct FileContent { + mime: String, + base64: String, + size: usize, +} + #[cfg(target_os = "macos")] fn macos_major_version() -> Option { fn cmd_stdout(cmd: &str, args: &[&str]) -> Option { @@ -2222,6 +2281,7 @@ fn main() { desktop_hosts_get, desktop_hosts_set, desktop_host_probe, + desktop_read_file, ]) .setup(|app| { let handle = app.handle().clone(); diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 22888171..38fc4f9c 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -27,6 +27,7 @@ "x": 17, "y": 26 }, + "dragDropEnabled": false, "visible": false, "backgroundThrottling": "disabled" } diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 51769d19..70540287 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -36,7 +36,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { useFileStore } from '@/stores/fileStore'; import { useMessageStore } from '@/stores/messageStore'; -import { isVSCodeRuntime } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; import type { MobileControlsPanel } from './mobileControlsUtils'; @@ -53,7 +53,7 @@ const EMPTY_QUEUE: QueuedMessage[] = []; interface ChatInputProps { onOpenSettings?: () => void; - scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } const CHAT_INPUT_DRAFT_KEY = 'openchamber_chat_input_draft'; @@ -95,6 +95,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode const textareaRef = React.useRef(null); const dropZoneRef = React.useRef(null); + const canAcceptDropRef = React.useRef(false); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const agentRef = React.useRef(null); @@ -373,7 +374,10 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const canAbort = working.isWorking; // Keep a ref to handleSubmit so callbacks don't depend on it. - const handleSubmitRef = React.useRef<(e?: React.FormEvent) => Promise>(async () => {}); + type SubmitOptions = { + queuedOnly?: boolean; + }; + const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { @@ -403,10 +407,14 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); - const handleSubmit = async (e?: React.FormEvent) => { - e?.preventDefault(); + const handleSubmit = async (options?: SubmitOptions) => { + const queuedOnly = options?.queuedOnly ?? false; - if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return; + if (queuedOnly) { + if (!hasQueuedMessages || !currentSessionId) return; + } else if (!canSend || (!currentSessionId && !newSessionDraftOpen)) { + return; + } // Re-pin and scroll to bottom when sending scrollToBottom?.({ instant: true, force: true }); @@ -448,8 +456,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } - // Add current input - if (hasContent) { + // Add current input (skip for queued-only auto-send) + if (!queuedOnly && hasContent) { const messageToSend = message.replace(/^\n+|\n+$/g, ''); const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const attachmentsToSend = attachedFiles.map((file) => ({ ...file })); @@ -473,7 +481,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); let drafts: InlineCommentDraft[] = []; - if (sessionKey) { + if (!queuedOnly && sessionKey) { drafts = consumeDrafts(sessionKey); } @@ -504,12 +512,14 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (currentSessionId && hasQueuedMessages) { clearQueue(currentSessionId); } - setMessage(''); - // Reset message history navigation state - setHistoryIndex(-1); - setDraftMessage(''); - if (attachedFiles.length > 0) { - clearAttachedFiles(); + if (!queuedOnly) { + setMessage(''); + // Reset message history navigation state + setHistoryIndex(-1); + setDraftMessage(''); + if (attachedFiles.length > 0) { + clearAttachedFiles(); + } } if (isMobile) { @@ -615,6 +625,10 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } if (isSoftNetworkError) { + if (allAttachments.length > 0) { + useFileStore.setState({ attachedFiles: allAttachments }); + toast.error('Failed to send attachments. Try fewer files or smaller images.'); + } return; } @@ -662,7 +676,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Use setTimeout to avoid calling during render setTimeout(() => { if (currentSessionId && currentProviderId && currentModelId) { - void handleSubmitRef.current(); + void handleSubmitRef.current({ queuedOnly: true }); } autoSendTriggeredRef.current = false; }, 100); @@ -1278,7 +1292,59 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [abortPromptSessionId, currentSessionId, clearAbortPrompt]); - const handleDragOver = (e: React.DragEvent) => { + React.useEffect(() => { + canAcceptDropRef.current = Boolean(currentSessionId || newSessionDraftOpen); + }, [currentSessionId, newSessionDraftOpen]); + + const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => { + if (!dataTransfer) return false; + if (dataTransfer.files && dataTransfer.files.length > 0) return true; + if (!dataTransfer.types) return false; + return Array.from(dataTransfer.types).includes('Files'); + }, []); + + const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => { + if (!dataTransfer) return []; + + const directFiles = Array.from(dataTransfer.files || []); + if (directFiles.length > 0) { + return directFiles; + } + + const fromItems = Array.from(dataTransfer.items || []) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + return fromItems; + }, []); + + const normalizeDroppedPath = React.useCallback((rawPath: string): string => { + const input = rawPath.trim(); + if (!input.toLowerCase().startsWith('file://')) { + return input; + } + + try { + let pathname = decodeURIComponent(new URL(input).pathname || ''); + if (/^\/[A-Za-z]:\//.test(pathname)) { + pathname = pathname.slice(1); + } + return pathname || input; + } catch { + const stripped = input.replace(/^file:\/\//i, ''); + try { + return decodeURIComponent(stripped); + } catch { + return stripped; + } + } + }, []); + + const handleDragEnter = (e: React.DragEvent) => { + if (!hasDraggedFiles(e.dataTransfer)) { + return; + } e.preventDefault(); e.stopPropagation(); if ((currentSessionId || newSessionDraftOpen) && !isDragging) { @@ -1286,6 +1352,18 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }; + const handleDragOver = (e: React.DragEvent) => { + if (!hasDraggedFiles(e.dataTransfer)) { + return; + } + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + if ((currentSessionId || newSessionDraftOpen) && !isDragging) { + setIsDragging(true); + } + }; + const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -1295,26 +1373,31 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }; const handleDrop = async (e: React.DragEvent) => { + if (!hasDraggedFiles(e.dataTransfer)) { + return; + } e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!currentSessionId && !newSessionDraftOpen) return; - const files = Array.from(e.dataTransfer.files); + const files = collectDroppedFiles(e.dataTransfer); let attachedCount = 0; - for (const file of files) { - const sizeBefore = useSessionStore.getState().attachedFiles.length; - try { - await addAttachedFile(file); - const sizeAfter = useSessionStore.getState().attachedFiles.length; - if (sizeAfter > sizeBefore) { - attachedCount += 1; + if (files.length > 0) { + for (const file of files) { + const sizeBefore = useSessionStore.getState().attachedFiles.length; + try { + await addAttachedFile(file); + const sizeAfter = useSessionStore.getState().attachedFiles.length; + if (sizeAfter > sizeBefore) { + attachedCount += 1; + } + } catch (error) { + console.error('File attach failed', error); + toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } - } catch (error) { - console.error('File attach failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } } @@ -1323,6 +1406,120 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }; + // Tauri desktop: handle native file drops via onDragDropEvent + React.useEffect(() => { + if (!isTauriShell()) return; + let cancelled = false; + let unlisten: (() => void) | null = null; + + void (async () => { + try { + const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + const webviewWindow = getCurrentWebviewWindow(); + const removeListener = await webviewWindow.onDragDropEvent(async (event) => { + if (!canAcceptDropRef.current) return; + + const payload = (event as { payload?: unknown }).payload; + if (!payload || typeof payload !== 'object') return; + + const typed = payload as { type?: string; paths?: string[]; position?: { x?: number; y?: number } }; + const type = typed.type; + const x = typed.position?.x; + const y = typed.position?.y; + + // Check if drop is inside the chat input area + const zone = dropZoneRef.current; + let inZone = false; + if (zone && typeof x === 'number' && typeof y === 'number') { + const rect = zone.getBoundingClientRect(); + inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + // Handle retina displays where Tauri might report physical pixels + if (!inZone && window.devicePixelRatio > 1) { + const sx = x / window.devicePixelRatio; + const sy = y / window.devicePixelRatio; + inZone = sx >= rect.left && sx <= rect.right && sy >= rect.top && sy <= rect.bottom; + } + } + + if (type === 'enter' || type === 'over') { + setIsDragging(inZone); + return; + } + if (type === 'leave') { + setIsDragging(false); + return; + } + if (type === 'drop') { + setIsDragging(false); + if (!inZone) return; + + const paths = Array.isArray(typed.paths) + ? typed.paths.filter((p): p is string => typeof p === 'string') + : []; + if (paths.length === 0) return; + + let attachedCount = 0; + for (const path of paths) { + const sizeBefore = useSessionStore.getState().attachedFiles.length; + try { + const normalizedPath = normalizeDroppedPath(path); + const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath; + let file: File; + + // In desktop shell on remote origin, local file paths are not readable via /api/fs/raw. + // Read bytes from local machine via Tauri command. + if (isTauriShell() && !isDesktopLocalOriginActive()) { + const { invoke } = await import('@tauri-apps/api/core'); + const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath }); + const byteCharacters = atob(result.base64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' }); + file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' }); + } else { + const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`); + if (!response.ok) { + throw new Error(`Failed to read dropped file (${response.status})`); + } + const blob = await response.blob(); + file = new File([blob], fileName, { type: blob.type || 'application/octet-stream' }); + } + + await addAttachedFile(file); + const sizeAfter = useSessionStore.getState().attachedFiles.length; + if (sizeAfter > sizeBefore) attachedCount++; + } catch (error) { + console.error('Failed to attach dropped file:', path, error); + toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`); + } + } + if (attachedCount > 0) { + toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); + } + } + }); + + if (cancelled) { + removeListener(); + return; + } + unlisten = removeListener; + } catch (error) { + if (!cancelled) { + console.warn('Failed to register Tauri drag-drop listener:', error); + } + } + })(); + + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [addAttachedFile, normalizeDroppedPath]); + const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => { let attachedCount = 0; @@ -1708,35 +1905,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo showAbortStatus={showAbortStatus} /> -
- {isDragging && ( -
-
-
- -
-

Drop files here to attach

-
-
- )} +
{ @@ -1766,13 +1935,37 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo className={cn( "flex flex-col relative overflow-visible", "border border-border/80", - "focus-within:ring-1 focus-within:ring-primary/50" + "focus-within:ring-1 focus-within:ring-primary/50", + isDragging && "ring-2 ring-primary ring-offset-2" )} style={{ borderRadius: cornerRadius, backgroundColor: currentTheme?.colors?.surface?.subtle, }} + ref={dropZoneRef} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} > + {isDragging && ( +
+
+
+ +
+

Drop files here to attach

+
+
+ )} {showCommandAutocomplete && ( = ({ onOpenSettings, scrollToBo onChange={handleTextChange} onKeyDown={handleKeyDown} onPaste={handlePaste} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDrop={handleDrop} onPointerDownCapture={handleTextareaPointerDownCapture} placeholder={currentSessionId || newSessionDraftOpen ? "# for agents; @ for files; / for commands" diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index bc006303..cc65d892 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -888,7 +888,7 @@ const ChatMessage: React.FC = ({ className={cn( 'group w-full', shouldShowHeader ? 'pt-6' : 'pt-0', - isUser ? 'pb-4' : isFollowedByAssistant ? 'pb-0' : 'pb-8' + isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8' )} data-message-id={message.info.id} ref={messageContainerRef} diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 4624be90..1f50d05f 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -141,6 +141,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => { }; const formatFileSize = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) return '...'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; @@ -157,6 +158,32 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => { }; const displayName = extractFilename(file.filename); + const isLocalImagePreview = + file.source !== 'server' && + file.mimeType.startsWith('image/') && + typeof file.dataUrl === 'string' && + file.dataUrl.startsWith('data:image/'); + + if (isLocalImagePreview) { + return ( +
+ {displayName} + +
+ ); + } return (
@@ -195,7 +222,6 @@ export const AttachedFilesList = memo(() => { return (
- Attached: {attachedFiles.map((file) => ( void; + compact?: boolean; } -export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDisplayProps) => { +export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => { const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url)); @@ -251,12 +278,30 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url); const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/')); - const handleImageClick = React.useCallback((file: { filename?: string; mime?: string; size?: number; url?: string }) => { - if (!onShowPopup || !file?.url) { + const imageGallery = React.useMemo( + () => + imageFiles.flatMap((file) => { + if (!file.url) return []; + const filename = extractFilename(file.filename) || 'Image'; + return [{ + url: file.url, + mimeType: file.mime, + filename, + size: file.size, + }]; + }), + [imageFiles] + ); + + const handleImageClick = React.useCallback((index: number) => { + if (!onShowPopup) { return; } - const filename = extractFilename(file.filename) || 'Image'; + const file = imageGallery[index]; + if (!file?.url) return; + + const filename = file.filename || 'Image'; const popupPayload: ToolPopupContent = { open: true, @@ -265,33 +310,39 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis metadata: { tool: 'image-preview', filename, - mime: file.mime, + mime: file.mimeType, size: file.size, }, image: { url: file.url, - mimeType: file.mime, + mimeType: file.mimeType, filename, + size: file.size, + gallery: imageGallery, + index, }, }; onShowPopup(popupPayload); - }, [onShowPopup]); + }, [imageGallery, onShowPopup]); if (fileItems.length === 0) return null; return ( -
+
{} {otherFiles.length > 0 && ( -
+
{otherFiles.map((file, index) => (
{getFileIcon(file.mime)} -
+
{extractFilename(file.filename)} @@ -303,8 +354,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis {} {imageFiles.length > 0 && ( -
-
+
+
{imageFiles.map((file, index) => { const filename = extractFilename(file.filename) || 'Image'; @@ -313,8 +364,13 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis Revert from here @@ -214,14 +222,14 @@ const UserMessageBody: React.FC<{ type="button" variant="ghost" size="icon" - className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50" + className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onFork(); }} > - + Fork from here @@ -235,7 +243,7 @@ const UserMessageBody: React.FC<{ variant="ghost" size="icon" data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined} - className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50" + className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50" aria-label="Copy message text" onPointerDown={(event) => event.stopPropagation()} onClick={handleCopyButtonClick} @@ -247,15 +255,16 @@ const UserMessageBody: React.FC<{ }} > {isMessageCopied ? ( - + ) : ( - + )} Copy message )} +
) : null}
@@ -872,7 +881,6 @@ const AssistantMessageBody: React.FC> = ({ const showErrorMessage = Boolean(errorMessage); const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)); - const [isSummaryHovered, setIsSummaryHovered] = React.useState(false); const turnDurationText = React.useMemo(() => { if (!isLastAssistantInTurn || !hasStopFinish) return undefined; @@ -884,6 +892,44 @@ const AssistantMessageBody: React.FC> = ({ const footerButtons = ( <> + {onCopyMessage && ( + + + + + Copy answer + + )} - {readAloudTooltip} - - )} - {onCopyMessage && ( - - - - - Copy answer - - )} - - ); + {readAloudTooltip} + + )} + + ); return ( @@ -1011,26 +1019,19 @@ const AssistantMessageBody: React.FC> = ({
setIsSummaryHovered(true)} - onMouseLeave={() => setIsSummaryHovered(false)} > {shouldShowFooter && ( -
+
+
+ {footerButtons} +
{turnDurationText ? ( {turnDurationText} - ) : } -
- {footerButtons} -
+ ) : null}
)}
@@ -1039,16 +1040,16 @@ const AssistantMessageBody: React.FC> = ({
{!showSummaryBody && shouldShowFooter && ( -
+
+
+ {footerButtons} +
{turnDurationText ? ( {turnDurationText} - ) : } -
- {footerButtons} -
+ ) : null}
)} diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index e7f7d144..4d4bf782 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { RiBrainAi3Line, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; +import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; @@ -81,9 +82,285 @@ const getToolIcon = (toolName: string) => { return ; }; +const IMAGE_PREVIEW_ANIMATION_MS = 150; + +const ImagePreviewDialog: React.FC<{ + popup: ToolPopupContent; + onOpenChange: (open: boolean) => void; + isMobile: boolean; +}> = ({ popup, onOpenChange, isMobile }) => { + const gallery = React.useMemo(() => { + const baseImage = popup.image; + if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>; + const fromPopup = Array.isArray(baseImage.gallery) + ? baseImage.gallery.filter((item): item is { url: string; mimeType?: string; filename?: string; size?: number } => Boolean(item?.url)) + : []; + + if (fromPopup.length > 0) { + return fromPopup; + } + + return [{ + url: baseImage.url, + mimeType: baseImage.mimeType, + filename: baseImage.filename, + size: baseImage.size, + }]; + }, [popup.image]); + + const [currentIndex, setCurrentIndex] = React.useState(0); + const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null); + const [isRendered, setIsRendered] = React.useState(popup.open); + const [isVisible, setIsVisible] = React.useState(popup.open); + const [isTransitioning, setIsTransitioning] = React.useState(false); + const [viewport, setViewport] = React.useState<{ width: number; height: number }>({ + width: typeof window !== 'undefined' ? window.innerWidth : 0, + height: typeof window !== 'undefined' ? window.innerHeight : 0, + }); + + React.useEffect(() => { + if (!popup.open || gallery.length === 0) { + return; + } + + const requestedIndex = typeof popup.image?.index === 'number' ? popup.image.index : -1; + if (requestedIndex >= 0 && requestedIndex < gallery.length) { + setCurrentIndex(requestedIndex); + return; + } + + const matchingIndex = popup.image?.url + ? gallery.findIndex((item) => item.url === popup.image?.url) + : -1; + setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0); + }, [gallery, popup.image?.index, popup.image?.url, popup.open]); + + const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image; + const imageTitle = currentImage?.filename || popup.title || 'Image preview'; + const hasMultipleImages = gallery.length > 1; + + const showPrevious = React.useCallback(() => { + if (gallery.length <= 1) return; + setCurrentIndex((prev) => (prev - 1 + gallery.length) % gallery.length); + }, [gallery.length]); + + const showNext = React.useCallback(() => { + if (gallery.length <= 1) return; + setCurrentIndex((prev) => (prev + 1) % gallery.length); + }, [gallery.length]); + + React.useEffect(() => { + if (popup.open) { + setIsRendered(true); + setIsTransitioning(true); + if (typeof window === 'undefined') { + setIsVisible(true); + return; + } + + const raf = window.requestAnimationFrame(() => { + setIsVisible(true); + }); + + const doneId = window.setTimeout(() => { + setIsTransitioning(false); + }, IMAGE_PREVIEW_ANIMATION_MS); + + return () => { + window.cancelAnimationFrame(raf); + window.clearTimeout(doneId); + }; + } + + setIsVisible(false); + setIsTransitioning(true); + if (typeof window === 'undefined') { + setIsRendered(false); + return; + } + + const timeoutId = window.setTimeout(() => { + setIsRendered(false); + setIsTransitioning(false); + }, IMAGE_PREVIEW_ANIMATION_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [popup.open]); + + React.useEffect(() => { + if (!popup.open) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onOpenChange(false); + return; + } + + if (event.key === 'ArrowLeft' && hasMultipleImages) { + event.preventDefault(); + showPrevious(); + return; + } + + if (event.key === 'ArrowRight' && hasMultipleImages) { + event.preventDefault(); + showNext(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]); + + React.useEffect(() => { + if (!popup.open || typeof window === 'undefined') { + return; + } + + const onResize = () => { + setViewport({ width: window.innerWidth, height: window.innerHeight }); + }; + + onResize(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, [popup.open]); + + React.useEffect(() => { + setImageNaturalSize(null); + }, [currentImage?.url]); + + const imageDisplaySize = React.useMemo(() => { + const maxWidth = Math.max(160, viewport.width * (isMobile ? 0.86 : 0.75)); + const maxHeight = Math.max(160, viewport.height * (isMobile ? 0.72 : 0.75)); + + if (!imageNaturalSize) { + return { + width: Math.round(maxWidth), + height: Math.round(maxHeight), + }; + } + + const widthScale = maxWidth / imageNaturalSize.width; + const heightScale = maxHeight / imageNaturalSize.height; + const scale = Math.min(widthScale, heightScale); + + return { + width: Math.max(1, Math.round(imageNaturalSize.width * scale)), + height: Math.max(1, Math.round(imageNaturalSize.height * scale)), + }; + }, [imageNaturalSize, isMobile, viewport.height, viewport.width]); + + if (!isRendered || !currentImage || typeof document === 'undefined') { + return null; + } + + const content = ( +
+ + ); + + return createPortal(content, document.body); +}; + const ToolOutputDialog: React.FC = ({ popup, onOpenChange, syntaxTheme, isMobile }) => { const [diffViewMode, setDiffViewMode] = React.useState(isMobile ? 'unified' : 'side-by-side'); + if (popup.image) { + return ; + } + return ( = ({ popup, onOpenChange ))}
) : null - ) : popup.image ? ( -
-
-
- {popup.image.filename -
- {popup.image.filename && ( - - {popup.image.filename} - - )} -
-
) : popup.content ? (
{(() => { diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 690443fd..21fa7a08 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -19,20 +19,45 @@ const buildMentionUrl = (name: string): string => { }; const UserTextPart: React.FC = ({ part, messageId, agentMention }) => { + const CLAMP_LINES = 2; const partWithText = part as PartWithText; const rawText = partWithText.text; const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); + const [collapseZoneHeight, setCollapseZoneHeight] = React.useState(0); const textRef = React.useRef(null); + const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => { + if (typeof window === 'undefined') { + return false; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + return element.contains(range.startContainer) || element.contains(range.endContainer); + }, []); + React.useEffect(() => { const el = textRef.current; - if (!el || isExpanded) return; + if (!el) return; const checkTruncation = () => { - setIsTruncated(el.scrollHeight > el.clientHeight); + if (!isExpanded) { + setIsTruncated(el.scrollHeight > el.clientHeight); + } + + const styles = window.getComputedStyle(el); + const lineHeight = Number.parseFloat(styles.lineHeight); + const fontSize = Number.parseFloat(styles.fontSize); + const fallbackLineHeight = Number.isFinite(fontSize) ? fontSize * 1.4 : 20; + const resolvedLineHeight = Number.isFinite(lineHeight) ? lineHeight : fallbackLineHeight; + setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES))); }; checkTruncation(); @@ -43,11 +68,28 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti return () => resizeObserver.disconnect(); }, [textContent, isExpanded]); - const handleClick = React.useCallback(() => { - if (isTruncated || isExpanded) { - setIsExpanded((prev) => !prev); + const handleClick = React.useCallback((event: React.MouseEvent) => { + const element = textRef.current; + if (!element) { + return; } - }, [isTruncated, isExpanded]); + + if (hasActiveSelectionInElement(element)) { + return; + } + + if (!isExpanded) { + if (isTruncated) { + setIsExpanded(true); + } + return; + } + + const clickY = event.clientY - element.getBoundingClientRect().top; + if (clickY <= collapseZoneHeight) { + setIsExpanded(false); + } + }, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]); if (!textContent || textContent.trim().length === 0) { return null; @@ -79,17 +121,18 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti }; return ( -
- {renderContent()} +
+
+ {renderContent()} +
); }; diff --git a/packages/ui/src/components/chat/message/types.ts b/packages/ui/src/components/chat/message/types.ts index 30909d2e..7488dc00 100644 --- a/packages/ui/src/components/chat/message/types.ts +++ b/packages/ui/src/components/chat/message/types.ts @@ -20,5 +20,12 @@ export interface ToolPopupContent { mimeType?: string; filename?: string; size?: number; + gallery?: Array<{ + url: string; + mimeType?: string; + filename?: string; + size?: number; + }>; + index?: number; }; } diff --git a/packages/ui/src/components/layout/BottomTerminalDock.tsx b/packages/ui/src/components/layout/BottomTerminalDock.tsx index bb89ee1b..e1c13ffa 100644 --- a/packages/ui/src/components/layout/BottomTerminalDock.tsx +++ b/packages/ui/src/components/layout/BottomTerminalDock.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'; +import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; @@ -157,15 +157,26 @@ export const BottomTerminalDock: React.FC = ({ isOpen, )} {isOpen && ( - +
+ + +
)}
{ -

Current instance: {currentInstanceLabel}

+

{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'}

{ const hasAppliedInitialSession = React.useRef(false); - const [currentView, setCurrentView] = React.useState('sessions'); + const bootDraftOpen = React.useMemo(() => { + try { + return Boolean(useSessionStore.getState().newSessionDraft?.open); + } catch { + return false; + } + }, []); + + const [currentView, setCurrentView] = React.useState(() => (bootDraftOpen ? 'chat' : 'sessions')); const [containerWidth, setContainerWidth] = React.useState(0); const containerRef = React.useRef(null); const currentSessionId = useSessionStore((state) => state.currentSessionId); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 584b4b0c..be3aaad6 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -632,6 +632,7 @@ export const SessionSidebar: React.FC = ({ const sessions = useSessionStore((state) => state.sessions); const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); const currentSessionId = useSessionStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle); const shareSession = useSessionStore((state) => state.shareSession); @@ -882,8 +883,11 @@ export const SessionSidebar: React.FC = ({ setSessionSwitcherOpen(false); } - if (!allowReselect && sessionId === currentSessionId) { - onSessionSelected?.(sessionId); + // Always return early if same session is selected to avoid unnecessary store operations + if (sessionId === currentSessionId) { + if (!allowReselect) { + onSessionSelected?.(sessionId); + } return; } setCurrentSession(sessionId); @@ -1458,6 +1462,11 @@ export const SessionSidebar: React.FC = ({ const previousActiveProjectRef = React.useRef(null); React.useLayoutEffect(() => { + // While a new session draft is open, keep the sidebar from auto-selecting remembered/fallback sessions. + // This is especially important in VS Code where the sidebar view is frequently mounted/unmounted. + if (newSessionDraftOpen) { + return; + } if (!activeProjectId || previousActiveProjectRef.current === activeProjectId) { return; } @@ -1467,6 +1476,21 @@ export const SessionSidebar: React.FC = ({ } previousActiveProjectRef.current = activeProjectId; const projectMap = projectSessionMeta.metaByProject.get(activeProjectId); + + // If we already have an active session that belongs to this project (eg user just selected it, + // or sidebar remounted after "back"), do NOT override it with remembered/fallback session. + if (currentSessionId && projectMap && projectMap.has(currentSessionId)) { + setActiveSessionByProject((prev) => { + if (prev.get(activeProjectId) === currentSessionId) { + return prev; + } + const next = new Map(prev); + next.set(activeProjectId, currentSessionId); + return next; + }); + return; + } + if (!projectMap || projectMap.size === 0) { setActiveMainTab('chat'); if (mobileVariant) { @@ -1492,6 +1516,7 @@ export const SessionSidebar: React.FC = ({ activeSessionByProject, currentSessionId, handleSessionSelect, + newSessionDraftOpen, mobileVariant, openNewSessionDraft, projectSections, @@ -1967,7 +1992,7 @@ export const SessionSidebar: React.FC = ({ ); const renderGroupSessions = React.useCallback( - (group: SessionGroup, groupKey: string, projectId?: string | null) => { + (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => { const isExpanded = expandedSessionGroups.has(groupKey); const isCollapsed = collapsedGroups.has(groupKey); const maxVisible = hideDirectoryControls ? 10 : 5; @@ -1997,11 +2022,49 @@ export const SessionSidebar: React.FC = ({ && normalizedGroupDirectory === currentSessionDirectory, ); + // VS Code sessions list uses a separate header (Agent Manager / New Session). + // When the caller requests a flat list (hideGroupLabel), omit the per-group header entirely. + if (hideGroupLabel) { + return ( +
+
+ {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} + {totalSessions === 0 ? ( +
+ No sessions in this workspace yet. +
+ ) : null} + {remainingCount > 0 && !isExpanded ? ( + + ) : null} + {isExpanded && totalSessions > maxVisible ? ( + + ) : null} +
+
+ ); + } + return (
{ + className={cn( + "group/gh flex items-center justify-between gap-2 py-1 min-w-0 rounded-sm", + !hideGroupLabel && "hover:bg-interactive-hover/50 cursor-pointer" + )} + onClick={!hideGroupLabel ? () => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(groupKey)) { @@ -2011,10 +2074,10 @@ export const SessionSidebar: React.FC = ({ } return next; }); - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { + } : undefined} + role={!hideGroupLabel ? "button" : undefined} + tabIndex={!hideGroupLabel ? 0 : undefined} + onKeyDown={!hideGroupLabel ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setCollapsedGroups((prev) => { @@ -2027,29 +2090,31 @@ export const SessionSidebar: React.FC = ({ return next; }); } - }} - aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`} + } : undefined} + aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined} > -
- {isCollapsed ? ( - - ) : ( - - )} - {!group.isMain || isGitProject ? ( - - ) : null} -
-

- {group.label} -

- {showBranchSubtitle ? ( - - {group.branch} - + {!hideGroupLabel ? ( +
+ {isCollapsed ? ( + + ) : ( + + )} + {!group.isMain || isGitProject ? ( + ) : null} +
+

+ {group.label} +

+ {showBranchSubtitle ? ( + + {group.branch} + + ) : null} +
-
+ ) :
} {group.directory ? (
{!group.isMain && group.worktree ? ( @@ -2404,7 +2469,8 @@ export const SessionSidebar: React.FC = ({ ); } const groupKey = `${activeSection.project.id}:${group.id}`; - return renderGroupSessions(group, groupKey, activeSection.project.id); + // In VS Code mode with showOnlyMainWorkspace, hide the group header to show a flat session list + return renderGroupSessions(group, groupKey, activeSection.project.id, showOnlyMainWorkspace); })()}
) : ( diff --git a/packages/ui/src/components/ui/text.tsx b/packages/ui/src/components/ui/text.tsx index ba895a83..9e41ba39 100644 --- a/packages/ui/src/components/ui/text.tsx +++ b/packages/ui/src/components/ui/text.tsx @@ -13,13 +13,13 @@ const variants = [ { variant: "shine", component: ({ children, className, ...props }) => ( - (null); - const currentSessionIdRef = React.useRef(currentSessionId ?? null); const suppressUserScrollUntilRef = React.useRef(0); const lastDirectScrollIntentAtRef = React.useRef(0); const isPinnedRef = React.useRef(true); const lastScrollTopRef = React.useRef(0); - - React.useEffect(() => { - currentSessionIdRef.current = currentSessionId ?? null; - }, [currentSessionId]); - const markProgrammaticScroll = React.useCallback(() => { suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS; }, []); @@ -330,8 +323,8 @@ export const useChatScrollManager = ({ } } }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); - - const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { + + const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { const existing = animationHandlersRef.current.get(messageId); if (existing) { return existing; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 34428f8c..11d04d5c 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -478,56 +478,6 @@ class OpencodeService { return lowerMime === 'image/heic' || lowerMime === 'image/heif'; } - /** - * Compress and resize image using Canvas. - */ - private async compressImage(dataUrl: string, mimeType: string, quality = 0.8, maxWidth = 2048): Promise { - if (typeof document === 'undefined') return dataUrl; - - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - let width = img.width; - let height = img.height; - - if (width > maxWidth || height > maxWidth) { - if (width > height) { - height = Math.round(height * (maxWidth / width)); - width = maxWidth; - } else { - width = Math.round(width * (maxWidth / height)); - height = maxWidth; - } - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - resolve(dataUrl); - return; - } - - ctx.drawImage(img, 0, 0, width, height); - - let targetMime = mimeType; - // Convert large PNGs to JPEG to save space - if (mimeType === 'image/png' && dataUrl.length > 2.5 * 1024 * 1024) { - targetMime = 'image/jpeg'; - } - - try { - resolve(canvas.toDataURL(targetMime, quality)); - } catch { - resolve(dataUrl); - } - }; - img.onerror = () => resolve(dataUrl); - img.src = dataUrl; - }); - } - /** * Convert HEIC image to JPEG. * Returns the original file if conversion fails. @@ -592,23 +542,6 @@ class OpencodeService { return this.convertHeicToJpeg(file); } - // Handle large image compression (Resize > 2048px or > 1MB) - if (file.mime.startsWith('image/') && (file.mime === 'image/jpeg' || file.mime === 'image/png' || file.mime === 'image/webp')) { - // > ~1MB base64 - if (file.url.length > 1.33 * 1024 * 1024) { - const compressedUrl = await this.compressImage(file.url, file.mime); - const newMime = compressedUrl.startsWith('data:image/jpeg') ? 'image/jpeg' : file.mime; - // Update the file object with compressed data - // We return a new object to avoid mutating the original file ref if used elsewhere, - // but here we just return the part. - return { - ...file, - mime: newMime, - url: compressedUrl - }; - } - } - // Handle text MIME normalization if (!this.shouldNormalizeToTextPlain(file.mime)) { return file; @@ -647,6 +580,7 @@ class OpencodeService { agent?: string; variant?: string; files?: Array<{ + id?: string; type: 'file'; mime: string; filename?: string; @@ -657,6 +591,7 @@ class OpencodeService { text: string; synthetic?: boolean; files?: Array<{ + id?: string; type: 'file'; mime: string; filename?: string; @@ -696,6 +631,7 @@ class OpencodeService { for (const file of params.files) { const normalized = await this.normalizeFilePart(file); const filePart: FilePartInput = { + ...(file.id ? { id: file.id } : {}), type: 'file', mime: normalized.mime, filename: normalized.filename, @@ -719,6 +655,7 @@ class OpencodeService { for (const file of additional.files) { const normalized = await this.normalizeFilePart(file); const filePart: FilePartInput = { + ...(file.id ? { id: file.id } : {}), type: 'file', mime: normalized.mime, filename: normalized.filename, diff --git a/packages/ui/src/stores/fileStore.ts b/packages/ui/src/stores/fileStore.ts index 4e0d1139..d863f507 100644 --- a/packages/ui/src/stores/fileStore.ts +++ b/packages/ui/src/stores/fileStore.ts @@ -1,6 +1,5 @@ import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { opencodeClient } from "@/lib/opencode/client"; import type { AttachedFile } from "./types/sessionTypes"; import { getSafeStorage } from "./utils/safeStorage"; @@ -41,7 +40,7 @@ const guessMimeTypeFromName = (filename: string): string => { case "pdf": return "application/pdf"; default: - return "text/plain"; + return "application/octet-stream"; } }; @@ -91,29 +90,30 @@ const guessMimeType = (file: File): string => { } }; -const base64ByteLength = (base64: string): number => { - const cleaned = base64.replace(/\s+/g, ""); - if (!cleaned) { - return 0; +const normalizeServerPath = (inputPath: string): string => inputPath.replace(/\\/g, "/").trim(); + +const toFileUrl = (inputPath: string): string => { + const normalized = normalizeServerPath(inputPath); + if (normalized.startsWith("file://")) { + return normalized; } - const padding = cleaned.endsWith("==") ? 2 : cleaned.endsWith("=") ? 1 : 0; - return Math.floor((cleaned.length * 3) / 4) - padding; + + const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`; + return `file://${encodeURI(withLeadingSlash)}`; }; -const base64EncodeBytes = (bytes: Uint8Array): string => { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let output = ""; - for (let i = 0; i < bytes.length; i += 3) { - const a = bytes[i] ?? 0; - const b = bytes[i + 1]; - const c = bytes[i + 2]; - const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0); - output += alphabet[(triple >> 18) & 63]; - output += alphabet[(triple >> 12) & 63]; - output += typeof b === "number" ? alphabet[(triple >> 6) & 63] : "="; - output += typeof c === "number" ? alphabet[triple & 63] : "="; +const readRawFileAsDataUrl = async (absolutePath: string): Promise => { + const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(absolutePath)}`); + if (!response.ok) { + throw new Error(`Failed to read raw file: ${response.status}`); } - return output; + const blob = await response.blob(); + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); }; export const useFileStore = create()( @@ -196,66 +196,33 @@ export const useFileStore = create()( addServerFile: async (path: string, name: string, content?: string) => { + const normalizedPath = normalizeServerPath(path); const { attachedFiles } = get(); - const isDuplicate = attachedFiles.some((f) => f.serverPath === path && f.source === "server"); + const isDuplicate = attachedFiles.some((f) => normalizeServerPath(f.serverPath || "") === normalizedPath && f.source === "server"); if (isDuplicate) { console.log(`Server file "${name}" is already attached`); return; } - let fileContent = content; - let encoding: "base64" | undefined; - let resolvedMimeType: string | undefined; - if (!fileContent) { - try { - - const tempClient = opencodeClient.getApiClient(); - - const lastSlashIndex = path.lastIndexOf("/"); - const directory = lastSlashIndex > 0 ? path.substring(0, lastSlashIndex) : "/"; - const filename = lastSlashIndex > 0 ? path.substring(lastSlashIndex + 1) : path; - - const response = await tempClient.file.read({ - path: filename, - directory: directory, - }); - - if (response.data && "content" in response.data) { - fileContent = response.data.content; - encoding = response.data.encoding ?? undefined; - resolvedMimeType = response.data.mimeType ?? undefined; - } else { - fileContent = ""; - } - } catch (error) { - console.error("Failed to read server file:", error); - - fileContent = `[File: ${name}]`; - } - } - - const inferredMime = resolvedMimeType || guessMimeTypeFromName(name); + const inferredMime = guessMimeTypeFromName(name); const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream"; - const base64 = (() => { - if (encoding === "base64") { - return fileContent || ""; + const shouldInlineBinary = safeMimeType !== "text/plain" && safeMimeType !== "application/x-directory"; + + let dataUrl = toFileUrl(normalizedPath); + if (shouldInlineBinary) { + try { + dataUrl = await readRawFileAsDataUrl(normalizedPath); + } catch (error) { + console.warn("Failed to inline binary server file, falling back to file://", error); } - const encoder = new TextEncoder(); - const data = encoder.encode(fileContent || ""); - return base64EncodeBytes(data); - })(); - - const sizeBytes = encoding === "base64" - ? base64ByteLength(base64) - : new TextEncoder().encode(fileContent || "").length; - - if (sizeBytes > MAX_ATTACHMENT_SIZE) { - throw new Error(`File "${name}" is too large. Maximum size is 50MB.`); } + const sizeBytes = typeof content === "string" + ? new TextEncoder().encode(content).length + : 0; + const file = new File([], name, { type: safeMimeType }); - const dataUrl = `data:${safeMimeType};base64,${base64}`; const attachedFile: AttachedFile = { id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, @@ -265,7 +232,7 @@ export const useFileStore = create()( filename: name, size: sizeBytes, source: "server", - serverPath: path, + serverPath: normalizedPath, }; set((state) => ({ @@ -286,6 +253,12 @@ export const useFileStore = create()( { name: "file-store", storage: createJSONStorage(() => getSafeStorage()), + version: 3, + migrate: (persistedState) => { + const state = persistedState as { attachedFiles?: AttachedFile[] } | undefined; + return { attachedFiles: Array.isArray(state?.attachedFiles) ? state.attachedFiles : [] }; + }, + // Keep unsent draft attachments across restarts. partialize: (state) => ({ attachedFiles: state.attachedFiles, }), diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 38badd2a..bf1a7473 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -102,6 +102,15 @@ const computePartsTextLength = (parts: Part[] | undefined): number => { }, 0); }; +const toFileUrl = (inputPath: string): string => { + const normalized = inputPath.replace(/\\/g, "/").trim(); + if (normalized.startsWith("file://")) { + return normalized; + } + const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`; + return `file://${encodeURI(withLeadingSlash)}`; +}; + const hasFinishStop = (info: { finish?: string } | undefined): boolean => { return info?.finish === "stop"; }; @@ -663,7 +672,12 @@ export const useMessageStore = create()( type: "file" as const, mime: file.mimeType, filename: file.filename, - url: file.dataUrl, + url: + file.source === "server" && + file.serverPath && + (file.mimeType === "text/plain" || file.mimeType === "application/x-directory") + ? toFileUrl(file.serverPath) + : file.dataUrl, })); set((state) => { @@ -687,7 +701,12 @@ export const useMessageStore = create()( type: "file" as const, mime: file.mimeType, filename: file.filename, - url: file.dataUrl, + url: + file.source === "server" && + file.serverPath && + (file.mimeType === "text/plain" || file.mimeType === "application/x-directory") + ? toFileUrl(file.serverPath) + : file.dataUrl, })), })); diff --git a/packages/ui/src/stores/utils/safeStorage.ts b/packages/ui/src/stores/utils/safeStorage.ts index 8006c1b2..d7dd962c 100644 --- a/packages/ui/src/stores/utils/safeStorage.ts +++ b/packages/ui/src/stores/utils/safeStorage.ts @@ -56,29 +56,31 @@ const createSafeStorage = (): Storage => { return; } catch { disableStorage(); + // Prevent stale previous value from surviving when writes fail (e.g. quota). + try { + baseStorage.removeItem(key); + } catch { + // noop + } } } fallback.setItem(key, value); }; const safeRemove = (key: string) => { - if (storageAvailable) { - try { - baseStorage.removeItem(key); - } catch { - disableStorage(); - } + try { + baseStorage.removeItem(key); + } catch { + disableStorage(); } fallback.removeItem(key); }; const safeClear = () => { - if (storageAvailable) { - try { - baseStorage.clear(); - } catch { - disableStorage(); - } + try { + baseStorage.clear(); + } catch { + disableStorage(); } fallback.clear(); }; @@ -155,29 +157,31 @@ const createSafeSessionStorage = (): Storage => { return; } catch { disableStorage(); + // Prevent stale previous value from surviving when writes fail (e.g. quota). + try { + baseStorage.removeItem(key); + } catch { + // noop + } } } fallback.setItem(key, value); }; const safeRemove = (key: string) => { - if (storageAvailable) { - try { - baseStorage.removeItem(key); - } catch { - disableStorage(); - } + try { + baseStorage.removeItem(key); + } catch { + disableStorage(); } fallback.removeItem(key); }; const safeClear = () => { - if (storageAvailable) { - try { - baseStorage.clear(); - } catch { - disableStorage(); - } + try { + baseStorage.clear(); + } catch { + disableStorage(); } fallback.clear(); }; diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index e94d3057..9e7a7edb 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -67,6 +67,12 @@ type ApiProxyRequestPayload = { bodyBase64?: string; }; +type ApiSessionMessageRequestPayload = { + path?: string; + headers?: Record; + bodyText?: string; +}; + type ApiProxyResponsePayload = { status: number; headers: Record; @@ -760,6 +766,23 @@ const collectHeaders = (headers: Headers): Record => { return result; }; +const buildUnavailableApiResponse = (): ApiProxyResponsePayload => { + const body = JSON.stringify({ error: 'OpenCode API unavailable' }); + return { + status: 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; +}; + +const sanitizeForwardHeaders = (input: Record | undefined): Record => { + const headers: Record = { ...(input || {}) }; + delete headers['content-length']; + delete headers['host']; + delete headers['connection']; + return headers; +}; + export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise { const { id, type, payload } = message; @@ -768,12 +791,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo case 'api:proxy': { const apiUrl = ctx?.manager?.getApiUrl(); if (!apiUrl) { - const body = JSON.stringify({ error: 'OpenCode API unavailable' }); - const data: ApiProxyResponsePayload = { - status: 503, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; + const data = buildUnavailableApiResponse(); return { id, type, success: true, data }; } @@ -788,7 +806,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const base = `${apiUrl.replace(/\/+$/, '')}/`; const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = { ...(headers || {}) }; + const requestHeaders: Record = sanitizeForwardHeaders(headers); // Ensure SSE requests are negotiated correctly. if (normalizedPath === '/event' || normalizedPath === '/global/event') { @@ -830,6 +848,68 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } + case 'api:session:message': { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + const data = buildUnavailableApiResponse(); + return { id, type, success: true, data }; + } + + const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload; + const normalizedPath = + typeof requestPath === 'string' && requestPath.trim().length > 0 + ? requestPath.trim().startsWith('/') + ? requestPath.trim() + : `/${requestPath.trim()}` + : '/'; + + if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) { + const body = JSON.stringify({ error: 'Invalid session message proxy path' }); + const data: ApiProxyResponsePayload = { + status: 400, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + const requestHeaders: Record = sanitizeForwardHeaders(headers); + + try { + const response = await fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: typeof bodyText === 'string' ? bodyText : '', + signal: AbortSignal.timeout(45000), + }); + + const arrayBuffer = await response.arrayBuffer(); + const data: ApiProxyResponsePayload = { + status: response.status, + headers: collectHeaders(response.headers), + bodyBase64: Buffer.from(arrayBuffer).toString('base64'), + }; + + return { id, type, success: true, data }; + } catch (error) { + const isTimeout = + error instanceof Error && + ((error as Error & { name?: string }).name === 'TimeoutError' || + (error as Error & { name?: string }).name === 'AbortError'); + const body = JSON.stringify({ + error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed', + }); + const data: ApiProxyResponsePayload = { + status: isTimeout ? 504 : 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + } + case 'files:list': { const { path: dirPath } = payload as { path: string }; const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index bcf49137..b4f39136 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -107,6 +107,15 @@ export async function proxyApiRequest(options: { return sendBridgeMessageWithOptions('api:proxy', options, { timeoutMs: 0 }); } +export async function proxySessionMessageRequest(options: { + path: string; + headers?: Record; + bodyText: string; +}): Promise { + // Keep parity with server-side direct forwarder: let extension host control timeout. + return sendBridgeMessageWithOptions('api:session:message', options, { timeoutMs: 0 }); +} + export type ProxiedSseStartResponse = { status: number; headers: Record; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 1812ba09..16ff0ad2 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1,5 +1,5 @@ import { createVSCodeAPIs } from './api'; -import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; +import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { buildVSCodeThemeFromPalette, @@ -343,7 +343,35 @@ const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | u return undefined; }; +const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => { + if (method === 'GET' || method === 'HEAD') return ''; + + if (input instanceof Request) { + const cloned = input.clone(); + return await cloned.text(); + } + + const body = init?.body; + if (!body) return ''; + + if (typeof body === 'string') { + return body; + } + + if (body instanceof URLSearchParams) { + return body.toString(); + } + + if (body instanceof Blob) { + return await body.text(); + } + + console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body); + return ''; +}; + const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event'; +const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname); const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const pathname = url.pathname; @@ -761,6 +789,16 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } }); } + if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) { + const bodyText = await extractBodyText(input, init, method); + const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText }); + const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); + const response = new Response(body, { status: proxied.status, headers: proxied.headers }); + recordBootstrapFetch(targetUrl.pathname, response.ok); + maybeHideLoadingOverlay(); + return response; + } + const bodyBase64 = await extractBodyBase64(input, init, method); const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 }); const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 78ba6004..2e6a2adb 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1,5 +1,4 @@ import express from 'express'; -import { createProxyMiddleware } from 'http-proxy-middleware'; import path from 'path'; import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; @@ -4630,6 +4629,157 @@ function setupProxy(app) { next(); }); + const isSseApiPath = (path) => path === '/event' || path === '/global/event'; + + const forwardSseRequest = async (req, res) => { + const startedAt = Date.now(); + const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const targetUrl = buildOpenCodeUrl(upstreamPath, ''); + const authHeaders = getOpenCodeAuthHeaders(); + + const requestHeaders = { + ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }), + 'cache-control': 'no-cache', + connection: 'keep-alive', + ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), + }; + + const controller = new AbortController(); + let connectTimer = null; + let idleTimer = null; + let heartbeatTimer = null; + let endedBy = 'upstream-end'; + + const cleanup = () => { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + req.off('close', onClientClose); + }; + + const resetIdleTimeout = () => { + if (idleTimer) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + endedBy = 'idle-timeout'; + controller.abort(); + }, 5 * 60 * 1000); + }; + + const onClientClose = () => { + endedBy = 'client-disconnect'; + controller.abort(); + }; + + req.on('close', onClientClose); + + try { + connectTimer = setTimeout(() => { + endedBy = 'connect-timeout'; + controller.abort(); + }, 10 * 1000); + + const upstreamResponse = await fetch(targetUrl, { + method: 'GET', + headers: requestHeaders, + signal: controller.signal, + }); + + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + + if (!upstreamResponse.ok || !upstreamResponse.body) { + const body = await upstreamResponse.text().catch(() => ''); + cleanup(); + if (!res.headersSent) { + if (upstreamResponse.headers.has('content-type')) { + res.setHeader('content-type', upstreamResponse.headers.get('content-type')); + } + res.status(upstreamResponse.status).send(body); + } + return; + } + + const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream'; + res.status(upstreamResponse.status); + res.setHeader('content-type', upstreamContentType); + res.setHeader('cache-control', 'no-cache'); + res.setHeader('connection', 'keep-alive'); + res.setHeader('x-accel-buffering', 'no'); + res.setHeader('x-content-type-options', 'nosniff'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + resetIdleTimeout(); + heartbeatTimer = setInterval(() => { + if (res.writableEnded || controller.signal.aborted) { + return; + } + try { + res.write(': ping\n\n'); + resetIdleTimeout(); + } catch { + } + }, 30 * 1000); + + const reader = upstreamResponse.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + endedBy = endedBy === 'upstream-end' ? 'upstream-finished' : endedBy; + break; + } + if (controller.signal.aborted) { + break; + } + if (value && value.length > 0) { + res.write(Buffer.from(value)); + resetIdleTimeout(); + } + } + } finally { + try { + reader.releaseLock(); + } catch { + } + } + + cleanup(); + if (!res.writableEnded) { + res.end(); + } + console.log(`SSE forward ${upstreamPath} closed (${endedBy}) in ${Date.now() - startedAt}ms`); + } catch (error) { + cleanup(); + const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; + if (!res.headersSent) { + res.status(isTimeout ? 504 : 503).json({ + error: isTimeout ? 'OpenCode SSE forward timed out' : 'OpenCode SSE forward failed', + }); + } else if (!res.writableEnded) { + res.end(); + } + console.warn(`SSE forward ${upstreamPath} failed (${endedBy}):`, error?.message || error); + } + }; + + app.get('/api/event', forwardSseRequest); + app.get('/api/global/event', forwardSseRequest); + app.use('/api', (_req, _res, next) => { ensureOpenCodeApiPrefix(); next(); @@ -4651,66 +4801,162 @@ function setupProxy(app) { }); - const proxyMiddleware = createProxyMiddleware({ - target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0', - router: () => { - if (!openCodePort) { - return 'http://127.0.0.1:0'; - } - return `http://localhost:${openCodePort}`; - }, - changeOrigin: true, - pathRewrite: (path) => { - if (!path.startsWith('/api')) { - return path; + const hopByHopRequestHeaders = new Set([ + 'host', + 'connection', + 'content-length', + 'transfer-encoding', + 'keep-alive', + 'te', + 'trailer', + 'upgrade', + ]); + + const hopByHopResponseHeaders = new Set([ + 'connection', + 'content-length', + 'transfer-encoding', + 'keep-alive', + 'te', + 'trailer', + 'upgrade', + 'www-authenticate', + ]); + + const collectForwardHeaders = (req) => { + const authHeaders = getOpenCodeAuthHeaders(); + const headers = {}; + + for (const [key, value] of Object.entries(req.headers || {})) { + if (!value) continue; + const lowerKey = key.toLowerCase(); + if (hopByHopRequestHeaders.has(lowerKey)) continue; + headers[lowerKey] = Array.isArray(value) ? value.join(', ') : String(value); + } + + if (authHeaders.Authorization) { + headers.Authorization = authHeaders.Authorization; + } + + return headers; + }; + + const collectRequestBodyBuffer = async (req) => { + if (Buffer.isBuffer(req.body)) { + return req.body; + } + + if (typeof req.body === 'string') { + return Buffer.from(req.body); + } + + if (req.body && typeof req.body === 'object') { + return Buffer.from(JSON.stringify(req.body)); + } + + if (req.readableEnded) { + return Buffer.alloc(0); + } + + return await new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); + }; + + const forwardGenericApiRequest = async (req, res) => { + try { + const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const targetUrl = buildOpenCodeUrl(upstreamPath, ''); + const headers = collectForwardHeaders(req); + const method = String(req.method || 'GET').toUpperCase(); + const hasBody = method !== 'GET' && method !== 'HEAD'; + const bodyBuffer = hasBody ? await collectRequestBodyBuffer(req) : null; + + const upstreamResponse = await fetch(targetUrl, { + method, + headers, + body: hasBody ? bodyBuffer : undefined, + signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS), + }); + + for (const [key, value] of upstreamResponse.headers.entries()) { + const lowerKey = key.toLowerCase(); + if (hopByHopResponseHeaders.has(lowerKey)) { + continue; + } + res.setHeader(key, value); } - const suffix = path.slice(4) || '/'; + const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer()); + res.status(upstreamResponse.status).send(upstreamBody); + } catch (error) { + if (!res.headersSent) { + const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; + res.status(isTimeout ? 504 : 503).json({ + error: isTimeout ? 'OpenCode request timed out' : 'OpenCode service unavailable', + }); + } + } + }; - return suffix; - }, - ws: false, - // v3.x API: callbacks go in 'on' object - on: { - error: (err, req, res) => { - console.error(`Proxy error: ${err.message}`); - if (!res.headersSent) { - res.status(503).json({ error: 'OpenCode service unavailable' }); - } - }, - proxyReq: (proxyReq, req, res) => { - console.log(`Proxying ${req.method} ${req.path} to OpenCode`); - const authHeaders = getOpenCodeAuthHeaders(); - if (authHeaders.Authorization) { - proxyReq.setHeader('Authorization', authHeaders.Authorization); - } + // Dedicated forwarder for large session message payloads. + // This avoids edge-cases in generic proxy streaming for multi-file attachments. + app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { + try { + const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const targetUrl = buildOpenCodeUrl(upstreamPath, ''); + const authHeaders = getOpenCodeAuthHeaders(); - if (req.headers.accept && req.headers.accept.includes('text/event-stream')) { - proxyReq.setHeader('Accept', 'text/event-stream'); - proxyReq.setHeader('Cache-Control', 'no-cache'); - proxyReq.setHeader('Connection', 'keep-alive'); - } - }, - proxyRes: (proxyRes, req, res) => { - // Strip WWW-Authenticate to prevent browser's native Basic Auth popup - if (proxyRes.headers['www-authenticate']) { - delete proxyRes.headers['www-authenticate']; - } + const headers = { + ...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }), + ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}), + ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), + }; - if (req.url?.includes('/event')) { - proxyRes.headers['Access-Control-Allow-Origin'] = '*'; - proxyRes.headers['Access-Control-Allow-Headers'] = 'Cache-Control, Accept'; - proxyRes.headers['Content-Type'] = 'text/event-stream'; - proxyRes.headers['Cache-Control'] = 'no-cache'; - proxyRes.headers['Connection'] = 'keep-alive'; - proxyRes.headers['X-Accel-Buffering'] = 'no'; - proxyRes.headers['X-Content-Type-Options'] = 'nosniff'; - } + const bodyBuffer = Buffer.isBuffer(req.body) + ? req.body + : Buffer.from(typeof req.body === 'string' ? req.body : ''); + + const upstreamResponse = await fetch(targetUrl, { + method: 'POST', + headers, + body: bodyBuffer, + signal: AbortSignal.timeout(45000), + }); + + const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer()); + + if (upstreamResponse.headers.has('content-type')) { + res.setHeader('content-type', upstreamResponse.headers.get('content-type')); + } + + res.status(upstreamResponse.status).send(upstreamBody); + } catch (error) { + if (!res.headersSent) { + const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; + res.status(isTimeout ? 504 : 503).json({ + error: isTimeout ? 'OpenCode message forward timed out' : 'OpenCode message forward failed', + }); } } }); - app.use('/api', proxyMiddleware); + app.use('/api', (req, res, next) => { + if (isSseApiPath(req.path)) { + return next(); + } + + if (req.method === 'POST' && /\/session\/[^/]+\/message$/.test(req.path || '')) { + return next(); + } + + return forwardGenericApiRequest(req, res); + }); } function startHealthMonitoring() { @@ -8478,7 +8724,36 @@ async function main(options = {}) { }) .join('\n\n'); - const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`; + const prompt = `You are generating a Conventional Commits subject line from the provided diff. + +Return EXACTLY one JSON object (no code fences, no extra keys, no extra text): +{"subject": string, "highlights": string[]} + +Non-negotiable: +- Output must be valid JSON (double quotes). +- Only claim what is supported by the diff. If unsure, be more general; do not guess. + +subject: +- Format: : (NO scope; never write type(scope)) +- Allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert +- Choose type (prefer fix when ambiguous): + - fix: any bug/regression/wrong behavior (state, selection, navigation, persistence, crash) + - feat: new user-facing capability or new workflow (not just guardrails/defaults) + - refactor/perf/docs/test/build/ci/style/chore/revert: only when clearly the primary change +- Summary style: + - imperative, present tense, outcome-first + - <= 72 characters, no trailing period + - avoid filenames, internal function names, and implementation details + +highlights: +- 0-3 items; it is OK to return []. +- Each item: one plain sentence, <= 90 chars, starts with an Uppercase verb. +- Must add information not already in the subject. +- Prefer user-observable behaviors (UI flow, navigation, selection, default view, persistence). +- No markdown bullets, no file paths, no helper names. + +Diff summary (may be truncated): +${diffSummaries}`; const model = 'gpt-5-nano';