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
This commit is contained in:
Bohdan Triapitsyn
2026-02-11 19:28:22 +02:00
committed by GitHub
parent 39e625d8ec
commit 844562749d
25 changed files with 1621 additions and 489 deletions
+60
View File
@@ -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<FileContent, String> {
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<u32> {
fn cmd_stdout(cmd: &str, args: &[&str]) -> Option<String> {
@@ -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();
@@ -27,6 +27,7 @@
"x": 17,
"y": 26
},
"dragDropEnabled": false,
"visible": false,
"backgroundThrottling": "disabled"
}
+254 -58
View File
@@ -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<ChatInputProps> = ({ onOpenSettings, scrollToBo
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const dropZoneRef = React.useRef<HTMLDivElement>(null);
const canAcceptDropRef = React.useRef(false);
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
@@ -373,7 +374,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<void>>(async () => {});
type SubmitOptions = {
queuedOnly?: boolean;
};
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
// Add message to queue instead of sending
const handleQueueMessage = React.useCallback(() => {
@@ -403,10 +407,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollToBo
showAbortStatus={showAbortStatus}
/>
</div>
<div
ref={dropZoneRef}
className={cn(
"chat-column relative overflow-visible",
isDragging && "ring-2 ring-primary ring-offset-2 rounded-xl"
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="text-center">
<div className="inline-flex justify-center">
<button
type="button"
className={iconButtonBaseClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
</div>
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
</div>
</div>
)}
<div className="chat-column relative overflow-visible">
<AttachedFilesList />
<QueuedMessageChips
onEditMessage={(content) => {
@@ -1766,13 +1935,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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 && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="text-center">
<div className="inline-flex justify-center">
<button
type="button"
className={iconButtonBaseClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
</div>
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
</div>
</div>
)}
{showCommandAutocomplete && (
<CommandAutocomplete
@@ -1826,6 +2019,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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"
@@ -888,7 +888,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
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}
@@ -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 (
<div className="relative h-12 w-12 sm:h-14 sm:w-14 overflow-hidden rounded-lg border border-border/40 bg-muted/10 flex-shrink-0">
<img
src={file.dataUrl}
alt={displayName}
className="h-full w-full object-cover"
loading="lazy"
/>
<button
onClick={onRemove}
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
);
}
return (
<div className="flex w-full sm:inline-flex sm:w-auto items-center gap-1.5 px-3 sm:px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta max-w-full min-w-0">
@@ -195,7 +222,6 @@ export const AttachedFilesList = memo(() => {
return (
<div className="pb-2 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
<span className="typography-meta text-muted-foreground font-medium flex-shrink-0">Attached:</span>
{attachedFiles.map((file) => (
<FileChip
key={file.id}
@@ -219,9 +245,10 @@ interface FilePart {
interface MessageFilesDisplayProps {
files: FilePart[];
onShowPopup?: (content: ToolPopupContent) => 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 (
<div className="space-y-2 mt-2">
<div className={cn(compact ? 'space-y-1.5 mt-1.5' : 'space-y-2 mt-2')}>
{}
{otherFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
{otherFiles.map((file, index) => (
<div
key={`file-${index}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta"
className={cn(
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta',
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
)}
>
{getFileIcon(file.mime)}
<div className="overflow-hidden max-w-[200px]">
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
<span className="truncate block" title={extractFilename(file.filename)}>
{extractFilename(file.filename)}
</span>
@@ -303,8 +354,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
{}
{imageFiles.length > 0 && (
<div className="overflow-x-auto -mx-1 px-1 py-1 scrollbar-thin">
<div className="flex gap-3 snap-x snap-mandatory">
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
<div className={cn('flex snap-x snap-mandatory', compact ? 'gap-2' : 'gap-3')}>
{imageFiles.map((file, index) => {
const filename = extractFilename(file.filename) || 'Image';
@@ -313,8 +364,13 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleImageClick(file)}
className="relative flex-none w-16 sm:w-20 md:w-24 aspect-square rounded-xl border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
onClick={() => handleImageClick(index)}
className={cn(
'relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary',
compact
? 'h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg'
: 'aspect-square w-16 sm:w-20 md:w-24 rounded-xl'
)}
aria-label={filename}
>
{file.url ? (
+112 -19
View File
@@ -8,6 +8,7 @@ import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
interface ChatMessageEntry {
@@ -87,31 +88,103 @@ const DynamicMessageRow = React.memo<MessageRowProps>(({
DynamicMessageRow.displayName = 'DynamicMessageRow';
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
displayMessages: ChatMessageEntry[];
interface TurnBlockProps {
turn: Turn;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ displayMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
}
const TurnBlock: React.FC<TurnBlockProps> = ({
turn,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
}) => {
const lastTurnMessageIds = useLastTurnMessageIds();
const renderMessage = React.useCallback(
(message: ChatMessageEntry) => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom]
);
return (
<section className="relative w-full" data-turn-id={turn.turnId}>
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-8 bg-gradient-to-b from-[var(--surface-background)] to-transparent"
/>
</div>
<div className="relative z-0">
{turn.assistantMessages.map((message) => renderMessage(message))}
</div>
</section>
);
};
TurnBlock.displayName = 'TurnBlock';
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
turns: Turn[];
ungroupedMessages: ChatMessageEntry[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ turns, ungroupedMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
const lastTurnMessageIds = useLastTurnMessageIds();
const renderUngroupedMessage = React.useCallback(
(message: ChatMessageEntry) => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom]
);
return (
<>
{displayMessages.map((message) => {
const isInLastTurn = lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
})}
{ungroupedMessages.map((message) => renderUngroupedMessage(message))}
{turns.map((turn) => (
<TurnBlock
key={turn.turnId}
turn={turn}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
))}
</>
);
};
@@ -161,6 +234,25 @@ const MessageList: React.FC<MessageListProps> = ({
});
}, [messages]);
const { turns, ungroupedMessages } = React.useMemo(() => {
const groupedTurns = detectTurns(displayMessages);
const groupedMessageIds = new Set<string>();
groupedTurns.forEach((turn) => {
groupedMessageIds.add(turn.userMessage.info.id);
turn.assistantMessages.forEach((message) => {
groupedMessageIds.add(message.info.id);
});
});
const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id));
return {
turns: groupedTurns,
ungroupedMessages: ungrouped,
};
}, [displayMessages]);
return (
<TurnGroupingProvider messages={displayMessages}>
<div>
@@ -183,7 +275,8 @@ const MessageList: React.FC<MessageListProps> = ({
)}
<MessageListContent
displayMessages={displayMessages}
turns={turns}
ungroupedMessages={ungroupedMessages}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
@@ -100,7 +100,6 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
return (
<div className="pb-2">
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
<span className="typography-meta text-muted-foreground font-medium">Queued:</span>
{queuedMessages.map((message) => (
<QueuedMessageChip
key={message.id}
@@ -181,11 +181,19 @@ const UserMessageBody: React.FC<{
);
})}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} compact />
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
<div className={cn(
"mt-1 flex items-center justify-end gap-2"
"absolute top-full left-0 right-0 z-10 pt-5 group/user-actions"
)}>
<div
className={cn(
"flex translate-x-5 items-center justify-end gap-1",
isMobile
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100"
)}
>
{onRevert && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
@@ -193,7 +201,7 @@ 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"
aria-label="Revert to this message"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
@@ -201,7 +209,7 @@ const UserMessageBody: React.FC<{
onRevert();
}}
>
<RiArrowGoBackLine className="h-3.5 w-3.5" />
<RiArrowGoBackLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
@@ -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();
}}
>
<RiGitBranchLine className="h-3.5 w-3.5" />
<RiGitBranchLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
@@ -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 ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
<RiCheckLine className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
<RiFileCopyLine className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
</Tooltip>
)}
</div>
</div>
) : null}
</div>
@@ -872,7 +881,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
const footerButtons = (
<>
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'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',
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
@@ -915,9 +961,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
</Tooltip>
{showMessageTTSButtons && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
{showMessageTTSButtons && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
@@ -937,49 +983,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'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',
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
</>
);
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
</>
);
return (
@@ -1011,26 +1019,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<FadeInOnReveal key="summary-body">
<div
className="group/assistant-text relative break-words"
onMouseEnter={() => setIsSummaryHovered(true)}
onMouseLeave={() => setIsSummaryHovered(false)}
>
<SimpleMarkdownRenderer content={summaryBody} />
{shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : <span />}
<div
className={cn(
"flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 focus-within:opacity-100 focus-within:pointer-events-auto",
isSummaryHovered && "opacity-100 pointer-events-auto",
)}
>
{footerButtons}
</div>
) : null}
</div>
)}
</div>
@@ -1039,16 +1040,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{!showSummaryBody && shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : <span />}
<div className="flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto">
{footerButtons}
</div>
) : null}
</div>
)}
@@ -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 <RiToolsLine className={iconClass} />;
};
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 = (
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-black/25 backdrop-blur-md',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
onMouseDown={() => onOpenChange(false)}
/>
{hasMultipleImages && (
<>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Previous image"
>
<RiArrowLeftSLine className="h-6 w-6" />
</button>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Next image"
>
<RiArrowRightSLine className="h-6 w-6" />
</button>
</>
)}
<div
className={cn(
'absolute inset-0 flex items-center justify-center pointer-events-none',
isMobile ? 'p-2.5' : 'p-4'
)}
>
<div
className={cn(
'pointer-events-auto flex flex-col gap-2',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
style={{ width: `${imageDisplaySize.width}px` }}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1 text-foreground typography-ui-header font-semibold truncate" title={imageTitle}>
{imageTitle}
</div>
<button
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close image preview"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
<img
src={currentImage.url}
alt={imageTitle}
className="block object-contain"
style={{ width: `${imageDisplaySize.width}px`, height: `${imageDisplaySize.height}px` }}
loading="lazy"
onLoad={(event) => {
const element = event.currentTarget;
const width = element.naturalWidth;
const height = element.naturalHeight;
if (width > 0 && height > 0) {
setImageNaturalSize((previous) => {
if (previous && previous.width === width && previous.height === height) {
return previous;
}
return { width, height };
});
}
}}
/>
</div>
</div>
</div>
);
return createPortal(content, document.body);
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
if (popup.image) {
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
}
return (
<Dialog open={popup.open} onOpenChange={onOpenChange}>
<DialogContent
@@ -385,24 +662,6 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
))}
</div>
) : null
) : popup.image ? (
<div className="p-4">
<div className="flex flex-col items-center gap-3">
<div className="max-h-[70vh] overflow-hidden rounded-2xl border border-border/40 bg-muted/10">
<img
src={popup.image.url}
alt={popup.image.filename || popup.title || 'Image preview'}
className="block h-full max-h-[70vh] w-auto max-w-full object-contain"
loading="lazy"
/>
</div>
{popup.image.filename && (
<span className="typography-meta text-muted-foreground text-center">
{popup.image.filename}
</span>
)}
</div>
</div>
) : popup.content ? (
<div className="p-4">
{(() => {
@@ -19,20 +19,45 @@ const buildMentionUrl = (name: string): string => {
};
const UserTextPart: React.FC<UserTextPartProps> = ({ 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<number>(0);
const textRef = React.useRef<HTMLDivElement>(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<UserTextPartProps> = ({ 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<HTMLDivElement>) => {
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<UserTextPartProps> = ({ part, messageId, agentMenti
};
return (
<div
className={cn(
"break-words whitespace-pre-wrap font-sans typography-markdown",
!isExpanded && "line-clamp-3",
(isTruncated || isExpanded) && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}
key={part.id || `${messageId}-user-text`}
>
{renderContent()}
<div className="relative" key={part.id || `${messageId}-user-text`}>
<div
className={cn(
"break-words whitespace-pre-wrap font-sans typography-markdown",
!isExpanded && "line-clamp-2",
isTruncated && !isExpanded && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}
>
{renderContent()}
</div>
</div>
);
};
@@ -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;
};
}
@@ -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<BottomTerminalDockProps> = ({ isOpen,
)}
{isOpen && (
<button
type="button"
onClick={toggleFullscreen}
className="absolute right-2 top-2 z-30 inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
aria-label={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
>
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
</button>
<div className="absolute right-2 top-2 z-30 inline-flex items-center gap-1">
<button
type="button"
onClick={toggleFullscreen}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
aria-label={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
>
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
</button>
<button
type="button"
onClick={() => setBottomTerminalOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title="Close terminal panel"
aria-label="Close terminal panel"
>
<RiCloseLine className="h-6 w-6" />
</button>
</div>
)}
<div
+8 -4
View File
@@ -882,19 +882,23 @@ export const Header: React.FC = () => {
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Open instance, usage and MCP (current: ${currentInstanceLabel})`}
aria-label={isDesktopApp
? `Open instance, usage and MCP (current: ${currentInstanceLabel})`
: 'Open services, usage and MCP'}
className={cn(
headerIconButtonClass,
'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5'
isDesktopApp
? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5'
: 'h-9 w-9'
)}
>
<RiStackLine className="h-5 w-5" />
<span className="truncate text-base font-normal">{currentInstanceLabel}</span>
{isDesktopApp && <span className="truncate text-base font-normal">{currentInstanceLabel}</span>}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Current instance: {currentInstanceLabel}</p>
<p>{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -69,7 +69,15 @@ export const VSCodeLayout: React.FC = () => {
const hasAppliedInitialSession = React.useRef(false);
const [currentView, setCurrentView] = React.useState<VSCodeView>('sessions');
const bootDraftOpen = React.useMemo(() => {
try {
return Boolean(useSessionStore.getState().newSessionDraft?.open);
} catch {
return false;
}
}, []);
const [currentView, setCurrentView] = React.useState<VSCodeView>(() => (bootDraftOpen ? 'chat' : 'sessions'));
const [containerWidth, setContainerWidth] = React.useState<number>(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
@@ -632,6 +632,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
const previousActiveProjectRef = React.useRef<string | null>(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<SessionSidebarProps> = ({
}
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<SessionSidebarProps> = ({
activeSessionByProject,
currentSessionId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
projectSections,
@@ -1967,7 +1992,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
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<SessionSidebarProps> = ({
&& 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 (
<div className="oc-group">
<div className="oc-group-body pb-3">
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
{totalSessions === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
No sessions in this workspace yet.
</div>
) : null}
{remainingCount > 0 && !isExpanded ? (
<button
type="button"
onClick={() => toggleGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
</button>
) : null}
{isExpanded && totalSessions > maxVisible ? (
<button
type="button"
onClick={() => toggleGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
</button>
) : null}
</div>
</div>
);
}
return (
<div className="oc-group">
<div
className="group/gh flex items-center justify-between gap-2 py-1 min-w-0 rounded-sm hover:bg-interactive-hover/50 cursor-pointer"
onClick={() => {
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<SessionSidebarProps> = ({
}
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<SessionSidebarProps> = ({
return next;
});
}
}}
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
} : undefined}
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
>
<div className="min-w-0 flex items-center gap-1.5 px-0">
{isCollapsed ? (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
{!group.isMain || isGitProject ? (
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : null}
<div className="min-w-0 flex flex-col justify-center">
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
{group.label}
</p>
{showBranchSubtitle ? (
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
{group.branch}
</span>
{!hideGroupLabel ? (
<div className="min-w-0 flex items-center gap-1.5 px-0">
{isCollapsed ? (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
{!group.isMain || isGitProject ? (
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : null}
<div className="min-w-0 flex flex-col justify-center">
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
{group.label}
</p>
{showBranchSubtitle ? (
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
{group.branch}
</span>
) : null}
</div>
</div>
</div>
) : <div />}
{group.directory ? (
<div className="flex items-center gap-1 px-0.5">
{!group.isMain && group.worktree ? (
@@ -2404,7 +2469,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
}
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);
})()}
</div>
) : (
+7 -7
View File
@@ -13,13 +13,13 @@ const variants = [
{
variant: "shine",
component: ({ children, className, ...props }) => (
<motion.span
{...props}
className={cn(
"bg-[linear-gradient(110deg,#bfbfbf,35%,#000,50%,#bfbfbf,75%,#bfbfbf)] dark:bg-[linear-gradient(110deg,#404040,35%,#fff,50%,#404040,75%,#404040)]",
"bg-[length:200%_100%] bg-clip-text text-transparent",
className
)}
<motion.span
{...props}
className={cn(
"bg-[linear-gradient(110deg,color-mix(in_srgb,var(--surface-muted-foreground)_45%,transparent),35%,var(--surface-foreground),50%,color-mix(in_srgb,var(--surface-muted-foreground)_45%,transparent),75%,color-mix(in_srgb,var(--surface-muted-foreground)_45%,transparent))]",
"bg-[length:200%_100%] bg-clip-text text-transparent",
className
)}
initial={{ backgroundPosition: "200% 0" }}
animate={{ backgroundPosition: "-200% 0" }}
transition={{
@@ -25,7 +25,6 @@ interface SessionMemoryState {
isZombie?: boolean;
}
interface UseChatScrollManagerOptions {
currentSessionId: string | null;
sessionMessages: ChatMessageRecord[];
@@ -87,17 +86,11 @@ export const useChatScrollManager = ({
const [isPinned, setIsPinned] = React.useState(true);
const lastSessionIdRef = React.useRef<string | null>(null);
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
const suppressUserScrollUntilRef = React.useRef<number>(0);
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
const isPinnedRef = React.useRef(true);
const lastScrollTopRef = React.useRef<number>(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;
+4 -67
View File
@@ -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<string> {
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,
+43 -70
View File
@@ -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<string> => {
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<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
export const useFileStore = create<FileStore>()(
@@ -196,66 +196,33 @@ export const useFileStore = create<FileStore>()(
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<FileStore>()(
filename: name,
size: sizeBytes,
source: "server",
serverPath: path,
serverPath: normalizedPath,
};
set((state) => ({
@@ -286,6 +253,12 @@ export const useFileStore = create<FileStore>()(
{
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,
}),
+21 -2
View File
@@ -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<MessageStore>()(
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<MessageStore>()(
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,
})),
}));
+28 -24
View File
@@ -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();
};
+87 -7
View File
@@ -67,6 +67,12 @@ type ApiProxyRequestPayload = {
bodyBase64?: string;
};
type ApiSessionMessageRequestPayload = {
path?: string;
headers?: Record<string, string>;
bodyText?: string;
};
type ApiProxyResponsePayload = {
status: number;
headers: Record<string, string>;
@@ -760,6 +766,23 @@ const collectHeaders = (headers: Headers): Record<string, string> => {
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<string, string> | undefined): Record<string, string> => {
const headers: Record<string, string> = { ...(input || {}) };
delete headers['content-length'];
delete headers['host'];
delete headers['connection'];
return headers;
};
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
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<string, string> = { ...(headers || {}) };
const requestHeaders: Record<string, string> = 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<string, string> = 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();
+9
View File
@@ -107,6 +107,15 @@ export async function proxyApiRequest(options: {
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
}
export async function proxySessionMessageRequest(options: {
path: string;
headers?: Record<string, string>;
bodyText: string;
}): Promise<ProxiedApiResponse> {
// Keep parity with server-side direct forwarder: let extension host control timeout.
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:session:message', options, { timeoutMs: 0 });
}
export type ProxiedSseStartResponse = {
status: number;
headers: Record<string, string>;
+39 -1
View File
@@ -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<string> => {
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();
+328 -53
View File
@@ -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: <type>: <summary> (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';