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
+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();
};