fix: various UI and server improvements
This commit is contained in:
@@ -969,12 +969,14 @@ const useFileReferenceInteractions = ({
|
|||||||
readFile,
|
readFile,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor,
|
preferRuntimeEditor,
|
||||||
|
skipValidation = false,
|
||||||
}: {
|
}: {
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
effectiveDirectory: string;
|
effectiveDirectory: string;
|
||||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
||||||
editor?: EditorAPI;
|
editor?: EditorAPI;
|
||||||
preferRuntimeEditor?: boolean;
|
preferRuntimeEditor?: boolean;
|
||||||
|
skipValidation?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
||||||
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
||||||
@@ -1001,14 +1003,26 @@ const useFileReferenceInteractions = ({
|
|||||||
return inFlight;
|
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 () => {
|
const checkPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (!readFile) {
|
if (!readFile) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await readFile(resolvedPath);
|
const exists = await silentReadFile(resolvedPath);
|
||||||
cache.set(resolvedPath, true);
|
cache.set(resolvedPath, exists);
|
||||||
return true;
|
return exists;
|
||||||
} catch {
|
} catch {
|
||||||
cache.set(resolvedPath, false);
|
cache.set(resolvedPath, false);
|
||||||
return false;
|
return false;
|
||||||
@@ -1193,9 +1207,14 @@ const useFileReferenceInteractions = ({
|
|||||||
void openFileReference(target);
|
void openFileReference(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
void annotateFileLinks();
|
if (!skipValidation) {
|
||||||
|
void annotateFileLinks();
|
||||||
|
}
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
|
if (skipValidation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
||||||
window.clearTimeout(annotationDebounceRef.current);
|
window.clearTimeout(annotationDebounceRef.current);
|
||||||
}
|
}
|
||||||
@@ -1224,7 +1243,7 @@ const useFileReferenceInteractions = ({
|
|||||||
container.removeEventListener('click', handleClick);
|
container.removeEventListener('click', handleClick);
|
||||||
container.removeEventListener('keydown', handleKeyDown);
|
container.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile, skipValidation]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useMermaidInlineInteractions = ({
|
const useMermaidInlineInteractions = ({
|
||||||
@@ -1341,6 +1360,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
readFile: files.readFile,
|
readFile: files.readFile,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor: runtime.isVSCode,
|
preferRuntimeEditor: runtime.isVSCode,
|
||||||
|
skipValidation: !isStreaming,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
@@ -1417,6 +1437,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
readFile: files.readFile,
|
readFile: files.readFile,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor: runtime.isVSCode,
|
preferRuntimeEditor: runtime.isVSCode,
|
||||||
|
skipValidation: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
const handleOpenFile = React.useCallback(async (node: FileNode) => {
|
const handleOpenFile = React.useCallback(async (node: FileNode) => {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
const openValidation = await validateContextFileOpen(files, node.path);
|
const openValidation = await validateContextFileOpen(files, node.path, root);
|
||||||
if (!openValidation.ok) {
|
if (!openValidation.ok) {
|
||||||
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1410,7 +1410,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
|
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
|
||||||
|
|
||||||
const absolutePath = toAbsolutePath(effectiveDirectory, filePath);
|
const absolutePath = toAbsolutePath(effectiveDirectory, filePath);
|
||||||
const openValidation = await validateContextFileOpen(files, absolutePath);
|
const openValidation = await validateContextFileOpen(files, absolutePath, effectiveDirectory ?? undefined);
|
||||||
if (!openValidation.ok) {
|
if (!openValidation.ok) {
|
||||||
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1080,13 +1080,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return result.content ?? '';
|
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) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error((error as { error?: string }).error || 'Failed to read file');
|
throw new Error((error as { error?: string }).error || 'Failed to read file');
|
||||||
}
|
}
|
||||||
return response.text();
|
return response.text();
|
||||||
}, [files]);
|
}, [files, currentDirectory]);
|
||||||
|
|
||||||
const displayedContent = React.useMemo(() => {
|
const displayedContent = React.useMemo(() => {
|
||||||
return fileContent.length > MAX_VIEW_CHARS
|
return fileContent.length > MAX_VIEW_CHARS
|
||||||
@@ -1929,7 +1932,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
: desktopImageSrc)
|
: desktopImageSrc)
|
||||||
: (isSelectedSvg
|
: (isSelectedSvg
|
||||||
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
|
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
|
||||||
: `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`))
|
: `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}&directory=${encodeURIComponent(currentDirectory || '')}`))
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,9 @@ export const PlanView: React.FC = () => {
|
|||||||
return result?.content ?? '';
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to read plan file (${response.status})`);
|
throw new Error(`Failed to read plan file (${response.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
|||||||
return 'unreadable';
|
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) {
|
if (files.readFile) {
|
||||||
const result = await files.readFile(path);
|
const result = await files.readFile(path);
|
||||||
return result.content ?? '';
|
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) {
|
if (!response.ok) {
|
||||||
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error((errorPayload as { error?: string }).error || 'Failed to read file');
|
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();
|
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 {
|
try {
|
||||||
const content = await readFileContent(files, path);
|
const content = await readFileContent(files, path, directory);
|
||||||
const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES);
|
const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES);
|
||||||
if (lineCount > MAX_OPEN_FILE_LINES) {
|
if (lineCount > MAX_OPEN_FILE_LINES) {
|
||||||
return { ok: false, reason: 'too-large' };
|
return { ok: false, reason: 'too-large' };
|
||||||
|
|||||||
@@ -1421,7 +1421,7 @@ class OpencodeService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elapsed = Date.now() - this.globalSseLastFlushAt;
|
const elapsed = Date.now() - this.globalSseLastFlushAt;
|
||||||
const delay = Math.max(0, 16 - elapsed);
|
const delay = 8;
|
||||||
this.globalSseFlushTimer = setTimeout(this.flushGlobalSseQueue, delay);
|
this.globalSseFlushTimer = setTimeout(this.flushGlobalSseQueue, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,7 +1516,12 @@ class OpencodeService {
|
|||||||
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||||
return;
|
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);
|
this.notifyGlobalSseError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const streamingPartQueue: QueuedStreamingPart[] = [];
|
|||||||
let streamingFlushScheduled = false;
|
let streamingFlushScheduled = false;
|
||||||
let streamingFlushRafId: number | null = null;
|
let streamingFlushRafId: number | null = null;
|
||||||
let streamingFlushTimeoutId: ReturnType<typeof setTimeout> | 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;
|
const STREAMING_QUEUE_HARD_LIMIT = 3000;
|
||||||
|
|
||||||
type StreamingPartImmediateHandler = (
|
type StreamingPartImmediateHandler = (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface SessionContextUsage {
|
|||||||
export const DEFAULT_MESSAGE_LIMIT = 200;
|
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. */
|
/** 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 = {
|
export const MEMORY_CONSTANTS = {
|
||||||
MAX_SESSIONS: 3,
|
MAX_SESSIONS: 3,
|
||||||
|
|||||||
Binary file not shown.
@@ -5825,7 +5825,9 @@ function setupProxy(app) {
|
|||||||
|
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }),
|
...(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',
|
connection: 'keep-alive',
|
||||||
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
||||||
};
|
};
|
||||||
@@ -5859,7 +5861,7 @@ function setupProxy(app) {
|
|||||||
idleTimer = setTimeout(() => {
|
idleTimer = setTimeout(() => {
|
||||||
endedBy = 'idle-timeout';
|
endedBy = 'idle-timeout';
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, 5 * 60 * 1000);
|
}, 30 * 60 * 1000); // 30 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClientClose = () => {
|
const onClientClose = () => {
|
||||||
@@ -5901,7 +5903,9 @@ function setupProxy(app) {
|
|||||||
const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream';
|
const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream';
|
||||||
res.status(upstreamResponse.status);
|
res.status(upstreamResponse.status);
|
||||||
res.setHeader('content-type', upstreamContentType);
|
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('connection', 'keep-alive');
|
||||||
res.setHeader('x-accel-buffering', 'no');
|
res.setHeader('x-accel-buffering', 'no');
|
||||||
res.setHeader('x-content-type-options', 'nosniff');
|
res.setHeader('x-content-type-options', 'nosniff');
|
||||||
|
|||||||
Reference in New Issue
Block a user