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
@@ -1843,6 +1843,65 @@ fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), S
|
||||
create_window(&app, &url, &local_origin).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Read a file and return its content as base64 with mime type detection.
|
||||
/// Used for drag-drop file attachments in desktop app.
|
||||
#[tauri::command]
|
||||
fn desktop_read_file(path: String) -> Result<FileContent, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let path = Path::new(&path);
|
||||
|
||||
// Check file size (max 50MB)
|
||||
let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {e}"))?;
|
||||
let size = metadata.len();
|
||||
if size > 50 * 1024 * 1024 {
|
||||
return Err("File is too large. Maximum size is 50MB.".to_string());
|
||||
}
|
||||
|
||||
// Read file bytes
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
|
||||
// Detect mime type from extension
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
|
||||
let mime = match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"bmp" => "image/bmp",
|
||||
"ico" => "image/x-icon",
|
||||
"pdf" => "application/pdf",
|
||||
"txt" => "text/plain",
|
||||
"md" => "text/markdown",
|
||||
"json" => "application/json",
|
||||
"js" => "text/javascript",
|
||||
"ts" => "text/typescript",
|
||||
"tsx" => "text/typescript-jsx",
|
||||
"jsx" => "text/javascript-jsx",
|
||||
"html" => "text/html",
|
||||
"css" => "text/css",
|
||||
"py" => "text/x-python",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
// Encode as base64
|
||||
let base64 = general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
Ok(FileContent {
|
||||
mime: mime.to_string(),
|
||||
base64,
|
||||
size: bytes.len(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FileContent {
|
||||
mime: String,
|
||||
base64: String,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_major_version() -> Option<u32> {
|
||||
fn cmd_stdout(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
@@ -2222,6 +2281,7 @@ fn main() {
|
||||
desktop_hosts_get,
|
||||
desktop_hosts_set,
|
||||
desktop_host_probe,
|
||||
desktop_read_file,
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"x": 17,
|
||||
"y": 26
|
||||
},
|
||||
"dragDropEnabled": false,
|
||||
"visible": false,
|
||||
"backgroundThrottling": "disabled"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -67,6 +67,12 @@ type ApiProxyRequestPayload = {
|
||||
bodyBase64?: string;
|
||||
};
|
||||
|
||||
type ApiSessionMessageRequestPayload = {
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyText?: string;
|
||||
};
|
||||
|
||||
type ApiProxyResponsePayload = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
@@ -760,6 +766,23 @@ const collectHeaders = (headers: Headers): Record<string, string> => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const buildUnavailableApiResponse = (): ApiProxyResponsePayload => {
|
||||
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
|
||||
return {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeForwardHeaders = (input: Record<string, string> | undefined): Record<string, string> => {
|
||||
const headers: Record<string, string> = { ...(input || {}) };
|
||||
delete headers['content-length'];
|
||||
delete headers['host'];
|
||||
delete headers['connection'];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
@@ -768,12 +791,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
case 'api:proxy': {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
const data = buildUnavailableApiResponse();
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
@@ -788,7 +806,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = { ...(headers || {}) };
|
||||
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers);
|
||||
|
||||
// Ensure SSE requests are negotiated correctly.
|
||||
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
||||
@@ -830,6 +848,68 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:session:message': {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
const data = buildUnavailableApiResponse();
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload;
|
||||
const normalizedPath =
|
||||
typeof requestPath === 'string' && requestPath.trim().length > 0
|
||||
? requestPath.trim().startsWith('/')
|
||||
? requestPath.trim()
|
||||
: `/${requestPath.trim()}`
|
||||
: '/';
|
||||
|
||||
if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) {
|
||||
const body = JSON.stringify({ error: 'Invalid session message proxy path' });
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: typeof bodyText === 'string' ? bodyText : '',
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: response.status,
|
||||
headers: collectHeaders(response.headers),
|
||||
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
|
||||
};
|
||||
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error instanceof Error &&
|
||||
((error as Error & { name?: string }).name === 'TimeoutError' ||
|
||||
(error as Error & { name?: string }).name === 'AbortError');
|
||||
const body = JSON.stringify({
|
||||
error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed',
|
||||
});
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: isTimeout ? 504 : 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
case 'files:list': {
|
||||
const { path: dirPath } = payload as { path: string };
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
@@ -107,6 +107,15 @@ export async function proxyApiRequest(options: {
|
||||
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
|
||||
}
|
||||
|
||||
export async function proxySessionMessageRequest(options: {
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyText: string;
|
||||
}): Promise<ProxiedApiResponse> {
|
||||
// Keep parity with server-side direct forwarder: let extension host control timeout.
|
||||
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:session:message', options, { timeoutMs: 0 });
|
||||
}
|
||||
|
||||
export type ProxiedSseStartResponse = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createVSCodeAPIs } from './api';
|
||||
import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
||||
import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import {
|
||||
buildVSCodeThemeFromPalette,
|
||||
@@ -343,7 +343,35 @@ const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | u
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string> => {
|
||||
if (method === 'GET' || method === 'HEAD') return '';
|
||||
|
||||
if (input instanceof Request) {
|
||||
const cloned = input.clone();
|
||||
return await cloned.text();
|
||||
}
|
||||
|
||||
const body = init?.body;
|
||||
if (!body) return '';
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (body instanceof URLSearchParams) {
|
||||
return body.toString();
|
||||
}
|
||||
|
||||
if (body instanceof Blob) {
|
||||
return await body.text();
|
||||
}
|
||||
|
||||
console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body);
|
||||
return '';
|
||||
};
|
||||
|
||||
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
|
||||
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
|
||||
|
||||
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
const pathname = url.pathname;
|
||||
@@ -761,6 +789,16 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
|
||||
}
|
||||
|
||||
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
|
||||
const bodyText = await extractBodyText(input, init, method);
|
||||
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
|
||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
||||
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
|
||||
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
||||
maybeHideLoadingOverlay();
|
||||
return response;
|
||||
}
|
||||
|
||||
const bodyBase64 = await extractBodyBase64(input, init, method);
|
||||
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
||||
|
||||
+328
-53
@@ -1,5 +1,4 @@
|
||||
import express from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import path from 'path';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
@@ -4630,6 +4629,157 @@ function setupProxy(app) {
|
||||
next();
|
||||
});
|
||||
|
||||
const isSseApiPath = (path) => path === '/event' || path === '/global/event';
|
||||
|
||||
const forwardSseRequest = async (req, res) => {
|
||||
const startedAt = Date.now();
|
||||
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
|
||||
const requestHeaders = {
|
||||
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }),
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
let connectTimer = null;
|
||||
let idleTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
let endedBy = 'upstream-end';
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
}
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
req.off('close', onClientClose);
|
||||
};
|
||||
|
||||
const resetIdleTimeout = () => {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
idleTimer = setTimeout(() => {
|
||||
endedBy = 'idle-timeout';
|
||||
controller.abort();
|
||||
}, 5 * 60 * 1000);
|
||||
};
|
||||
|
||||
const onClientClose = () => {
|
||||
endedBy = 'client-disconnect';
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
req.on('close', onClientClose);
|
||||
|
||||
try {
|
||||
connectTimer = setTimeout(() => {
|
||||
endedBy = 'connect-timeout';
|
||||
controller.abort();
|
||||
}, 10 * 1000);
|
||||
|
||||
const upstreamResponse = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok || !upstreamResponse.body) {
|
||||
const body = await upstreamResponse.text().catch(() => '');
|
||||
cleanup();
|
||||
if (!res.headersSent) {
|
||||
if (upstreamResponse.headers.has('content-type')) {
|
||||
res.setHeader('content-type', upstreamResponse.headers.get('content-type'));
|
||||
}
|
||||
res.status(upstreamResponse.status).send(body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream';
|
||||
res.status(upstreamResponse.status);
|
||||
res.setHeader('content-type', upstreamContentType);
|
||||
res.setHeader('cache-control', 'no-cache');
|
||||
res.setHeader('connection', 'keep-alive');
|
||||
res.setHeader('x-accel-buffering', 'no');
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
resetIdleTimeout();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (res.writableEnded || controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
resetIdleTimeout();
|
||||
} catch {
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
const reader = upstreamResponse.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
endedBy = endedBy === 'upstream-end' ? 'upstream-finished' : endedBy;
|
||||
break;
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
if (value && value.length > 0) {
|
||||
res.write(Buffer.from(value));
|
||||
resetIdleTimeout();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
console.log(`SSE forward ${upstreamPath} closed (${endedBy}) in ${Date.now() - startedAt}ms`);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
||||
if (!res.headersSent) {
|
||||
res.status(isTimeout ? 504 : 503).json({
|
||||
error: isTimeout ? 'OpenCode SSE forward timed out' : 'OpenCode SSE forward failed',
|
||||
});
|
||||
} else if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
console.warn(`SSE forward ${upstreamPath} failed (${endedBy}):`, error?.message || error);
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/event', forwardSseRequest);
|
||||
app.get('/api/global/event', forwardSseRequest);
|
||||
|
||||
app.use('/api', (_req, _res, next) => {
|
||||
ensureOpenCodeApiPrefix();
|
||||
next();
|
||||
@@ -4651,66 +4801,162 @@ function setupProxy(app) {
|
||||
});
|
||||
|
||||
|
||||
const proxyMiddleware = createProxyMiddleware({
|
||||
target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0',
|
||||
router: () => {
|
||||
if (!openCodePort) {
|
||||
return 'http://127.0.0.1:0';
|
||||
}
|
||||
return `http://localhost:${openCodePort}`;
|
||||
},
|
||||
changeOrigin: true,
|
||||
pathRewrite: (path) => {
|
||||
if (!path.startsWith('/api')) {
|
||||
return path;
|
||||
const hopByHopRequestHeaders = new Set([
|
||||
'host',
|
||||
'connection',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'keep-alive',
|
||||
'te',
|
||||
'trailer',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
const hopByHopResponseHeaders = new Set([
|
||||
'connection',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'keep-alive',
|
||||
'te',
|
||||
'trailer',
|
||||
'upgrade',
|
||||
'www-authenticate',
|
||||
]);
|
||||
|
||||
const collectForwardHeaders = (req) => {
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
const headers = {};
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers || {})) {
|
||||
if (!value) continue;
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (hopByHopRequestHeaders.has(lowerKey)) continue;
|
||||
headers[lowerKey] = Array.isArray(value) ? value.join(', ') : String(value);
|
||||
}
|
||||
|
||||
if (authHeaders.Authorization) {
|
||||
headers.Authorization = authHeaders.Authorization;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
const collectRequestBodyBuffer = async (req) => {
|
||||
if (Buffer.isBuffer(req.body)) {
|
||||
return req.body;
|
||||
}
|
||||
|
||||
if (typeof req.body === 'string') {
|
||||
return Buffer.from(req.body);
|
||||
}
|
||||
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
return Buffer.from(JSON.stringify(req.body));
|
||||
}
|
||||
|
||||
if (req.readableEnded) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
const forwardGenericApiRequest = async (req, res) => {
|
||||
try {
|
||||
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||
const headers = collectForwardHeaders(req);
|
||||
const method = String(req.method || 'GET').toUpperCase();
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
const bodyBuffer = hasBody ? await collectRequestBodyBuffer(req) : null;
|
||||
|
||||
const upstreamResponse = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? bodyBuffer : undefined,
|
||||
signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
for (const [key, value] of upstreamResponse.headers.entries()) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (hopByHopResponseHeaders.has(lowerKey)) {
|
||||
continue;
|
||||
}
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
|
||||
const suffix = path.slice(4) || '/';
|
||||
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||
res.status(upstreamResponse.status).send(upstreamBody);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
||||
res.status(isTimeout ? 504 : 503).json({
|
||||
error: isTimeout ? 'OpenCode request timed out' : 'OpenCode service unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return suffix;
|
||||
},
|
||||
ws: false,
|
||||
// v3.x API: callbacks go in 'on' object
|
||||
on: {
|
||||
error: (err, req, res) => {
|
||||
console.error(`Proxy error: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||
}
|
||||
},
|
||||
proxyReq: (proxyReq, req, res) => {
|
||||
console.log(`Proxying ${req.method} ${req.path} to OpenCode`);
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
if (authHeaders.Authorization) {
|
||||
proxyReq.setHeader('Authorization', authHeaders.Authorization);
|
||||
}
|
||||
// Dedicated forwarder for large session message payloads.
|
||||
// This avoids edge-cases in generic proxy streaming for multi-file attachments.
|
||||
app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
|
||||
try {
|
||||
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
|
||||
proxyReq.setHeader('Accept', 'text/event-stream');
|
||||
proxyReq.setHeader('Cache-Control', 'no-cache');
|
||||
proxyReq.setHeader('Connection', 'keep-alive');
|
||||
}
|
||||
},
|
||||
proxyRes: (proxyRes, req, res) => {
|
||||
// Strip WWW-Authenticate to prevent browser's native Basic Auth popup
|
||||
if (proxyRes.headers['www-authenticate']) {
|
||||
delete proxyRes.headers['www-authenticate'];
|
||||
}
|
||||
const headers = {
|
||||
...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }),
|
||||
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}),
|
||||
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
||||
};
|
||||
|
||||
if (req.url?.includes('/event')) {
|
||||
proxyRes.headers['Access-Control-Allow-Origin'] = '*';
|
||||
proxyRes.headers['Access-Control-Allow-Headers'] = 'Cache-Control, Accept';
|
||||
proxyRes.headers['Content-Type'] = 'text/event-stream';
|
||||
proxyRes.headers['Cache-Control'] = 'no-cache';
|
||||
proxyRes.headers['Connection'] = 'keep-alive';
|
||||
proxyRes.headers['X-Accel-Buffering'] = 'no';
|
||||
proxyRes.headers['X-Content-Type-Options'] = 'nosniff';
|
||||
}
|
||||
const bodyBuffer = Buffer.isBuffer(req.body)
|
||||
? req.body
|
||||
: Buffer.from(typeof req.body === 'string' ? req.body : '');
|
||||
|
||||
const upstreamResponse = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: bodyBuffer,
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||
|
||||
if (upstreamResponse.headers.has('content-type')) {
|
||||
res.setHeader('content-type', upstreamResponse.headers.get('content-type'));
|
||||
}
|
||||
|
||||
res.status(upstreamResponse.status).send(upstreamBody);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
||||
res.status(isTimeout ? 504 : 503).json({
|
||||
error: isTimeout ? 'OpenCode message forward timed out' : 'OpenCode message forward failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', proxyMiddleware);
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (isSseApiPath(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && /\/session\/[^/]+\/message$/.test(req.path || '')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return forwardGenericApiRequest(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
function startHealthMonitoring() {
|
||||
@@ -8478,7 +8724,36 @@ async function main(options = {}) {
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
|
||||
const prompt = `You are generating a Conventional Commits subject line from the provided diff.
|
||||
|
||||
Return EXACTLY one JSON object (no code fences, no extra keys, no extra text):
|
||||
{"subject": string, "highlights": string[]}
|
||||
|
||||
Non-negotiable:
|
||||
- Output must be valid JSON (double quotes).
|
||||
- Only claim what is supported by the diff. If unsure, be more general; do not guess.
|
||||
|
||||
subject:
|
||||
- Format: <type>: <summary> (NO scope; never write type(scope))
|
||||
- Allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert
|
||||
- Choose type (prefer fix when ambiguous):
|
||||
- fix: any bug/regression/wrong behavior (state, selection, navigation, persistence, crash)
|
||||
- feat: new user-facing capability or new workflow (not just guardrails/defaults)
|
||||
- refactor/perf/docs/test/build/ci/style/chore/revert: only when clearly the primary change
|
||||
- Summary style:
|
||||
- imperative, present tense, outcome-first
|
||||
- <= 72 characters, no trailing period
|
||||
- avoid filenames, internal function names, and implementation details
|
||||
|
||||
highlights:
|
||||
- 0-3 items; it is OK to return [].
|
||||
- Each item: one plain sentence, <= 90 chars, starts with an Uppercase verb.
|
||||
- Must add information not already in the subject.
|
||||
- Prefer user-observable behaviors (UI flow, navigation, selection, default view, persistence).
|
||||
- No markdown bullets, no file paths, no helper names.
|
||||
|
||||
Diff summary (may be truncated):
|
||||
${diffSummaries}`;
|
||||
|
||||
const model = 'gpt-5-nano';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user