Files
openchamber/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts
T
YifanandBohdan Triapitsyn bee9d19f3a feat(web): add WebSocket transport for message event streaming with SSE fallback (#764)
* feat: add websocket message stream transport

* fix: avoid false missing session directories in sidebar

* fix: re-probe project root session directories

* refactor: use button group for message stream transport

* fix: resolve chat input hook dependency warning

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-04-17 11:07:26 +03:00

188 lines
5.5 KiB
TypeScript

import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { mapWithConcurrency } from '@/lib/concurrency';
import { normalizePath } from '../utils';
type ProjectLike = { path: string };
type DirectoryStatusValue = 'unknown' | 'exists' | 'missing';
type Args = {
sortedSessions: Session[];
projects: ProjectLike[];
directoryStatus: Map<string, DirectoryStatusValue>;
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, DirectoryStatusValue>>>;
};
const PROBE_CONCURRENCY = 3;
const MISSING_CACHE_KEY = 'oc.directoryProbe.missing';
// Re-probe missing directories periodically in case they're recreated
const MISSING_REPROBE_MS = 10 * 60 * 1000; // 10 minutes
type MissingCache = Record<string, number>; // directory -> timestamp
function loadMissingCache(): MissingCache {
try {
const raw = localStorage.getItem(MISSING_CACHE_KEY);
if (!raw) return {};
return JSON.parse(raw) as MissingCache;
} catch {
return {};
}
}
function saveMissingCache(cache: MissingCache): void {
try {
localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(cache));
} catch {
// ignore quota errors
}
}
async function probeDirectory(directory: string): Promise<DirectoryStatusValue> {
try {
await opencodeClient.listLocalDirectory(directory);
return 'exists';
} catch (error) {
const looksLikeSdkWorktree =
directory.includes('/opencode/worktree/') ||
directory.includes('/.opencode/data/worktree/') ||
directory.includes('/.local/share/opencode/worktree/');
const reachable = await opencodeClient.probeDirectory(directory).catch(() => false);
if (reachable) {
return 'exists';
}
const message = error instanceof Error ? error.message.toLowerCase() : '';
const definitelyMissing =
message.includes('enoent') ||
message.includes('not found') ||
message.includes('does not exist') ||
message.includes('no such file');
if (definitelyMissing || looksLikeSdkWorktree) {
return 'missing';
}
return 'unknown';
}
}
export const useDirectoryStatusProbe = ({
sortedSessions,
projects,
directoryStatus,
setDirectoryStatus,
}: Args): void => {
const directoryStatusRef = React.useRef<Map<string, DirectoryStatusValue>>(new Map());
const probeInFlightRef = React.useRef(false);
const missingCacheRef = React.useRef<MissingCache>(loadMissingCache());
React.useEffect(() => {
directoryStatusRef.current = directoryStatus;
}, [directoryStatus]);
React.useEffect(() => {
const directories = new Set<string>();
const normalizedProjectRoots = new Set<string>();
sortedSessions.forEach((session) => {
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (dir) directories.add(dir);
});
projects.forEach((project) => {
const normalized = normalizePath(project.path);
if (normalized) {
directories.add(normalized);
normalizedProjectRoots.add(normalized);
}
});
const now = Date.now();
const missingCache = missingCacheRef.current;
const toProbe: string[] = [];
const preseeded = new Map<string, DirectoryStatusValue>();
for (const directory of directories) {
const isProjectRoot = normalizedProjectRoots.has(directory);
const known = directoryStatusRef.current.get(directory);
if (known === 'exists') continue;
const cachedAt = missingCache[directory];
if (known === 'missing') {
if (isProjectRoot) {
toProbe.push(directory);
continue;
}
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
continue;
}
toProbe.push(directory);
continue;
}
// Use cached "missing" status if fresh enough — skip the HTTP probe
if (!isProjectRoot && cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
preseeded.set(directory, 'missing');
continue;
}
toProbe.push(directory);
}
// Apply preseeded missing statuses immediately (no HTTP call)
if (preseeded.size > 0) {
setDirectoryStatus((prev) => {
let changed = false;
const next = new Map(prev);
for (const [dir, status] of preseeded) {
if (next.get(dir) !== status) {
next.set(dir, status);
changed = true;
}
}
return changed ? next : prev;
});
}
if (toProbe.length === 0 || probeInFlightRef.current) return;
probeInFlightRef.current = true;
let cancelled = false;
let cacheChanged = false;
void mapWithConcurrency(toProbe, PROBE_CONCURRENCY, async (directory) => {
const status = await probeDirectory(directory);
// Update missing cache
if (status === 'missing') {
missingCache[directory] = Date.now();
cacheChanged = true;
} else if (missingCache[directory]) {
delete missingCache[directory];
cacheChanged = true;
}
if (!cancelled) {
setDirectoryStatus((prev) => {
if (prev.get(directory) === status) return prev;
const next = new Map(prev);
next.set(directory, status);
return next;
});
}
return { directory, status };
}).finally(() => {
probeInFlightRef.current = false;
if (cacheChanged) {
saveMissingCache(missingCache);
}
});
return () => {
cancelled = true;
};
}, [sortedSessions, projects, setDirectoryStatus]);
};