fix: various UI and server improvements

This commit is contained in:
2026-03-18 13:25:46 +08:00
parent 3c41503827
commit 9300aff26f
11 changed files with 58 additions and 22 deletions

View File

@@ -969,12 +969,14 @@ const useFileReferenceInteractions = ({
readFile,
editor,
preferRuntimeEditor,
skipValidation = false,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
effectiveDirectory: string;
readFile?: (path: string) => Promise<{ content: string; path: string }>;
editor?: EditorAPI;
preferRuntimeEditor?: boolean;
skipValidation?: boolean;
}) => {
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
@@ -1001,14 +1003,26 @@ const useFileReferenceInteractions = ({
return inFlight;
}
const silentReadFile = async (path: string): Promise<boolean> => {
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);
};
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<MarkdownRendererProps> = ({
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();

View File

@@ -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;

View File

@@ -1410,7 +1410,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
: 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;

View File

@@ -1080,13 +1080,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ 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 || '')}`))
: '';

View File

@@ -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})`);
}

View File

@@ -24,13 +24,14 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
return 'unreadable';
};
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
const readFileContent = async (files: FilesAPI, path: string, directory?: string): Promise<string> => {
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<string> =
return response.text();
};
export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise<ContextFileOpenValidationResult> => {
export const validateContextFileOpen = async (files: FilesAPI, path: string, directory?: string): Promise<ContextFileOpenValidationResult> => {
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' };

View File

@@ -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);
}

View File

@@ -51,7 +51,7 @@ const streamingPartQueue: QueuedStreamingPart[] = [];
let streamingFlushScheduled = false;
let streamingFlushRafId: number | null = null;
let streamingFlushTimeoutId: ReturnType<typeof setTimeout> | null = null;
const STREAMING_FLUSH_TIMEOUT_MS = 50;
const STREAMING_FLUSH_TIMEOUT_MS = 8;
const STREAMING_QUEUE_HARD_LIMIT = 3000;
type StreamingPartImmediateHandler = (

View File

@@ -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,

Binary file not shown.

View File

@@ -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');