From 61bda4f3ca9dfd01b2b69b3d039f7a7df1f0b9a9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 21:43:35 -0500 Subject: [PATCH 1/3] perf(desktop): stop the file tree going sticky during agent edit bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revalidateTree runs on every $workspaceChangeTick (mutating-tool completion, coalesced ~500ms). Two costs per tick, gone: 1. clearProjectDirCache() wiped the gitroot + gitignore caches. But listings are read fresh every time (readProjectDir never caches them), so the wipe bought nothing except forcing a full re-read of every ancestor .gitignore — each a full readdir — for every loaded dir, every tick. Dropped; a .gitignore edit is still picked up on the next full refresh (cwd/connection change / manual). 2. reconcile awaited each child dir serially, crawling a wide/deep tree one dir at a time. Now Promise.all over siblings (order preserved), recursing per loaded subfolder. use-project-tree.test.ts + right-sidebar/index.test.tsx green (15). tsc + eslint clean. --- .../right-sidebar/files/use-project-tree.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 59ec3b1b73f5..7d5b2c971132 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -233,7 +233,11 @@ async function revalidateTree(cwd: string): Promise { } const rootPath = state.resolvedCwd || cwd - clearProjectDirCache() + // Don't wipe the gitroot/gitignore caches here. Listings are read fresh + // (readProjectDir never caches them), so new/deleted files surface anyway; + // wiping forced a full re-read of every ancestor .gitignore on every edit + // tick (~500ms), which made the tree sticky mid-burst. A .gitignore edit is + // picked up on the next full refresh (cwd/connection change / manual refresh). const reconcile = async (dirPath: string, existing: TreeNode[]): Promise => { const { entries, error } = await readProjectDir(dirPath, rootPath) @@ -243,22 +247,21 @@ async function revalidateTree(cwd: string): Promise { } const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node])) - const merged: TreeNode[] = [] - for (const entry of entries) { - const prev = byId.get(entry.path) + // Reconcile siblings concurrently (Promise.all preserves order); loaded + // subfolders recurse so deep edits surface without a re-expand. Awaiting + // each child serially crawled a wide/deep tree one dir at a time per tick. + return Promise.all( + entries.map(async entry => { + const prev = byId.get(entry.path) - if (prev?.isDirectory && prev.children) { - // Loaded folder: recurse so deep edits surface without a re-expand. - merged.push({ ...prev, children: await reconcile(prev.id, prev.children) }) - } else if (prev) { - merged.push(prev) - } else { - merged.push(makeNode(entry.path, entry.name, entry.isDirectory)) - } - } + if (prev?.isDirectory && prev.children) { + return { ...prev, children: await reconcile(prev.id, prev.children) } + } - return merged + return prev ?? makeNode(entry.path, entry.name, entry.isDirectory) + }) + ) } const nextData = await reconcile(rootPath, state.data) From ae15742bc2d76876ac59ff03973f034191833a72 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 21:46:01 -0500 Subject: [PATCH 2/3] style(desktop): tighten revalidateTree comments --- .../src/app/right-sidebar/files/use-project-tree.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 7d5b2c971132..45859b9c0c32 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -233,11 +233,9 @@ async function revalidateTree(cwd: string): Promise { } const rootPath = state.resolvedCwd || cwd - // Don't wipe the gitroot/gitignore caches here. Listings are read fresh - // (readProjectDir never caches them), so new/deleted files surface anyway; - // wiping forced a full re-read of every ancestor .gitignore on every edit - // tick (~500ms), which made the tree sticky mid-burst. A .gitignore edit is - // picked up on the next full refresh (cwd/connection change / manual refresh). + // No cache wipe per tick: listings are always read fresh (readProjectDir + // doesn't cache them), so wiping only re-read every ancestor .gitignore each + // ~500ms edit tick — sticky mid-burst. A .gitignore edit lands on next refresh. const reconcile = async (dirPath: string, existing: TreeNode[]): Promise => { const { entries, error } = await readProjectDir(dirPath, rootPath) @@ -248,9 +246,8 @@ async function revalidateTree(cwd: string): Promise { const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node])) - // Reconcile siblings concurrently (Promise.all preserves order); loaded - // subfolders recurse so deep edits surface without a re-expand. Awaiting - // each child serially crawled a wide/deep tree one dir at a time per tick. + // Reconcile siblings concurrently (Promise.all keeps order); loaded + // subfolders recurse. Serial awaits crawled a wide/deep tree one dir per tick. return Promise.all( entries.map(async entry => { const prev = byId.get(entry.path) From 0aa64ffcfcf50bc2b0f31d7667a0e5366aa8ad76 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 21:56:57 -0500 Subject: [PATCH 3/3] perf(desktop): targeted file-tree revalidation instead of whole-tree rescan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite of the paradigm, not just a cheaper version of it. Before, any file mutation bumped a contentless $workspaceChangeTick and the tree re-read EVERY loaded directory to diff — the parent state was never told what actually changed. Now the mutation carries its path: - workspace-events accumulates the changed dir(s) (dirname of an absolute tool path) and exposes consumeWorkspaceChange(); an opaque mutation (terminal, or a relative/unresolvable path) sets `full` instead. - gateway-event passes toolChangedPath(payload) through on tool.complete. - revalidateTree(cwd, change) re-reads ONLY the changed dirs that are loaded and patches just those subtrees — root + untouched folders never hit the FS or re-render. Full recursive reconcile is kept as the fallback for `full`. So a write in one folder no longer crawls the whole tree; the opaque terminal case still self-heals via the full path. Safe fallback everywhere a path can't be resolved, so no change is ever missed. typecheck + eslint clean; use-project-tree / right-sidebar / gateway-events tests green. --- .../right-sidebar/files/use-project-tree.ts | 92 ++++++++++++++++--- .../hooks/use-message-stream/gateway-event.ts | 4 +- apps/desktop/src/store/workspace-events.ts | 65 ++++++++++++- 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 45859b9c0c32..69ece75af56c 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -3,9 +3,9 @@ import { atom } from 'nanostores' import { useCallback, useEffect, useMemo } from 'react' import { $connection } from '@/store/session' -import { $workspaceChangeTick } from '@/store/workspace-events' +import { $workspaceChangeTick, consumeWorkspaceChange } from '@/store/workspace-events' -import { clearProjectDirCache, readProjectDir } from './ipc' +import { clearProjectDirCache, type ProjectTreeEntry, readProjectDir } from './ipc' export interface TreeNode { /** Absolute filesystem path. Doubles as react-arborist node id. */ @@ -48,6 +48,33 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: }) } +function findNode(nodes: TreeNode[], id: string): null | TreeNode { + for (const node of nodes) { + if (node.id === id) { + return node + } + + if (node.children?.length) { + const hit = findNode(node.children, id) + + if (hit) { + return hit + } + } + } + + return null +} + +// Merge a freshly-read dir's entries into its existing children: keep surviving +// nodes (subtrees intact), add new, drop deleted. Non-recursive — a grandchild +// dir only re-reads when it's itself in the change set. +function mergeChildren(existing: TreeNode[], entries: ProjectTreeEntry[]): TreeNode[] { + const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node])) + + return entries.map(entry => byId.get(entry.path) ?? makeNode(entry.path, entry.name, entry.isDirectory)) +} + function placeholderChild(parentId: string): TreeNode { return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' } } @@ -220,12 +247,13 @@ export function resetProjectTreeState() { clearProjectDirCache() } -// Non-destructive refresh: re-read every currently-loaded directory and merge -// entries (add new files/folders, drop deleted ones) while preserving expansion -// and already-loaded subtrees. Unlike `loadRoot({force})` this never collapses -// the tree, so it's safe to run live as the agent edits — and because node ids -// (absolute paths) stay stable across merges, rows can animate in/out. -async function revalidateTree(cwd: string): Promise { +// Non-destructive live refresh as the agent edits: preserves expansion + loaded +// subtrees (stable absolute-path ids let rows animate in/out), never collapses. +// Targeted by default — re-reads only the changed dirs in `change`; the root and +// untouched folders never touch the filesystem or re-render. Falls back to +// re-reading every loaded dir only when the mutation is opaque (a terminal +// command / a path we couldn't resolve) — see store/workspace-events. +async function revalidateTree(cwd: string, change: { dirs: string[]; full: boolean }): Promise { const state = $projectTree.get() if (!cwd || state.cwd !== cwd || !state.loaded) { @@ -233,21 +261,55 @@ async function revalidateTree(cwd: string): Promise { } const rootPath = state.resolvedCwd || cwd - // No cache wipe per tick: listings are always read fresh (readProjectDir - // doesn't cache them), so wiping only re-read every ancestor .gitignore each - // ~500ms edit tick — sticky mid-burst. A .gitignore edit lands on next refresh. + if (!change.full && change.dirs.length) { + // Only re-read changed dirs that are actually loaded (root, or an expanded + // folder); a change inside a collapsed/absent dir isn't visible → skip. + const targets = change.dirs.filter(dir => dir === rootPath || findNode(state.data, dir)?.children) + + if (!targets.length) { + return + } + + const reads = await Promise.all(targets.map(async dir => ({ dir, ...(await readProjectDir(dir, rootPath)) }))) + + setProjectTree(latest => { + if (latest.cwd !== cwd || !latest.loaded) { + return latest + } + + let data = latest.data + + for (const { dir, entries, error } of reads) { + if (error) { + continue // keep last-known children on a transient read error + } + + data = + dir === rootPath + ? mergeChildren(data, entries) + : patchNode(data, dir, node => + node.children ? { ...node, children: mergeChildren(node.children, entries) } : node + ) + } + + return data === latest.data ? latest : { ...latest, data } + }) + + return + } + + // Opaque fallback: reconcile every loaded dir. Siblings read concurrently + // (Promise.all keeps order); loaded subfolders recurse. const reconcile = async (dirPath: string, existing: TreeNode[]): Promise => { const { entries, error } = await readProjectDir(dirPath, rootPath) if (error) { - return existing // keep the last-known children on a transient read error + return existing } const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node])) - // Reconcile siblings concurrently (Promise.all keeps order); loaded - // subfolders recurse. Serial awaits crawled a wide/deep tree one dir per tick. return Promise.all( entries.map(async entry => { const prev = byId.get(entry.path) @@ -360,7 +422,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { // very first render: tick 0 is the initial value, not a real change). useEffect(() => { if (workspaceTick > 0) { - void revalidateTree(cwd) + void revalidateTree(cwd, consumeWorkspaceChange()) } }, [workspaceTick, cwd]) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 25669b5a48ba..3b2c15b1e1fe 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -46,7 +46,7 @@ import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } import { clearActiveSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' -import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' +import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' import type { RpcEvent } from '@/types/hermes' import type { ClientSessionState } from '../../../types' @@ -554,7 +554,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // (coding rail, review pane, file tree) to refresh. Event-driven, not // polled: fires exactly when the agent touches the tree. if (payload && toolMayMutateFiles(payload)) { - notifyWorkspaceChanged() + notifyWorkspaceChanged(toolChangedPath(payload)) } } else if (SUBAGENT_EVENT_TYPES.has(event.type)) { if (sessionId && payload && !sessionInterrupted(sessionId)) { diff --git a/apps/desktop/src/store/workspace-events.ts b/apps/desktop/src/store/workspace-events.ts index 174427a28596..4e75bf7b3e45 100644 --- a/apps/desktop/src/store/workspace-events.ts +++ b/apps/desktop/src/store/workspace-events.ts @@ -9,6 +9,33 @@ import { atom } from 'nanostores' export const $workspaceChangeTick = atom(0) +// What changed since the last consume. The file tree targets `dirs` (surgical +// subtree re-reads) and only falls back to a whole-tree rescan when `full` is +// set — an opaque mutation (a terminal command, or a path we can't resolve to +// the tree's absolute ids) whose touched paths we can't enumerate. Coarse +// subscribers (coding rail, review) ignore this and just react to the tick. +let pendingDirs = new Set() +let pendingFull = false + +/** Drain the accumulated change since the previous call (the tree's consumer). */ +export function consumeWorkspaceChange(): { dirs: string[]; full: boolean } { + const change = { dirs: [...pendingDirs], full: pendingFull } + pendingDirs = new Set() + pendingFull = false + + return change +} + +// Parent dir of an ABSOLUTE path (POSIX or `C:/…`); null for a relative path we +// can't anchor to the tree — the caller treats null as "rescan to be safe". +function dirOf(path: string): null | string { + const p = path.replace(/\\/g, '/').replace(/\/+$/, '') + const absolute = p.startsWith('/') || /^[a-z]:\//i.test(p) + const slash = p.lastIndexOf('/') + + return absolute && slash >= 0 ? p.slice(0, slash) : null +} + // Throttle so a burst of edits in one turn coalesces: fire on the leading edge // for instant feedback, then at most once per window (a trailing fire catches // the last edit of the burst). @@ -21,7 +48,17 @@ function fire(): void { $workspaceChangeTick.set($workspaceChangeTick.get() + 1) } -export function notifyWorkspaceChanged(): void { +/** @param changedPath absolute path a tool touched; omit (or pass a relative / + * unknowable path) to force a full-tree rescan. */ +export function notifyWorkspaceChanged(changedPath?: string): void { + const dir = changedPath ? dirOf(changedPath) : null + + if (dir) { + pendingDirs.add(dir) + } else { + pendingFull = true + } + const since = Date.now() - lastFired if (since >= MIN_INTERVAL_MS) { @@ -58,3 +95,29 @@ export function toolMayMutateFiles(payload: { name?: unknown; tool?: unknown; in return MUTATING_TOOL_RE.test(name) } + +// Common arg keys a single-file writer/mover uses for its target. A hit lets the +// tree target that dir; a miss (terminal, multi-path, odd schema) → full rescan. +const PATH_ARG_KEYS = ['path', 'file_path', 'filename', 'file', 'target_file', 'new_path', 'dest', 'destination'] + +/** Best-effort absolute path a finished tool touched, from its args — or + * undefined (→ full rescan) for terminal/opaque/multi-path mutations. */ +export function toolChangedPath(payload: { args?: unknown; arguments?: unknown }): string | undefined { + const args = payload.args ?? payload.arguments + + if (!args || typeof args !== 'object') { + return undefined + } + + const record = args as Record + + for (const key of PATH_ARG_KEYS) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return undefined +}