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.
This commit is contained in:
brooklyn!
2026-06-02 20:23:09 +00:00
committed by GitHub
parent 79bfddd37c
commit 31c40c72c0
14 changed files with 493 additions and 51 deletions
@@ -11,6 +11,8 @@ const ROW_HEIGHT = 22
const INDENT = 10
interface ProjectTreeProps {
collapseNonce: number
cwd: string
data: TreeNode[]
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
@@ -21,6 +23,8 @@ interface ProjectTreeProps {
}
export function ProjectTree({
collapseNonce,
cwd,
data,
onActivateFile,
onActivateFolder,
@@ -63,7 +67,7 @@ export function ProjectTree({
onNodeOpenChange(id, node.isOpen)
if (node.isOpen && node.data.children === undefined) {
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
void onLoadChildren(id)
}
},
@@ -72,7 +76,7 @@ export function ProjectTree({
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (!node.data.isDirectory) {
if (node.data && !node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
@@ -83,7 +87,7 @@ export function ProjectTree({
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
data={data}
disableDrag
disableDrop
@@ -91,6 +95,7 @@ export function ProjectTree({
height={size.height}
indent={INDENT}
initialOpenState={openState}
key={`${cwd}:${collapseNonce}`}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
@@ -135,6 +140,10 @@ function ProjectTreeRow({
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
}) {
if (!node.data) {
return <div style={style} />
}
const isFolder = node.data.isDirectory
const isPlaceholder = node.data.id.endsWith('::__loading__')
@@ -47,16 +47,20 @@ function placeholderChild(parentId: string): TreeNode {
}
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
@@ -67,6 +71,7 @@ interface ProjectTreeState {
}
const initialState: ProjectTreeState = {
collapseNonce: 0,
cwd: '',
data: [],
loaded: false,
@@ -112,6 +117,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
$projectTree.set({
collapseNonce: current.collapseNonce,
cwd,
data: [],
loaded: false,
@@ -174,6 +180,19 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
[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)) {
@@ -222,6 +241,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
return useMemo(
() => ({
collapseAll,
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
data: state.cwd === cwd ? state.data : [],
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
@@ -231,10 +252,12 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
setNodeOpen
}),
[
collapseAll,
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.collapseNonce,
state.cwd,
state.data,
state.openState,
+72 -12
View File
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
@@ -52,7 +53,10 @@ export function RightSidebarPane({
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
@@ -97,6 +101,8 @@ export function RightSidebarPane({
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
@@ -106,6 +112,7 @@ export function RightSidebarPane({
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
@@ -160,13 +167,22 @@ function RightSidebarChrome({
}
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
hasCwd: boolean
onChangeFolder: () => Promise<void> | void
onCollapseAll: () => void
onRefresh: () => void
}
const HEADER_ACTION_CLASS =
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
collapseNonce,
cwd,
cwdName,
data,
@@ -176,6 +192,7 @@ function FilesystemTab({
onActivateFile,
onActivateFolder,
onChangeFolder,
onCollapseAll,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
@@ -188,14 +205,35 @@ function FilesystemTab({
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? cwd : 'No folder selected'}
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Open folder"
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
</Button>
<Button
aria-label="Collapse all folders"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
<Button
aria-label="Refresh tree"
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
@@ -206,6 +244,7 @@ function FilesystemTab({
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
cwd={cwd}
data={data}
error={error}
@@ -226,6 +265,7 @@ export function RightSidebarSectionHeader({ children }: { children: ReactNode })
}
interface FileTreeBodyProps {
collapseNonce: number
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
@@ -239,6 +279,7 @@ interface FileTreeBodyProps {
}
function FileTreeBody({
collapseNonce,
cwd,
data,
error,
@@ -267,15 +308,34 @@ function FileTreeBody({
}
return (
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
<ErrorBoundary
fallback={({ reset }) => (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<EmptyState body="The file tree hit an error rendering this folder." title="Tree error" />
<button
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
onClick={reset}
type="button"
>
Try again
</button>
</div>
)}
key={cwd}
label="file-tree"
>
<ProjectTree
collapseNonce={collapseNonce}
cwd={cwd}
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
</ErrorBoundary>
)
}
@@ -50,16 +50,23 @@ export function useCwdActions({
}
if (!activeSessionId) {
setCurrentCwd(trimmed)
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: trimmed
})
setCurrentCwd(info.cwd || trimmed)
// Adopt the backend's normalized cwd so the persisted workspace and
// branch stay consistent with what the agent will use.
if (info.cwd) {
setCurrentCwd(info.cwd)
}
setCurrentBranch(info.branch || '')
} catch (err) {
notifyError(err, 'Working directory change failed')
} catch {
setCurrentBranch('')
}
return
@@ -15,6 +15,7 @@ import {
$currentCwd,
$messages,
$sessions,
getRememberedWorkspaceCwd,
setActiveSessionId,
setAwaitingResponse,
setBusy,
@@ -291,7 +292,8 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
setCurrentCwd('')
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
@@ -308,7 +310,7 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
const cwd = $currentCwd.get().trim()
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
@@ -0,0 +1,91 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, RefreshCw } from '@/lib/icons'
export interface ErrorBoundaryFallbackProps {
error: Error
reset: () => void
}
interface ErrorBoundaryProps {
children: ReactNode
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode
label?: string
onError?: (error: Error, info: ErrorInfo) => void
}
interface ErrorBoundaryState {
error: Error | null
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
console.error(tag, error, info.componentStack)
this.props.onError?.(error, info)
}
reset = () => {
this.setState({ error: null })
}
render() {
const { error } = this.state
if (!error) {
return this.props.children
}
if (this.props.fallback) {
return this.props.fallback({ error, reset: this.reset })
}
return <RootErrorFallback error={error} reset={this.reset} />
}
}
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
return (
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
</p>
</div>
</div>
<div className="grid gap-4 p-5">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
{error.message || String(error)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>
<RefreshCw className="size-4" />
Try again
</Button>
<Button onClick={() => window.location.reload()} variant="outline">
Reload window
</Button>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
Open logs
</Button>
</div>
</div>
</div>
</div>
)
}
+12 -9
View File
@@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './app'
import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { installClipboardShim } from './lib/clipboard'
import { ThemeProvider } from './themes/context'
@@ -32,14 +33,16 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
)
+14 -2
View File
@@ -3,10 +3,15 @@ import { atom } from 'nanostores'
import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages'
import { persistString, storedString } from '@/lib/storage'
import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
interface AppAtom<T> {
get: () => T
set: (value: T) => void
@@ -39,7 +44,7 @@ export const $currentProvider = atom('')
export const $currentReasoningEffort = atom('')
export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false)
export const $currentCwd = atom('')
export const $currentCwd = atom(getRememberedWorkspaceCwd())
export const $currentBranch = atom('')
export const $currentUsage = atom<UsageStats>({
calls: 0,
@@ -73,7 +78,14 @@ export const setCurrentProvider = (next: Updater<string>) => updateAtom($current
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
export const setCurrentCwd = (next: Updater<string>) => {
updateAtom($currentCwd, next)
// Keep localStorage in sync with the atom: a real folder is remembered, an
// empty cwd clears the key (|| null → removeItem).
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)