mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
* 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.
268 lines
6.6 KiB
TypeScript
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
|
|
]
|
|
)
|
|
}
|