fix: resolve perf regression in transcript loading and session search

Two performance regressions introduced in recent PRs:

1. readAgentConfigs blocked transcript rendering (PR #50)
   The agent config IPC call was awaited on the critical path of
   fetchSessionDetail, preventing any transcript data from rendering
   until the filesystem read completed. On macOS this was especially
   noticeable due to security checks on first directory access.
   Fixed by making the call fire-and-forget: the transcript renders
   immediately and subagent color badges update asynchronously.
   Also set the project cache key optimistically before the async call
   to prevent duplicate in-flight requests on rapid navigation.

2. SessionSearcher stat()-called every session file on each search (PR #53)
   LocalFileSystemProvider.readdir() did not populate the optional
   mtimeMs field on FsDirent entries. The new SearchTextCache-based
   SessionSearcher fell back to an individual fsProvider.stat() call
   per session file when mtimeMs was missing, adding N extra filesystem
   round-trips on every search in local mode.
   Fixed by statting all entries concurrently inside readdir(), so
   mtimeMs is always populated and the stat fallback is never triggered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
proxy 2026-02-21 15:46:25 -05:00
parent 39d88e22a4
commit 9e7d3b58a1
2 changed files with 31 additions and 14 deletions

View file

@ -43,11 +43,25 @@ export class LocalFileSystemProvider implements FileSystemProvider {
async readdir(dirPath: string): Promise<FsDirent[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
return entries.map((entry) => ({
name: entry.name,
isFile: () => entry.isFile(),
isDirectory: () => entry.isDirectory(),
}));
// Stat all entries concurrently to populate mtimeMs, used by SessionSearcher's
// mtime-based cache invalidation. Failures are silently ignored (mtimeMs stays undefined).
return Promise.all(
entries.map(async (entry) => {
let mtimeMs: number | undefined;
try {
const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
mtimeMs = stat.mtimeMs;
} catch {
// ignore
}
return {
name: entry.name,
mtimeMs,
isFile: () => entry.isFile(),
isDirectory: () => entry.isDirectory(),
};
})
);
}
createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream {

View file

@ -197,16 +197,19 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
let claudeMdStats: Map<string, ClaudeMdStats> | null = null;
let contextStats: Map<string, ContextStats> | null = null;
let phaseInfo: ContextPhaseInfo | null = null;
// Fetch agent configs from .claude/agents/ (only when project changes)
// Fetch agent configs from .claude/agents/ (only when project changes).
// Fire-and-forget: don't block transcript rendering — color badges update async.
if (connectionMode !== 'ssh' && projectRoot && projectRoot !== agentConfigsCachedForProject) {
try {
const configs = await api.readAgentConfigs(projectRoot);
if (requestGeneration !== sessionDetailFetchGeneration) return;
agentConfigsCachedForProject = projectRoot;
set({ agentConfigs: configs });
} catch (err) {
logger.error('Failed to read agent configs:', err);
}
agentConfigsCachedForProject = projectRoot; // Optimistic set to prevent duplicate fetches
api
.readAgentConfigs(projectRoot)
.then((configs) => {
set({ agentConfigs: configs });
})
.catch((err) => {
logger.error('Failed to read agent configs:', err);
agentConfigsCachedForProject = ''; // Reset so it retries next time
});
}
if (connectionMode !== 'ssh' && conversation?.items) {