hermes-agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
brooklyn! 31c40c72c0
fix(desktop): stabilize project folder sessions (#37586)
* fix(desktop): stabilize project folder sessions

Keep desktop folder selection aligned with new sessions and scope TUI gateway cwd through session context so prompts and tools resolve against the selected workspace.

* fix(desktop): address review feedback on folder sessions

Snapshot sessions before iterating to avoid concurrent-mutation crashes,
optional-chain the revealLogs catch, and read console-message args from
the correct Electron event/messageDetails positions.

* fix(desktop): address second review pass on folder sessions

Sync the remembered workspace key with the cwd atom (clear on empty),
only load tree children for real directory nodes, and throttle renderer
auto-reloads so a deterministic startup crash can't loop forever.

* fix(desktop): inherit parent workspace for ephemeral agent tasks

Background and preview tasks use ephemeral ids absent from the session
map, so pass the parent session cwd into the session context explicitly
instead of clearing it back to the gateway launch dir. Also correct the
set_session_vars docstring about clear_session_vars semantics.

* fix(desktop): validate preview cwd before pinning session context

A non-empty but non-existent client cwd would pin an unusable override
and silently fall back to the launch dir. Validate once, reuse for both
the session context and the terminal override, and fall back to the
parent session workspace when invalid.

* fix(desktop): harden preview cwd normalization and adopt normalized cwd

Guard preview cwd normalization against malformed client paths so a bad
input can't fail the whole restart, and adopt the backend's normalized
config.get cwd in the no-active-session path so the persisted workspace
stays consistent with what the agent uses.
2026-06-02 20:23:09 +00:00

268 lines
6.6 KiB
TypeScript

import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
/** Absolute filesystem path. Doubles as react-arborist node id. */
id: string
name: string
/** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */
isDirectory: boolean
/** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
}
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
if (!nodes) {
return []
}
return nodes.map(n => {
if (n.id === id) {
return patch(n)
}
if (n.children && n.children.length > 0) {
return { ...n, children: patchNode(n.children, id, patch) }
}
return n
})
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
}
export interface UseProjectTreeResult {
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
collapseNonce: number
data: TreeNode[]
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
collapseAll: () => void
loadChildren: (id: string) => Promise<void>
refreshRoot: () => Promise<void>
setNodeOpen: (id: string, open: boolean) => void
}
interface ProjectTreeState {
collapseNonce: number
cwd: string
data: TreeNode[]
loaded: boolean
openState: Record<string, boolean>
requestId: number
rootError: string | null
rootLoading: boolean
}
const initialState: ProjectTreeState = {
collapseNonce: 0,
cwd: '',
data: [],
loaded: false,
openState: {},
requestId: 0,
rootError: null,
rootLoading: false
}
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
}
function clearProjectTree() {
nextRootRequestId += 1
inflight.clear()
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
}
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
if (!cwd) {
clearProjectTree()
return
}
const current = $projectTree.get()
if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) {
return
}
const requestId = nextRootRequestId + 1
nextRootRequestId = requestId
inflight.clear()
if (force || current.cwd !== cwd) {
clearProjectDirCache(cwd)
}
$projectTree.set({
collapseNonce: current.collapseNonce,
cwd,
data: [],
loaded: false,
openState: current.cwd === cwd ? current.openState : {},
requestId,
rootError: null,
rootLoading: true
})
const { entries, error } = await readProjectDir(cwd, cwd)
setProjectTree(latest => {
if (latest.cwd !== cwd || latest.requestId !== requestId) {
return latest
}
return {
...latest,
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
loaded: true,
rootError: error || null,
rootLoading: false
}
})
}
export function resetProjectTreeState() {
clearProjectTree()
clearProjectDirCache()
}
/**
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
* expand and cached in this feature-owned atom so unrelated chat rerenders or
* remounts cannot reset the browser. A placeholder leaf renders so the
* disclosure caret shows for unloaded folders. `refreshRoot` invalidates the
* whole tree (used after cwd change or manual refresh).
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
const setNodeOpen = useCallback(
(id: string, open: boolean) => {
setProjectTree(current => {
if (current.cwd !== cwd || current.openState[id] === open) {
return current
}
return {
...current,
openState: {
...current.openState,
[id]: open
}
}
})
},
[cwd]
)
// Clears the recorded open state and bumps the nonce; the tree is keyed on
// the nonce so it remounts with everything collapsed (loaded children stay
// cached in `data`, just hidden).
const collapseAll = useCallback(() => {
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
return { ...current, collapseNonce: current.collapseNonce + 1, openState: {} }
})
}, [cwd])
const loadChildren = useCallback(
async (id: string) => {
if (!cwd || inflight.has(id)) {
return
}
inflight.add(id)
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
return {
...current,
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
}
})
const { entries, error } = await readProjectDir(id, cwd)
inflight.delete(id)
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
return {
...current,
data: patchNode(current.data, id, n => ({
...n,
loading: false,
error: error || undefined,
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
},
[cwd]
)
useEffect(() => {
void loadRoot(cwd)
}, [cwd])
return useMemo(
() => ({
collapseAll,
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
data: state.cwd === cwd ? state.data : [],
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
refreshRoot,
rootError: state.cwd === cwd ? state.rootError : null,
rootLoading: state.cwd === cwd ? state.rootLoading : Boolean(cwd),
setNodeOpen
}),
[
collapseAll,
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.collapseNonce,
state.cwd,
state.data,
state.openState,
state.rootError,
state.rootLoading
]
)
}