diff --git a/ui/src/components/chat/MarkdownRenderer.tsx b/ui/src/components/chat/MarkdownRenderer.tsx index cbc624a..dbd1026 100644 --- a/ui/src/components/chat/MarkdownRenderer.tsx +++ b/ui/src/components/chat/MarkdownRenderer.tsx @@ -969,12 +969,14 @@ const useFileReferenceInteractions = ({ readFile, editor, preferRuntimeEditor, + skipValidation = false, }: { containerRef: React.RefObject; effectiveDirectory: string; readFile?: (path: string) => Promise<{ content: string; path: string }>; editor?: EditorAPI; preferRuntimeEditor?: boolean; + skipValidation?: boolean; }) => { const validationCacheRef = React.useRef>(new Map()); const inFlightValidationsRef = React.useRef>>(new Map()); @@ -1001,14 +1003,26 @@ const useFileReferenceInteractions = ({ return inFlight; } + const silentReadFile = async (path: string): Promise => { + if (!readFile) { + return false; + } + try { + await readFile(path); + return true; + } catch { + return false; + } + }; + const checkPromise = (async () => { try { if (!readFile) { return false; } - await readFile(resolvedPath); - cache.set(resolvedPath, true); - return true; + const exists = await silentReadFile(resolvedPath); + cache.set(resolvedPath, exists); + return exists; } catch { cache.set(resolvedPath, false); return false; @@ -1193,9 +1207,14 @@ const useFileReferenceInteractions = ({ void openFileReference(target); }; - void annotateFileLinks(); + if (!skipValidation) { + void annotateFileLinks(); + } const observer = new MutationObserver(() => { + if (skipValidation) { + return; + } if (annotationDebounceRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(annotationDebounceRef.current); } @@ -1224,7 +1243,7 @@ const useFileReferenceInteractions = ({ container.removeEventListener('click', handleClick); container.removeEventListener('keydown', handleKeyDown); }; - }, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]); + }, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile, skipValidation]); }; const useMermaidInlineInteractions = ({ @@ -1341,6 +1360,7 @@ export const MarkdownRenderer: React.FC = ({ readFile: files.readFile, editor, preferRuntimeEditor: runtime.isVSCode, + skipValidation: !isStreaming, }); const shikiThemes = useMarkdownShikiThemes(); @@ -1417,6 +1437,7 @@ export const SimpleMarkdownRenderer: React.FC<{ readFile: files.readFile, editor, preferRuntimeEditor: runtime.isVSCode, + skipValidation: true, }); const shikiThemes = useMarkdownShikiThemes(); diff --git a/ui/src/components/layout/SidebarFilesTree.tsx b/ui/src/components/layout/SidebarFilesTree.tsx index 4e04105..f6ac50b 100644 --- a/ui/src/components/layout/SidebarFilesTree.tsx +++ b/ui/src/components/layout/SidebarFilesTree.tsx @@ -536,7 +536,7 @@ export const SidebarFilesTree: React.FC = () => { const handleOpenFile = React.useCallback(async (node: FileNode) => { if (!root) return; - const openValidation = await validateContextFileOpen(files, node.path); + const openValidation = await validateContextFileOpen(files, node.path, root); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; diff --git a/ui/src/components/views/DiffView.tsx b/ui/src/components/views/DiffView.tsx index 813f296..8775117 100644 --- a/ui/src/components/views/DiffView.tsx +++ b/ui/src/components/views/DiffView.tsx @@ -1410,7 +1410,7 @@ export const DiffView: React.FC = ({ : getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified)); const absolutePath = toAbsolutePath(effectiveDirectory, filePath); - const openValidation = await validateContextFileOpen(files, absolutePath); + const openValidation = await validateContextFileOpen(files, absolutePath, effectiveDirectory ?? undefined); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; diff --git a/ui/src/components/views/FilesView.tsx b/ui/src/components/views/FilesView.tsx index 826623a..fd5b467 100644 --- a/ui/src/components/views/FilesView.tsx +++ b/ui/src/components/views/FilesView.tsx @@ -1080,13 +1080,16 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return result.content ?? ''; } - const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); + const directory = currentDirectory || undefined; + const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`, { + headers: directory ? { 'x-opencode-directory': directory } : undefined, + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error((error as { error?: string }).error || 'Failed to read file'); } return response.text(); - }, [files]); + }, [files, currentDirectory]); const displayedContent = React.useMemo(() => { return fileContent.length > MAX_VIEW_CHARS @@ -1929,7 +1932,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { : desktopImageSrc) : (isSelectedSvg ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` - : `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`)) + : `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}&directory=${encodeURIComponent(currentDirectory || '')}`)) : ''; diff --git a/ui/src/components/views/PlanView.tsx b/ui/src/components/views/PlanView.tsx index 29799f3..24f5be6 100644 --- a/ui/src/components/views/PlanView.tsx +++ b/ui/src/components/views/PlanView.tsx @@ -263,7 +263,9 @@ export const PlanView: React.FC = () => { return result?.content ?? ''; } - const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); + const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`, { + headers: sessionDirectory ? { 'x-opencode-directory': sessionDirectory } : undefined, + }); if (!response.ok) { throw new Error(`Failed to read plan file (${response.status})`); } diff --git a/ui/src/lib/contextFileOpenGuard.ts b/ui/src/lib/contextFileOpenGuard.ts index 315b679..ef8096f 100644 --- a/ui/src/lib/contextFileOpenGuard.ts +++ b/ui/src/lib/contextFileOpenGuard.ts @@ -24,13 +24,14 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { return 'unreadable'; }; -const readFileContent = async (files: FilesAPI, path: string): Promise => { +const readFileContent = async (files: FilesAPI, path: string, directory?: string): Promise => { if (files.readFile) { const result = await files.readFile(path); return result.content ?? ''; } - const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); + const headers = directory ? { 'x-opencode-directory': directory } : undefined; + const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`, { headers }); if (!response.ok) { const errorPayload = await response.json().catch(() => ({ error: response.statusText })); throw new Error((errorPayload as { error?: string }).error || 'Failed to read file'); @@ -39,9 +40,9 @@ const readFileContent = async (files: FilesAPI, path: string): Promise = return response.text(); }; -export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise => { +export const validateContextFileOpen = async (files: FilesAPI, path: string, directory?: string): Promise => { try { - const content = await readFileContent(files, path); + const content = await readFileContent(files, path, directory); const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES); if (lineCount > MAX_OPEN_FILE_LINES) { return { ok: false, reason: 'too-large' }; diff --git a/ui/src/lib/opencode/client.ts b/ui/src/lib/opencode/client.ts index a686303..37fccac 100644 --- a/ui/src/lib/opencode/client.ts +++ b/ui/src/lib/opencode/client.ts @@ -1421,7 +1421,7 @@ class OpencodeService { return; } const elapsed = Date.now() - this.globalSseLastFlushAt; - const delay = Math.max(0, 16 - elapsed); + const delay = 8; this.globalSseFlushTimer = setTimeout(this.flushGlobalSseQueue, delay); } @@ -1516,7 +1516,12 @@ class OpencodeService { if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { return; } - console.error('[OpencodeClient] Global SSE stream error (will retry):', error); + const errorMsg = error instanceof Error ? error.message : String(error); + const isNetworkError = errorMsg.includes('network error') || (error as any)?.name === 'TypeError'; + console.error('[OpencodeClient] Global SSE stream error (will retry):', error, 'isNetworkError:', isNetworkError); + if (isNetworkError) { + console.error('[OpencodeClient] Network error detected - OpenCode server may have crashed or disconnected!'); + } this.notifyGlobalSseError(error); } diff --git a/ui/src/stores/messageStore.ts b/ui/src/stores/messageStore.ts index b34aa0e..88a56b0 100644 --- a/ui/src/stores/messageStore.ts +++ b/ui/src/stores/messageStore.ts @@ -51,7 +51,7 @@ const streamingPartQueue: QueuedStreamingPart[] = []; let streamingFlushScheduled = false; let streamingFlushRafId: number | null = null; let streamingFlushTimeoutId: ReturnType | null = null; -const STREAMING_FLUSH_TIMEOUT_MS = 50; +const STREAMING_FLUSH_TIMEOUT_MS = 8; const STREAMING_QUEUE_HARD_LIMIT = 3000; type StreamingPartImmediateHandler = ( diff --git a/ui/src/stores/types/sessionTypes.ts b/ui/src/stores/types/sessionTypes.ts index 1283366..9c98526 100644 --- a/ui/src/stores/types/sessionTypes.ts +++ b/ui/src/stores/types/sessionTypes.ts @@ -57,7 +57,7 @@ export interface SessionContextUsage { export const DEFAULT_MESSAGE_LIMIT = 200; /** Timeout after which a session stuck in 'busy' or 'retry' with no SSE events is force-reset to idle. */ -export const STUCK_SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +export const STUCK_SESSION_TIMEOUT_MS = 25 * 60 * 1000; // 25 minutes export const MEMORY_CONSTANTS = { MAX_SESSIONS: 3, diff --git a/web/XCOpenCodeWeb.exe b/web/XCOpenCodeWeb.exe index a482869..1bd30e7 100644 Binary files a/web/XCOpenCodeWeb.exe and b/web/XCOpenCodeWeb.exe differ diff --git a/web/server/index.js b/web/server/index.js index 15a153a..61b53f6 100644 --- a/web/server/index.js +++ b/web/server/index.js @@ -5825,7 +5825,9 @@ function setupProxy(app) { const requestHeaders = { ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }), - 'cache-control': 'no-cache', + 'cache-control': 'no-cache, no-store, must-revalidate', + 'pragma': 'no-cache', + 'expires': '0', connection: 'keep-alive', ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), }; @@ -5859,7 +5861,7 @@ function setupProxy(app) { idleTimer = setTimeout(() => { endedBy = 'idle-timeout'; controller.abort(); - }, 5 * 60 * 1000); + }, 30 * 60 * 1000); // 30 minutes }; const onClientClose = () => { @@ -5901,7 +5903,9 @@ function setupProxy(app) { 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('cache-control', 'no-cache, no-store, must-revalidate'); + res.setHeader('pragma', 'no-cache'); + res.setHeader('expires', '0'); res.setHeader('connection', 'keep-alive'); res.setHeader('x-accel-buffering', 'no'); res.setHeader('x-content-type-options', 'nosniff');