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:
committed by
GitHub
parent
39e625d8ec
commit
844562749d
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user