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())
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn macos_major_version() -> Option<u32> {
|
fn macos_major_version() -> Option<u32> {
|
||||||
fn cmd_stdout(cmd: &str, args: &[&str]) -> Option<String> {
|
fn cmd_stdout(cmd: &str, args: &[&str]) -> Option<String> {
|
||||||
@@ -2222,6 +2281,7 @@ fn main() {
|
|||||||
desktop_hosts_get,
|
desktop_hosts_get,
|
||||||
desktop_hosts_set,
|
desktop_hosts_set,
|
||||||
desktop_host_probe,
|
desktop_host_probe,
|
||||||
|
desktop_read_file,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"x": 17,
|
"x": 17,
|
||||||
"y": 26
|
"y": 26
|
||||||
},
|
},
|
||||||
|
"dragDropEnabled": false,
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"backgroundThrottling": "disabled"
|
"backgroundThrottling": "disabled"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
|||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { isIMECompositionEvent } from '@/lib/ime';
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { StopIcon } from '@/components/icons/StopIcon';
|
import { StopIcon } from '@/components/icons/StopIcon';
|
||||||
import type { MobileControlsPanel } from './mobileControlsUtils';
|
import type { MobileControlsPanel } from './mobileControlsUtils';
|
||||||
@@ -53,7 +53,7 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
|
|||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onOpenSettings?: () => void;
|
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';
|
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 [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
||||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const canAcceptDropRef = React.useRef(false);
|
||||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||||
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
||||||
@@ -373,7 +374,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const canAbort = working.isWorking;
|
const canAbort = working.isWorking;
|
||||||
|
|
||||||
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
// 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
|
// Add message to queue instead of sending
|
||||||
const handleQueueMessage = React.useCallback(() => {
|
const handleQueueMessage = React.useCallback(() => {
|
||||||
@@ -403,10 +407,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
|
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (options?: SubmitOptions) => {
|
||||||
e?.preventDefault();
|
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
|
// Re-pin and scroll to bottom when sending
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
@@ -448,8 +456,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add current input
|
// Add current input (skip for queued-only auto-send)
|
||||||
if (hasContent) {
|
if (!queuedOnly && hasContent) {
|
||||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||||
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||||
@@ -473,7 +481,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||||
let drafts: InlineCommentDraft[] = [];
|
let drafts: InlineCommentDraft[] = [];
|
||||||
if (sessionKey) {
|
if (!queuedOnly && sessionKey) {
|
||||||
drafts = consumeDrafts(sessionKey);
|
drafts = consumeDrafts(sessionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,12 +512,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (currentSessionId && hasQueuedMessages) {
|
if (currentSessionId && hasQueuedMessages) {
|
||||||
clearQueue(currentSessionId);
|
clearQueue(currentSessionId);
|
||||||
}
|
}
|
||||||
setMessage('');
|
if (!queuedOnly) {
|
||||||
// Reset message history navigation state
|
setMessage('');
|
||||||
setHistoryIndex(-1);
|
// Reset message history navigation state
|
||||||
setDraftMessage('');
|
setHistoryIndex(-1);
|
||||||
if (attachedFiles.length > 0) {
|
setDraftMessage('');
|
||||||
clearAttachedFiles();
|
if (attachedFiles.length > 0) {
|
||||||
|
clearAttachedFiles();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -615,6 +625,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSoftNetworkError) {
|
if (isSoftNetworkError) {
|
||||||
|
if (allAttachments.length > 0) {
|
||||||
|
useFileStore.setState({ attachedFiles: allAttachments });
|
||||||
|
toast.error('Failed to send attachments. Try fewer files or smaller images.');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +676,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
// Use setTimeout to avoid calling during render
|
// Use setTimeout to avoid calling during render
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (currentSessionId && currentProviderId && currentModelId) {
|
if (currentSessionId && currentProviderId && currentModelId) {
|
||||||
void handleSubmitRef.current();
|
void handleSubmitRef.current({ queuedOnly: true });
|
||||||
}
|
}
|
||||||
autoSendTriggeredRef.current = false;
|
autoSendTriggeredRef.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -1278,7 +1292,59 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [abortPromptSessionId, currentSessionId, clearAbortPrompt]);
|
}, [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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
|
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) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1295,26 +1373,31 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent) => {
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
if (!hasDraggedFiles(e.dataTransfer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
if (!currentSessionId && !newSessionDraftOpen) return;
|
if (!currentSessionId && !newSessionDraftOpen) return;
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = collectDroppedFiles(e.dataTransfer);
|
||||||
let attachedCount = 0;
|
let attachedCount = 0;
|
||||||
|
|
||||||
for (const file of files) {
|
if (files.length > 0) {
|
||||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
for (const file of files) {
|
||||||
try {
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||||
await addAttachedFile(file);
|
try {
|
||||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
await addAttachedFile(file);
|
||||||
if (sizeAfter > sizeBefore) {
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||||
attachedCount += 1;
|
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 }>) => {
|
const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => {
|
||||||
let attachedCount = 0;
|
let attachedCount = 0;
|
||||||
|
|
||||||
@@ -1708,35 +1905,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
showAbortStatus={showAbortStatus}
|
showAbortStatus={showAbortStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="chat-column relative overflow-visible">
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<AttachedFilesList />
|
<AttachedFilesList />
|
||||||
<QueuedMessageChips
|
<QueuedMessageChips
|
||||||
onEditMessage={(content) => {
|
onEditMessage={(content) => {
|
||||||
@@ -1766,13 +1935,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col relative overflow-visible",
|
"flex flex-col relative overflow-visible",
|
||||||
"border border-border/80",
|
"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={{
|
style={{
|
||||||
borderRadius: cornerRadius,
|
borderRadius: cornerRadius,
|
||||||
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
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 && (
|
{showCommandAutocomplete && (
|
||||||
<CommandAutocomplete
|
<CommandAutocomplete
|
||||||
@@ -1826,6 +2019,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||||
placeholder={currentSessionId || newSessionDraftOpen
|
placeholder={currentSessionId || newSessionDraftOpen
|
||||||
? "# for agents; @ for files; / for commands"
|
? "# for agents; @ for files; / for commands"
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'group w-full',
|
'group w-full',
|
||||||
shouldShowHeader ? 'pt-6' : 'pt-0',
|
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}
|
data-message-id={message.info.id}
|
||||||
ref={messageContainerRef}
|
ref={messageContainerRef}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '...';
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
@@ -157,6 +158,32 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const displayName = extractFilename(file.filename);
|
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 (
|
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">
|
<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 (
|
return (
|
||||||
<div className="pb-2 overflow-hidden">
|
<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">
|
<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) => (
|
{attachedFiles.map((file) => (
|
||||||
<FileChip
|
<FileChip
|
||||||
key={file.id}
|
key={file.id}
|
||||||
@@ -219,9 +245,10 @@ interface FilePart {
|
|||||||
interface MessageFilesDisplayProps {
|
interface MessageFilesDisplayProps {
|
||||||
files: FilePart[];
|
files: FilePart[];
|
||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
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));
|
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 imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
|
||||||
const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
|
const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
|
||||||
|
|
||||||
const handleImageClick = React.useCallback((file: { filename?: string; mime?: string; size?: number; url?: string }) => {
|
const imageGallery = React.useMemo(
|
||||||
if (!onShowPopup || !file?.url) {
|
() =>
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = extractFilename(file.filename) || 'Image';
|
const file = imageGallery[index];
|
||||||
|
if (!file?.url) return;
|
||||||
|
|
||||||
|
const filename = file.filename || 'Image';
|
||||||
|
|
||||||
const popupPayload: ToolPopupContent = {
|
const popupPayload: ToolPopupContent = {
|
||||||
open: true,
|
open: true,
|
||||||
@@ -265,33 +310,39 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
|
|||||||
metadata: {
|
metadata: {
|
||||||
tool: 'image-preview',
|
tool: 'image-preview',
|
||||||
filename,
|
filename,
|
||||||
mime: file.mime,
|
mime: file.mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
url: file.url,
|
url: file.url,
|
||||||
mimeType: file.mime,
|
mimeType: file.mimeType,
|
||||||
filename,
|
filename,
|
||||||
|
size: file.size,
|
||||||
|
gallery: imageGallery,
|
||||||
|
index,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
onShowPopup(popupPayload);
|
onShowPopup(popupPayload);
|
||||||
}, [onShowPopup]);
|
}, [imageGallery, onShowPopup]);
|
||||||
|
|
||||||
if (fileItems.length === 0) return null;
|
if (fileItems.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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 && (
|
{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) => (
|
{otherFiles.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={`file-${index}`}
|
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)}
|
{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)}>
|
<span className="truncate block" title={extractFilename(file.filename)}>
|
||||||
{extractFilename(file.filename)}
|
{extractFilename(file.filename)}
|
||||||
</span>
|
</span>
|
||||||
@@ -303,8 +354,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
|
|||||||
|
|
||||||
{}
|
{}
|
||||||
{imageFiles.length > 0 && (
|
{imageFiles.length > 0 && (
|
||||||
<div className="overflow-x-auto -mx-1 px-1 py-1 scrollbar-thin">
|
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
|
||||||
<div className="flex gap-3 snap-x snap-mandatory">
|
<div className={cn('flex snap-x snap-mandatory', compact ? 'gap-2' : 'gap-3')}>
|
||||||
{imageFiles.map((file, index) => {
|
{imageFiles.map((file, index) => {
|
||||||
const filename = extractFilename(file.filename) || 'Image';
|
const filename = extractFilename(file.filename) || 'Image';
|
||||||
|
|
||||||
@@ -313,8 +364,13 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleImageClick(file)}
|
onClick={() => handleImageClick(index)}
|
||||||
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"
|
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}
|
aria-label={filename}
|
||||||
>
|
>
|
||||||
{file.url ? (
|
{file.url ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { PermissionRequest } from '@/types/permission';
|
|||||||
import type { QuestionRequest } from '@/types/question';
|
import type { QuestionRequest } from '@/types/question';
|
||||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||||
|
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
|
||||||
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
|
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
|
||||||
|
|
||||||
interface ChatMessageEntry {
|
interface ChatMessageEntry {
|
||||||
@@ -87,31 +88,103 @@ const DynamicMessageRow = React.memo<MessageRowProps>(({
|
|||||||
|
|
||||||
DynamicMessageRow.displayName = 'DynamicMessageRow';
|
DynamicMessageRow.displayName = 'DynamicMessageRow';
|
||||||
|
|
||||||
// Inner component that renders messages with access to context hooks
|
interface TurnBlockProps {
|
||||||
const MessageListContent: React.FC<{
|
turn: Turn;
|
||||||
displayMessages: ChatMessageEntry[];
|
|
||||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
}> = ({ displayMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
|
}
|
||||||
|
|
||||||
|
const TurnBlock: React.FC<TurnBlockProps> = ({
|
||||||
|
turn,
|
||||||
|
onMessageContentChange,
|
||||||
|
getAnimationHandlers,
|
||||||
|
scrollToBottom,
|
||||||
|
}) => {
|
||||||
const lastTurnMessageIds = useLastTurnMessageIds();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{displayMessages.map((message) => {
|
{ungroupedMessages.map((message) => renderUngroupedMessage(message))}
|
||||||
const isInLastTurn = lastTurnMessageIds.has(message.info.id);
|
|
||||||
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
|
{turns.map((turn) => (
|
||||||
|
<TurnBlock
|
||||||
return (
|
key={turn.turnId}
|
||||||
<RowComponent
|
turn={turn}
|
||||||
key={message.info.id}
|
onMessageContentChange={onMessageContentChange}
|
||||||
message={message}
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
onContentChange={onMessageContentChange}
|
scrollToBottom={scrollToBottom}
|
||||||
animationHandlers={getAnimationHandlers(message.info.id)}
|
/>
|
||||||
scrollToBottom={scrollToBottom}
|
))}
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -161,6 +234,25 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
});
|
});
|
||||||
}, [messages]);
|
}, [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 (
|
return (
|
||||||
<TurnGroupingProvider messages={displayMessages}>
|
<TurnGroupingProvider messages={displayMessages}>
|
||||||
<div>
|
<div>
|
||||||
@@ -183,7 +275,8 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageListContent
|
<MessageListContent
|
||||||
displayMessages={displayMessages}
|
turns={turns}
|
||||||
|
ungroupedMessages={ungroupedMessages}
|
||||||
onMessageContentChange={onMessageContentChange}
|
onMessageContentChange={onMessageContentChange}
|
||||||
getAnimationHandlers={getAnimationHandlers}
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
scrollToBottom={scrollToBottom}
|
scrollToBottom={scrollToBottom}
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
|
|||||||
return (
|
return (
|
||||||
<div className="pb-2">
|
<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">
|
<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) => (
|
{queuedMessages.map((message) => (
|
||||||
<QueuedMessageChip
|
<QueuedMessageChip
|
||||||
key={message.id}
|
key={message.id}
|
||||||
|
|||||||
@@ -181,11 +181,19 @@ const UserMessageBody: React.FC<{
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} compact />
|
||||||
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
|
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
|
||||||
<div className={cn(
|
<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 && (
|
{onRevert && (
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -193,7 +201,7 @@ const UserMessageBody: React.FC<{
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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"
|
aria-label="Revert to this message"
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -201,7 +209,7 @@ const UserMessageBody: React.FC<{
|
|||||||
onRevert();
|
onRevert();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiArrowGoBackLine className="h-3.5 w-3.5" />
|
<RiArrowGoBackLine className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
||||||
@@ -214,14 +222,14 @@ const UserMessageBody: React.FC<{
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onFork();
|
onFork();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiGitBranchLine className="h-3.5 w-3.5" />
|
<RiGitBranchLine className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
||||||
@@ -235,7 +243,7 @@ const UserMessageBody: React.FC<{
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
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"
|
aria-label="Copy message text"
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={handleCopyButtonClick}
|
onClick={handleCopyButtonClick}
|
||||||
@@ -247,15 +255,16 @@ const UserMessageBody: React.FC<{
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isMessageCopied ? (
|
{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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
|
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -872,7 +881,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
const showErrorMessage = Boolean(errorMessage);
|
const showErrorMessage = Boolean(errorMessage);
|
||||||
|
|
||||||
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
||||||
const [isSummaryHovered, setIsSummaryHovered] = React.useState(false);
|
|
||||||
|
|
||||||
const turnDurationText = React.useMemo(() => {
|
const turnDurationText = React.useMemo(() => {
|
||||||
if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
|
if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
|
||||||
@@ -884,6 +892,44 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
|
|
||||||
const footerButtons = (
|
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}>
|
<Tooltip delayDuration={1000}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -915,9 +961,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
|
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{showMessageTTSButtons && hasCopyableText && (
|
{showMessageTTSButtons && hasCopyableText && (
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -937,49 +983,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
|
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
@@ -1011,26 +1019,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<FadeInOnReveal key="summary-body">
|
<FadeInOnReveal key="summary-body">
|
||||||
<div
|
<div
|
||||||
className="group/assistant-text relative break-words"
|
className="group/assistant-text relative break-words"
|
||||||
onMouseEnter={() => setIsSummaryHovered(true)}
|
|
||||||
onMouseLeave={() => setIsSummaryHovered(false)}
|
|
||||||
>
|
>
|
||||||
<SimpleMarkdownRenderer content={summaryBody} />
|
<SimpleMarkdownRenderer content={summaryBody} />
|
||||||
{shouldShowFooter && (
|
{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 ? (
|
{turnDurationText ? (
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||||
<RiHourglassLine className="h-3.5 w-3.5" />
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
||||||
{turnDurationText}
|
{turnDurationText}
|
||||||
</span>
|
</span>
|
||||||
) : <span />}
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1039,16 +1040,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</div>
|
</div>
|
||||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||||
{!showSummaryBody && shouldShowFooter && (
|
{!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 ? (
|
{turnDurationText ? (
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||||
<RiHourglassLine className="h-3.5 w-3.5" />
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
||||||
{turnDurationText}
|
{turnDurationText}
|
||||||
</span>
|
</span>
|
||||||
) : <span />}
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||||
@@ -81,9 +82,285 @@ const getToolIcon = (toolName: string) => {
|
|||||||
return <RiToolsLine className={iconClass} />;
|
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 ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||||
|
|
||||||
|
if (popup.image) {
|
||||||
|
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -385,24 +662,6 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : 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 ? (
|
) : popup.content ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -19,20 +19,45 @@ const buildMentionUrl = (name: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||||
|
const CLAMP_LINES = 2;
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text;
|
const rawText = partWithText.text;
|
||||||
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||||
|
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
|
||||||
const textRef = React.useRef<HTMLDivElement>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
const el = textRef.current;
|
const el = textRef.current;
|
||||||
if (!el || isExpanded) return;
|
if (!el) return;
|
||||||
|
|
||||||
const checkTruncation = () => {
|
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();
|
checkTruncation();
|
||||||
@@ -43,11 +68,28 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
return () => resizeObserver.disconnect();
|
return () => resizeObserver.disconnect();
|
||||||
}, [textContent, isExpanded]);
|
}, [textContent, isExpanded]);
|
||||||
|
|
||||||
const handleClick = React.useCallback(() => {
|
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (isTruncated || isExpanded) {
|
const element = textRef.current;
|
||||||
setIsExpanded((prev) => !prev);
|
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) {
|
if (!textContent || textContent.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -79,17 +121,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
||||||
className={cn(
|
<div
|
||||||
"break-words whitespace-pre-wrap font-sans typography-markdown",
|
className={cn(
|
||||||
!isExpanded && "line-clamp-3",
|
"break-words whitespace-pre-wrap font-sans typography-markdown",
|
||||||
(isTruncated || isExpanded) && "cursor-pointer"
|
!isExpanded && "line-clamp-2",
|
||||||
)}
|
isTruncated && !isExpanded && "cursor-pointer"
|
||||||
ref={textRef}
|
)}
|
||||||
onClick={handleClick}
|
ref={textRef}
|
||||||
key={part.id || `${messageId}-user-text`}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,5 +20,12 @@ export interface ToolPopupContent {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
gallery?: Array<{
|
||||||
|
url: string;
|
||||||
|
mimeType?: string;
|
||||||
|
filename?: string;
|
||||||
|
size?: number;
|
||||||
|
}>;
|
||||||
|
index?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
@@ -157,15 +157,26 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<button
|
<div className="absolute right-2 top-2 z-30 inline-flex items-center gap-1">
|
||||||
type="button"
|
<button
|
||||||
onClick={toggleFullscreen}
|
type="button"
|
||||||
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"
|
onClick={toggleFullscreen}
|
||||||
title={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
|
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"
|
||||||
aria-label={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
|
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>
|
{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
|
<div
|
||||||
|
|||||||
@@ -882,19 +882,23 @@ export const Header: React.FC = () => {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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(
|
className={cn(
|
||||||
headerIconButtonClass,
|
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" />
|
<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>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Current instance: {currentInstanceLabel}</p>
|
<p>{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
|
|
||||||
const hasAppliedInitialSession = React.useRef(false);
|
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 [containerWidth, setContainerWidth] = React.useState<number>(0);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
|||||||
@@ -632,6 +632,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
|
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
|
||||||
const shareSession = useSessionStore((state) => state.shareSession);
|
const shareSession = useSessionStore((state) => state.shareSession);
|
||||||
@@ -882,8 +883,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowReselect && sessionId === currentSessionId) {
|
// Always return early if same session is selected to avoid unnecessary store operations
|
||||||
onSessionSelected?.(sessionId);
|
if (sessionId === currentSessionId) {
|
||||||
|
if (!allowReselect) {
|
||||||
|
onSessionSelected?.(sessionId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentSession(sessionId);
|
setCurrentSession(sessionId);
|
||||||
@@ -1458,6 +1462,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const previousActiveProjectRef = React.useRef<string | null>(null);
|
const previousActiveProjectRef = React.useRef<string | null>(null);
|
||||||
React.useLayoutEffect(() => {
|
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) {
|
if (!activeProjectId || previousActiveProjectRef.current === activeProjectId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1467,6 +1476,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
previousActiveProjectRef.current = activeProjectId;
|
previousActiveProjectRef.current = activeProjectId;
|
||||||
const projectMap = projectSessionMeta.metaByProject.get(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) {
|
if (!projectMap || projectMap.size === 0) {
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) {
|
if (mobileVariant) {
|
||||||
@@ -1492,6 +1516,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
activeSessionByProject,
|
activeSessionByProject,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
|
newSessionDraftOpen,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
openNewSessionDraft,
|
openNewSessionDraft,
|
||||||
projectSections,
|
projectSections,
|
||||||
@@ -1967,7 +1992,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderGroupSessions = React.useCallback(
|
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 isExpanded = expandedSessionGroups.has(groupKey);
|
||||||
const isCollapsed = collapsedGroups.has(groupKey);
|
const isCollapsed = collapsedGroups.has(groupKey);
|
||||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||||
@@ -1997,11 +2022,49 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
&& normalizedGroupDirectory === currentSessionDirectory,
|
&& 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 (
|
return (
|
||||||
<div className="oc-group">
|
<div className="oc-group">
|
||||||
<div
|
<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"
|
className={cn(
|
||||||
onClick={() => {
|
"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) => {
|
setCollapsedGroups((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(groupKey)) {
|
if (next.has(groupKey)) {
|
||||||
@@ -2011,10 +2074,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
} : undefined}
|
||||||
role="button"
|
role={!hideGroupLabel ? "button" : undefined}
|
||||||
tabIndex={0}
|
tabIndex={!hideGroupLabel ? 0 : undefined}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={!hideGroupLabel ? (event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setCollapsedGroups((prev) => {
|
setCollapsedGroups((prev) => {
|
||||||
@@ -2027,29 +2090,31 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
} : undefined}
|
||||||
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
|
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex items-center gap-1.5 px-0">
|
{!hideGroupLabel ? (
|
||||||
{isCollapsed ? (
|
<div className="min-w-0 flex items-center gap-1.5 px-0">
|
||||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
{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" />
|
) : (
|
||||||
)}
|
<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" />
|
{!group.isMain || isGitProject ? (
|
||||||
) : null}
|
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
<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}
|
) : 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>
|
) : <div />}
|
||||||
{group.directory ? (
|
{group.directory ? (
|
||||||
<div className="flex items-center gap-1 px-0.5">
|
<div className="flex items-center gap-1 px-0.5">
|
||||||
{!group.isMain && group.worktree ? (
|
{!group.isMain && group.worktree ? (
|
||||||
@@ -2404,7 +2469,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const groupKey = `${activeSection.project.id}:${group.id}`;
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ const variants = [
|
|||||||
{
|
{
|
||||||
variant: "shine",
|
variant: "shine",
|
||||||
component: ({ children, className, ...props }) => (
|
component: ({ children, className, ...props }) => (
|
||||||
<motion.span
|
<motion.span
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
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-[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",
|
"bg-[length:200%_100%] bg-clip-text text-transparent",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
initial={{ backgroundPosition: "200% 0" }}
|
initial={{ backgroundPosition: "200% 0" }}
|
||||||
animate={{ backgroundPosition: "-200% 0" }}
|
animate={{ backgroundPosition: "-200% 0" }}
|
||||||
transition={{
|
transition={{
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ interface SessionMemoryState {
|
|||||||
isZombie?: boolean;
|
isZombie?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface UseChatScrollManagerOptions {
|
interface UseChatScrollManagerOptions {
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
sessionMessages: ChatMessageRecord[];
|
sessionMessages: ChatMessageRecord[];
|
||||||
@@ -87,17 +86,11 @@ export const useChatScrollManager = ({
|
|||||||
const [isPinned, setIsPinned] = React.useState(true);
|
const [isPinned, setIsPinned] = React.useState(true);
|
||||||
|
|
||||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
|
|
||||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
||||||
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
||||||
const isPinnedRef = React.useRef(true);
|
const isPinnedRef = React.useRef(true);
|
||||||
const lastScrollTopRef = React.useRef<number>(0);
|
const lastScrollTopRef = React.useRef<number>(0);
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
currentSessionIdRef.current = currentSessionId ?? null;
|
|
||||||
}, [currentSessionId]);
|
|
||||||
|
|
||||||
const markProgrammaticScroll = React.useCallback(() => {
|
const markProgrammaticScroll = React.useCallback(() => {
|
||||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -330,8 +323,8 @@ export const useChatScrollManager = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
|
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||||
const existing = animationHandlersRef.current.get(messageId);
|
const existing = animationHandlersRef.current.get(messageId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
|
|||||||
@@ -478,56 +478,6 @@ class OpencodeService {
|
|||||||
return lowerMime === 'image/heic' || lowerMime === 'image/heif';
|
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.
|
* Convert HEIC image to JPEG.
|
||||||
* Returns the original file if conversion fails.
|
* Returns the original file if conversion fails.
|
||||||
@@ -592,23 +542,6 @@ class OpencodeService {
|
|||||||
return this.convertHeicToJpeg(file);
|
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
|
// Handle text MIME normalization
|
||||||
if (!this.shouldNormalizeToTextPlain(file.mime)) {
|
if (!this.shouldNormalizeToTextPlain(file.mime)) {
|
||||||
return file;
|
return file;
|
||||||
@@ -647,6 +580,7 @@ class OpencodeService {
|
|||||||
agent?: string;
|
agent?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
|
id?: string;
|
||||||
type: 'file';
|
type: 'file';
|
||||||
mime: string;
|
mime: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
@@ -657,6 +591,7 @@ class OpencodeService {
|
|||||||
text: string;
|
text: string;
|
||||||
synthetic?: boolean;
|
synthetic?: boolean;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
|
id?: string;
|
||||||
type: 'file';
|
type: 'file';
|
||||||
mime: string;
|
mime: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
@@ -696,6 +631,7 @@ class OpencodeService {
|
|||||||
for (const file of params.files) {
|
for (const file of params.files) {
|
||||||
const normalized = await this.normalizeFilePart(file);
|
const normalized = await this.normalizeFilePart(file);
|
||||||
const filePart: FilePartInput = {
|
const filePart: FilePartInput = {
|
||||||
|
...(file.id ? { id: file.id } : {}),
|
||||||
type: 'file',
|
type: 'file',
|
||||||
mime: normalized.mime,
|
mime: normalized.mime,
|
||||||
filename: normalized.filename,
|
filename: normalized.filename,
|
||||||
@@ -719,6 +655,7 @@ class OpencodeService {
|
|||||||
for (const file of additional.files) {
|
for (const file of additional.files) {
|
||||||
const normalized = await this.normalizeFilePart(file);
|
const normalized = await this.normalizeFilePart(file);
|
||||||
const filePart: FilePartInput = {
|
const filePart: FilePartInput = {
|
||||||
|
...(file.id ? { id: file.id } : {}),
|
||||||
type: 'file',
|
type: 'file',
|
||||||
mime: normalized.mime,
|
mime: normalized.mime,
|
||||||
filename: normalized.filename,
|
filename: normalized.filename,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||||
import { opencodeClient } from "@/lib/opencode/client";
|
|
||||||
import type { AttachedFile } from "./types/sessionTypes";
|
import type { AttachedFile } from "./types/sessionTypes";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ const guessMimeTypeFromName = (filename: string): string => {
|
|||||||
case "pdf":
|
case "pdf":
|
||||||
return "application/pdf";
|
return "application/pdf";
|
||||||
default:
|
default:
|
||||||
return "text/plain";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,29 +90,30 @@ const guessMimeType = (file: File): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64ByteLength = (base64: string): number => {
|
const normalizeServerPath = (inputPath: string): string => inputPath.replace(/\\/g, "/").trim();
|
||||||
const cleaned = base64.replace(/\s+/g, "");
|
|
||||||
if (!cleaned) {
|
const toFileUrl = (inputPath: string): string => {
|
||||||
return 0;
|
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 readRawFileAsDataUrl = async (absolutePath: string): Promise<string> => {
|
||||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(absolutePath)}`);
|
||||||
let output = "";
|
if (!response.ok) {
|
||||||
for (let i = 0; i < bytes.length; i += 3) {
|
throw new Error(`Failed to read raw file: ${response.status}`);
|
||||||
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] : "=";
|
|
||||||
}
|
}
|
||||||
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>()(
|
export const useFileStore = create<FileStore>()(
|
||||||
@@ -196,66 +196,33 @@ export const useFileStore = create<FileStore>()(
|
|||||||
|
|
||||||
addServerFile: async (path: string, name: string, content?: string) => {
|
addServerFile: async (path: string, name: string, content?: string) => {
|
||||||
|
|
||||||
|
const normalizedPath = normalizeServerPath(path);
|
||||||
const { attachedFiles } = get();
|
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) {
|
if (isDuplicate) {
|
||||||
console.log(`Server file "${name}" is already attached`);
|
console.log(`Server file "${name}" is already attached`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileContent = content;
|
const inferredMime = guessMimeTypeFromName(name);
|
||||||
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 safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream";
|
const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream";
|
||||||
|
|
||||||
const base64 = (() => {
|
const shouldInlineBinary = safeMimeType !== "text/plain" && safeMimeType !== "application/x-directory";
|
||||||
if (encoding === "base64") {
|
|
||||||
return fileContent || "";
|
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 file = new File([], name, { type: safeMimeType });
|
||||||
const dataUrl = `data:${safeMimeType};base64,${base64}`;
|
|
||||||
|
|
||||||
const attachedFile: AttachedFile = {
|
const attachedFile: AttachedFile = {
|
||||||
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
@@ -265,7 +232,7 @@ export const useFileStore = create<FileStore>()(
|
|||||||
filename: name,
|
filename: name,
|
||||||
size: sizeBytes,
|
size: sizeBytes,
|
||||||
source: "server",
|
source: "server",
|
||||||
serverPath: path,
|
serverPath: normalizedPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -286,6 +253,12 @@ export const useFileStore = create<FileStore>()(
|
|||||||
{
|
{
|
||||||
name: "file-store",
|
name: "file-store",
|
||||||
storage: createJSONStorage(() => getSafeStorage()),
|
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) => ({
|
partialize: (state) => ({
|
||||||
attachedFiles: state.attachedFiles,
|
attachedFiles: state.attachedFiles,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ const computePartsTextLength = (parts: Part[] | undefined): number => {
|
|||||||
}, 0);
|
}, 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 => {
|
const hasFinishStop = (info: { finish?: string } | undefined): boolean => {
|
||||||
return info?.finish === "stop";
|
return info?.finish === "stop";
|
||||||
};
|
};
|
||||||
@@ -663,7 +672,12 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: file.mimeType,
|
mime: file.mimeType,
|
||||||
filename: file.filename,
|
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) => {
|
set((state) => {
|
||||||
@@ -687,7 +701,12 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: file.mimeType,
|
mime: file.mimeType,
|
||||||
filename: file.filename,
|
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;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
disableStorage();
|
disableStorage();
|
||||||
|
// Prevent stale previous value from surviving when writes fail (e.g. quota).
|
||||||
|
try {
|
||||||
|
baseStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallback.setItem(key, value);
|
fallback.setItem(key, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeRemove = (key: string) => {
|
const safeRemove = (key: string) => {
|
||||||
if (storageAvailable) {
|
try {
|
||||||
try {
|
baseStorage.removeItem(key);
|
||||||
baseStorage.removeItem(key);
|
} catch {
|
||||||
} catch {
|
disableStorage();
|
||||||
disableStorage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fallback.removeItem(key);
|
fallback.removeItem(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeClear = () => {
|
const safeClear = () => {
|
||||||
if (storageAvailable) {
|
try {
|
||||||
try {
|
baseStorage.clear();
|
||||||
baseStorage.clear();
|
} catch {
|
||||||
} catch {
|
disableStorage();
|
||||||
disableStorage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fallback.clear();
|
fallback.clear();
|
||||||
};
|
};
|
||||||
@@ -155,29 +157,31 @@ const createSafeSessionStorage = (): Storage => {
|
|||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
disableStorage();
|
disableStorage();
|
||||||
|
// Prevent stale previous value from surviving when writes fail (e.g. quota).
|
||||||
|
try {
|
||||||
|
baseStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallback.setItem(key, value);
|
fallback.setItem(key, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeRemove = (key: string) => {
|
const safeRemove = (key: string) => {
|
||||||
if (storageAvailable) {
|
try {
|
||||||
try {
|
baseStorage.removeItem(key);
|
||||||
baseStorage.removeItem(key);
|
} catch {
|
||||||
} catch {
|
disableStorage();
|
||||||
disableStorage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fallback.removeItem(key);
|
fallback.removeItem(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeClear = () => {
|
const safeClear = () => {
|
||||||
if (storageAvailable) {
|
try {
|
||||||
try {
|
baseStorage.clear();
|
||||||
baseStorage.clear();
|
} catch {
|
||||||
} catch {
|
disableStorage();
|
||||||
disableStorage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fallback.clear();
|
fallback.clear();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ type ApiProxyRequestPayload = {
|
|||||||
bodyBase64?: string;
|
bodyBase64?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiSessionMessageRequestPayload = {
|
||||||
|
path?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiProxyResponsePayload = {
|
type ApiProxyResponsePayload = {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -760,6 +766,23 @@ const collectHeaders = (headers: Headers): Record<string, string> => {
|
|||||||
return result;
|
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> {
|
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||||
const { id, type, payload } = message;
|
const { id, type, payload } = message;
|
||||||
|
|
||||||
@@ -768,12 +791,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
case 'api:proxy': {
|
case 'api:proxy': {
|
||||||
const apiUrl = ctx?.manager?.getApiUrl();
|
const apiUrl = ctx?.manager?.getApiUrl();
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
|
const data = buildUnavailableApiResponse();
|
||||||
const data: ApiProxyResponsePayload = {
|
|
||||||
status: 503,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
bodyBase64: base64EncodeUtf8(body),
|
|
||||||
};
|
|
||||||
return { id, type, success: true, data };
|
return { id, type, success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +806,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
|
|
||||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
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.
|
// Ensure SSE requests are negotiated correctly.
|
||||||
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
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': {
|
case 'files:list': {
|
||||||
const { path: dirPath } = payload as { path: string };
|
const { path: dirPath } = payload as { path: string };
|
||||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
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 });
|
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 = {
|
export type ProxiedSseStartResponse = {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createVSCodeAPIs } from './api';
|
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 type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||||
import {
|
import {
|
||||||
buildVSCodeThemeFromPalette,
|
buildVSCodeThemeFromPalette,
|
||||||
@@ -343,7 +343,35 @@ const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | u
|
|||||||
return undefined;
|
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 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 handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||||
const pathname = url.pathname;
|
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' } });
|
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 bodyBase64 = await extractBodyBase64(input, init, method);
|
||||||
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
||||||
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
||||||
|
|||||||
+328
-53
@@ -1,5 +1,4 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn, spawnSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -4630,6 +4629,157 @@ function setupProxy(app) {
|
|||||||
next();
|
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) => {
|
app.use('/api', (_req, _res, next) => {
|
||||||
ensureOpenCodeApiPrefix();
|
ensureOpenCodeApiPrefix();
|
||||||
next();
|
next();
|
||||||
@@ -4651,66 +4801,162 @@ function setupProxy(app) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const proxyMiddleware = createProxyMiddleware({
|
const hopByHopRequestHeaders = new Set([
|
||||||
target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0',
|
'host',
|
||||||
router: () => {
|
'connection',
|
||||||
if (!openCodePort) {
|
'content-length',
|
||||||
return 'http://127.0.0.1:0';
|
'transfer-encoding',
|
||||||
}
|
'keep-alive',
|
||||||
return `http://localhost:${openCodePort}`;
|
'te',
|
||||||
},
|
'trailer',
|
||||||
changeOrigin: true,
|
'upgrade',
|
||||||
pathRewrite: (path) => {
|
]);
|
||||||
if (!path.startsWith('/api')) {
|
|
||||||
return path;
|
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;
|
// Dedicated forwarder for large session message payloads.
|
||||||
},
|
// This avoids edge-cases in generic proxy streaming for multi-file attachments.
|
||||||
ws: false,
|
app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
|
||||||
// v3.x API: callbacks go in 'on' object
|
try {
|
||||||
on: {
|
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||||
error: (err, req, res) => {
|
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||||
console.error(`Proxy error: ${err.message}`);
|
const authHeaders = getOpenCodeAuthHeaders();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
|
const headers = {
|
||||||
proxyReq.setHeader('Accept', 'text/event-stream');
|
...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }),
|
||||||
proxyReq.setHeader('Cache-Control', 'no-cache');
|
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}),
|
||||||
proxyReq.setHeader('Connection', 'keep-alive');
|
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
||||||
}
|
};
|
||||||
},
|
|
||||||
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'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url?.includes('/event')) {
|
const bodyBuffer = Buffer.isBuffer(req.body)
|
||||||
proxyRes.headers['Access-Control-Allow-Origin'] = '*';
|
? req.body
|
||||||
proxyRes.headers['Access-Control-Allow-Headers'] = 'Cache-Control, Accept';
|
: Buffer.from(typeof req.body === 'string' ? req.body : '');
|
||||||
proxyRes.headers['Content-Type'] = 'text/event-stream';
|
|
||||||
proxyRes.headers['Cache-Control'] = 'no-cache';
|
const upstreamResponse = await fetch(targetUrl, {
|
||||||
proxyRes.headers['Connection'] = 'keep-alive';
|
method: 'POST',
|
||||||
proxyRes.headers['X-Accel-Buffering'] = 'no';
|
headers,
|
||||||
proxyRes.headers['X-Content-Type-Options'] = 'nosniff';
|
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() {
|
function startHealthMonitoring() {
|
||||||
@@ -8478,7 +8724,36 @@ async function main(options = {}) {
|
|||||||
})
|
})
|
||||||
.join('\n\n');
|
.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';
|
const model = 'gpt-5-nano';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user