From 3a6ca1279fbedac816377de89a533783bff861be Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 14:05:39 -0500 Subject: [PATCH 1/6] 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. --- agent/runtime_cwd.py | 39 +++++++- apps/desktop/electron/main.cjs | 44 ++++++++- .../src/app/right-sidebar/files/tree.tsx | 15 ++- .../right-sidebar/files/use-project-tree.ts | 23 +++++ apps/desktop/src/app/right-sidebar/index.tsx | 84 ++++++++++++++--- .../src/app/session/hooks/use-cwd-actions.ts | 7 +- .../app/session/hooks/use-session-actions.ts | 6 +- .../desktop/src/components/error-boundary.tsx | 91 +++++++++++++++++++ apps/desktop/src/main.tsx | 21 +++-- apps/desktop/src/store/session.ts | 18 +++- gateway/session_context.py | 15 +++ tests/agent/test_runtime_cwd.py | 52 ++++++++++- tests/test_tui_gateway_server.py | 28 ++++++ tui_gateway/server.py | 15 ++- 14 files changed, 416 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/components/error-boundary.tsx diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index d25d38ccedc8..d57a9da7e244 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -6,16 +6,42 @@ relies on the launch dir. Reading it in one place keeps the system prompt, the tool surfaces, and context-file discovery agreeing on where the agent lives. -The #29531 per-session extension point is this function: a future PR adds a -contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the -`set_session_vars` seam — by design, not a reopening hazard. +Multi-session gateways can pin a logical cwd via the `_SESSION_CWD` +contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd. """ import os +from contextvars import ContextVar, Token from pathlib import Path +from typing import Any + +_UNSET: Any = object() + +_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET) + + +def set_session_cwd(cwd: str | None) -> Token: + """Pin the logical cwd for the current context.""" + return _SESSION_CWD.set((cwd or "").strip()) + + +def clear_session_cwd() -> None: + _SESSION_CWD.set("") + + +def _session_cwd_override() -> str: + value = _SESSION_CWD.get() + if value is _UNSET: + return "" + return str(value).strip() def resolve_agent_cwd() -> Path: + override = _session_cwd_override() + if override: + p = Path(override).expanduser() + if p.is_dir(): + return p raw = os.environ.get("TERMINAL_CWD", "").strip() if raw: p = Path(raw).expanduser() @@ -27,7 +53,10 @@ def resolve_agent_cwd() -> Path: def resolve_context_cwd() -> Path | None: # None means "no configured cwd": build_context_files_prompt then falls back # to the launch dir (os.getcwd()) — correct for the local CLI. The gateway - # avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py). - # No getcwd arm here: that fallback is owned by the caller, not this resolver. + # avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py) + # or, per session, the _SESSION_CWD contextvar above. + override = _session_cwd_override() + if override: + return Path(override).expanduser() raw = os.environ.get("TERMINAL_CWD", "").strip() return Path(raw).expanduser() if raw else None diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index bd94e69a5f17..8ebc09db796d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3222,6 +3222,36 @@ function createWindow() { openExternalUrl(url) }) + mainWindow.webContents.on('render-process-gone', (_event, details) => { + rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`) + + if (details?.reason === 'crashed' || details?.reason === 'oom') { + setImmediate(() => { + if (!mainWindow || mainWindow.isDestroyed()) return + try { + mainWindow.webContents.reload() + } catch (err) { + rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`) + } + }) + } + }) + + mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive')) + + mainWindow.webContents.on('console-message', (eventOrLevel, message, line, sourceId) => { + const isObjectForm = eventOrLevel && typeof eventOrLevel === 'object' + const level = isObjectForm ? eventOrLevel.level : eventOrLevel + const isError = level === 'error' || level === 3 + + if (!isError) return + + const text = isObjectForm ? eventOrLevel.message : message + const src = isObjectForm ? eventOrLevel.sourceId : sourceId + const lineNo = isObjectForm ? eventOrLevel.lineNumber : line + rememberLog(`[renderer console] ${text} (${src}:${lineNo})`) + }) + if (DEV_SERVER) { mainWindow.loadURL(DEV_SERVER) } else { @@ -3372,13 +3402,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { }) ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { - const properties = ['openFile'] - if (options?.directories) properties.push('openDirectory') + const properties = options?.directories ? ['openDirectory'] : ['openFile'] if (options?.multiple !== false) properties.push('multiSelections') + let resolvedDefaultPath + if (options?.defaultPath) { + try { + resolvedDefaultPath = path.resolve(String(options.defaultPath)) + } catch { + resolvedDefaultPath = undefined + } + } + const result = await dialog.showOpenDialog(mainWindow, { title: options?.title || 'Add context', - defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined, + defaultPath: resolvedDefaultPath, properties, filters: Array.isArray(options?.filters) ? options.filters : undefined }) diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx index 95bea5fd45af..769b3d2667e6 100644 --- a/apps/desktop/src/app/right-sidebar/files/tree.tsx +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -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?.children === undefined) { void onLoadChildren(id) } }, @@ -72,7 +76,7 @@ export function ProjectTree({ const handleActivate = useCallback( (node: NodeApi) => { - if (!node.data.isDirectory) { + if (node.data && !node.data.isDirectory) { onPreviewFile?.(node.data.id) } }, @@ -83,7 +87,7 @@ export function ProjectTree({
{size.height > 0 && size.width > 0 ? ( - 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
+ } + const isFolder = node.data.isDirectory const isPlaceholder = node.data.id.endsWith('::__loading__') 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 229bfcacd829..23fb5efe2dc9 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 @@ -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 rootError: string | null rootLoading: boolean + collapseAll: () => void loadChildren: (id: string) => Promise refreshRoot: () => Promise 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, diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 02c9708ed7fd..ae429988dee1 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -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({ ) : ( Promise | 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({ + + +
+ )} + key={cwd} + label="file-tree" + > + + ) } diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index b1122d1c5509..cbf2a8080285 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -50,16 +50,17 @@ 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) setCurrentBranch(info.branch || '') - } catch (err) { - notifyError(err, 'Working directory change failed') + } catch { + setCurrentBranch('') } return diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 82ef77c7ebc1..066f1eee0faa 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -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('session.create', { cols: 96, ...(cwd && { cwd }) }) const stored = created.stored_session_id ?? null diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx new file mode 100644 index 000000000000..f3be4aacc424 --- /dev/null +++ b/apps/desktop/src/components/error-boundary.tsx @@ -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 { + 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 + } +} + +function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { + return ( +
+
+
+
+ +
+
+

Something broke in the interface

+

+ The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window. +

+
+
+ +
+
+ {error.message || String(error)} +
+ +
+ + + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index f203e42d7cea..59341df1a1a7 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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( - - - - - - - - - + + + + + + + + + + + ) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index cf2372f3d048..ae34b2319d38 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -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 | ((current: T) => T) +const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' + +export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || '' + interface AppAtom { 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({ calls: 0, @@ -73,7 +78,16 @@ export const setCurrentProvider = (next: Updater) => updateAtom($current export const setCurrentReasoningEffort = (next: Updater) => updateAtom($currentReasoningEffort, next) export const setCurrentServiceTier = (next: Updater) => updateAtom($currentServiceTier, next) export const setCurrentFastMode = (next: Updater) => updateAtom($currentFastMode, next) -export const setCurrentCwd = (next: Updater) => updateAtom($currentCwd, next) + +export const setCurrentCwd = (next: Updater) => { + updateAtom($currentCwd, next) + const value = $currentCwd.get().trim() + + if (value) { + persistString(WORKSPACE_CWD_KEY, value) + } +} + export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next) export const setCurrentUsage = (next: Updater) => updateAtom($currentUsage, next) export const setSessionStartedAt = (next: Updater) => updateAtom($sessionStartedAt, next) diff --git a/gateway/session_context.py b/gateway/session_context.py index ee43eca0f76a..f86fada971d0 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -107,6 +107,7 @@ def set_session_vars( user_name: str = "", session_key: str = "", message_id: str = "", + cwd: str = "", ) -> list: """Set all session context variables and return reset tokens. @@ -115,6 +116,8 @@ def set_session_vars( Returns a list of ``Token`` objects (one per variable) that can be passed to ``clear_session_vars``. + + ``cwd`` pins the logical working directory for this context. """ tokens = [ _SESSION_PLATFORM.set(platform), @@ -126,6 +129,12 @@ def set_session_vars( _SESSION_KEY.set(session_key), _SESSION_MESSAGE_ID.set(message_id), ] + try: + from agent.runtime_cwd import set_session_cwd + + set_session_cwd(cwd) + except Exception: + pass return tokens @@ -151,6 +160,12 @@ def clear_session_vars(tokens: list) -> None: _SESSION_MESSAGE_ID, ): var.set("") + try: + from agent.runtime_cwd import clear_session_cwd + + clear_session_cwd() + except Exception: + pass def get_session_env(name: str, default: str = "") -> str: diff --git a/tests/agent/test_runtime_cwd.py b/tests/agent/test_runtime_cwd.py index 817e5271d480..827cfc09c220 100644 --- a/tests/agent/test_runtime_cwd.py +++ b/tests/agent/test_runtime_cwd.py @@ -6,7 +6,12 @@ import pytest import agent.runtime_cwd as rt -from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd +from agent.runtime_cwd import ( + clear_session_cwd, + resolve_agent_cwd, + resolve_context_cwd, + set_session_cwd, +) def _raise_oserror(*args, **kwargs): @@ -77,3 +82,48 @@ def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch): # than building Path(" ") and resolving garbage under the launch dir. monkeypatch.setenv("TERMINAL_CWD", " ") assert resolve_context_cwd() is None + + +class TestSessionCwdOverride: + """The #29531 per-session arm: a contextvar cwd wins over TERMINAL_CWD so a + multi-session gateway can pin each session to its own folder.""" + + def test_session_cwd_overrides_terminal_cwd(self, monkeypatch, tmp_path): + other = tmp_path / "other" + other.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd(str(other)) + try: + assert resolve_agent_cwd() == other + assert resolve_context_cwd() == other + finally: + rt._SESSION_CWD.reset(token) + + def test_empty_session_cwd_falls_back_to_terminal_cwd(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd("") + try: + assert resolve_agent_cwd() == tmp_path + assert resolve_context_cwd() == tmp_path + finally: + rt._SESSION_CWD.reset(token) + + def test_clear_session_cwd_restores_terminal_cwd(self, monkeypatch, tmp_path): + other = tmp_path / "other" + other.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd(str(other)) + try: + clear_session_cwd() + assert resolve_agent_cwd() == tmp_path + finally: + rt._SESSION_CWD.reset(token) + + def test_nonexistent_session_cwd_falls_back(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd(str(tmp_path / "gone")) + try: + # resolve_agent_cwd guards on isdir; a missing session cwd must not win. + assert resolve_agent_cwd() == tmp_path + finally: + rt._SESSION_CWD.reset(token) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6c93dd629d38..a89e3b00e317 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -10,6 +10,34 @@ from tui_gateway import server +def test_session_context_uses_session_cwd(monkeypatch, tmp_path): + """Desktop/TUI sessions must pin the agent cwd per session. + + The gateway process itself is often launched from apps/desktop in dev, so + falling back to os.getcwd() makes agents answer from the desktop app folder + even when the sidebar/session cwd is a real project. + """ + from agent.runtime_cwd import resolve_agent_cwd + + sid = "cwd-sid" + session_key = "cwd-key" + project = tmp_path / "project" + project.mkdir() + launcher = tmp_path / "apps" / "desktop" + launcher.mkdir(parents=True) + + server._sessions[sid] = {"session_key": session_key, "cwd": str(project)} + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.chdir(launcher) + + tokens = server._set_session_context(session_key) + try: + assert resolve_agent_cwd() == project + finally: + server._clear_session_context(tokens) + server._sessions.pop(sid, None) + + class _ChunkyStdout: def __init__(self): self.parts: list[str] = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 528e0b2b11fd..64e8c7011e3c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -809,11 +809,21 @@ def _save_cfg(cfg: dict): _cfg_mtime = None +def _cwd_for_session_key(session_key: str) -> str: + """Reverse-map session_key to the session's logical cwd.""" + if not session_key: + return "" + for sess in _sessions.values(): + if sess.get("session_key") == session_key: + return str(sess.get("cwd") or "") + return "" + + def _set_session_context(session_key: str) -> list: try: from gateway.session_context import set_session_vars - return set_session_vars(session_key=session_key) + return set_session_vars(session_key=session_key, cwd=_cwd_for_session_key(session_key)) except Exception: return [] @@ -2764,6 +2774,7 @@ def _(rid, params: dict) -> dict: explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd))) except Exception: explicit_cwd = False + resolved_cwd = _completion_cwd(params) _enable_gateway_prompts() ready = threading.Event() @@ -2782,7 +2793,7 @@ def _(rid, params: dict) -> dict: "history_lock": threading.Lock(), "history_version": 0, "image_counter": 0, - "cwd": _completion_cwd(params), + "cwd": resolved_cwd, "inflight_turn": None, "last_active": now, "pending_title": title or None, From 0b6614031cd3e24e2b6d26bb0a34a93c5e5dd9be Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 14:20:07 -0500 Subject: [PATCH 2/6] 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. --- apps/desktop/electron/main.cjs | 23 +++++++++++-------- .../desktop/src/components/error-boundary.tsx | 2 +- tui_gateway/server.py | 9 ++++++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 8ebc09db796d..10a4445c624d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3239,16 +3239,19 @@ function createWindow() { mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive')) - mainWindow.webContents.on('console-message', (eventOrLevel, message, line, sourceId) => { - const isObjectForm = eventOrLevel && typeof eventOrLevel === 'object' - const level = isObjectForm ? eventOrLevel.level : eventOrLevel - const isError = level === 'error' || level === 3 - - if (!isError) return - - const text = isObjectForm ? eventOrLevel.message : message - const src = isObjectForm ? eventOrLevel.sourceId : sourceId - const lineNo = isObjectForm ? eventOrLevel.lineNumber : line + // Electron always passes the event first. The canonical (Electron 36+) shape + // is (event, messageDetails); the deprecated positional shape is + // (event, level, message, line, sourceId). Handle both. `level` is numeric + // (0..3), where 3 === error. + mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => { + const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null + const level = details ? details.level : detailsOrLevel + + if (level !== 3) return + + const text = details ? details.message : message + const src = details ? details.sourceUrl : sourceId + const lineNo = details ? details.lineNumber : line rememberLog(`[renderer console] ${text} (${src}:${lineNo})`) }) diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx index f3be4aacc424..0afef1c9e2ab 100644 --- a/apps/desktop/src/components/error-boundary.tsx +++ b/apps/desktop/src/components/error-boundary.tsx @@ -80,7 +80,7 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { -
diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 64e8c7011e3c..d1981c8681a6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -810,10 +810,15 @@ def _save_cfg(cfg: dict): def _cwd_for_session_key(session_key: str) -> str: - """Reverse-map session_key to the session's logical cwd.""" + """Reverse-map session_key to the session's logical cwd. + + Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the + thread pool, so iterating the live view risks ``RuntimeError: dictionary + changed size during iteration``. + """ if not session_key: return "" - for sess in _sessions.values(): + for sess in list(_sessions.values()): if sess.get("session_key") == session_key: return str(sess.get("cwd") or "") return "" From 477fc8037786b4e1221ae0790cf7418769d6ea41 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 14:33:53 -0500 Subject: [PATCH 3/6] 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. --- apps/desktop/electron/main.cjs | 19 +++++++++++++++++++ .../src/app/right-sidebar/files/tree.tsx | 2 +- apps/desktop/src/store/session.ts | 8 +++----- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 10a4445c624d..db6a2b7b9d42 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -429,6 +429,13 @@ function registerMediaProtocol() { let mainWindow = null let hermesProcess = null let connectionPromise = null +// Auto-reload budget for renderer crashes. A deterministic startup crash would +// otherwise loop forever (reload → crash → reload), pinning CPU and spamming +// logs. Allow a few reloads per rolling window, then stop and leave the dead +// window so the user can read the error / quit. +const RENDERER_RELOAD_WINDOW_MS = 60_000 +const RENDERER_RELOAD_MAX = 3 +let rendererReloadTimes = [] // Latched bootstrap failure: when the first-launch install fails, we hold // onto the error so subsequent startHermes() calls (e.g. the renderer's // ensureGatewayOpen retrying after the WS won't open) return the same error @@ -3226,6 +3233,18 @@ function createWindow() { rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`) if (details?.reason === 'crashed' || details?.reason === 'oom') { + const now = Date.now() + rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS) + + if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) { + rememberLog( + `[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)` + ) + + return + } + + rendererReloadTimes.push(now) setImmediate(() => { if (!mainWindow || mainWindow.isDestroyed()) return try { diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx index 769b3d2667e6..40cc4ed8222f 100644 --- a/apps/desktop/src/app/right-sidebar/files/tree.tsx +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -67,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) } }, diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index ae34b2319d38..06bbb1ff0632 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -81,11 +81,9 @@ export const setCurrentFastMode = (next: Updater) => updateAtom($curren export const setCurrentCwd = (next: Updater) => { updateAtom($currentCwd, next) - const value = $currentCwd.get().trim() - - if (value) { - persistString(WORKSPACE_CWD_KEY, value) - } + // 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) => updateAtom($currentBranch, next) From 7b65e05e2990c5731555e0416a94df590fd253c6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 14:44:45 -0500 Subject: [PATCH 4/6] 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. --- gateway/session_context.py | 10 +++++----- tests/test_tui_gateway_server.py | 21 +++++++++++++++++++++ tui_gateway/server.py | 13 +++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/gateway/session_context.py b/gateway/session_context.py index f86fada971d0..8dfc84cac804 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -111,11 +111,11 @@ def set_session_vars( ) -> list: """Set all session context variables and return reset tokens. - Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore - the previous values when the handler exits. - - Returns a list of ``Token`` objects (one per variable) that can be - passed to ``clear_session_vars``. + Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler + exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress + the ``os.environ`` fallback) rather than restoring prior values — these + helpers are not nestable/stack-safe, and the returned tokens are accepted + only for API compatibility. ``cwd`` pins the logical working directory for this context. """ diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a89e3b00e317..df6adbc41f22 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -38,6 +38,27 @@ def test_session_context_uses_session_cwd(monkeypatch, tmp_path): server._sessions.pop(sid, None) +def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): + """Background/preview tasks use ephemeral ids absent from `_sessions`, so the + parent workspace is passed explicitly; it must pin instead of clearing back + to the gateway launch dir.""" + from agent.runtime_cwd import resolve_agent_cwd + + project = tmp_path / "project" + project.mkdir() + launcher = tmp_path / "apps" / "desktop" + launcher.mkdir(parents=True) + + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.chdir(launcher) + + tokens = server._set_session_context("bg_deadbe", cwd=str(project)) + try: + assert resolve_agent_cwd() == project + finally: + server._clear_session_context(tokens) + + class _ChunkyStdout: def __init__(self): self.parts: list[str] = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d1981c8681a6..ea7c18969245 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -824,11 +824,16 @@ def _cwd_for_session_key(session_key: str) -> str: return "" -def _set_session_context(session_key: str) -> list: +def _set_session_context(session_key: str, cwd: str | None = None) -> list: try: from gateway.session_context import set_session_vars - return set_session_vars(session_key=session_key, cwd=_cwd_for_session_key(session_key)) + # Ephemeral task IDs (background, preview) aren't in `_sessions`, so the + # reverse-map returns "" and would clear the cwd override. Callers that + # know the parent workspace pass it explicitly so spawned agents inherit + # it instead of falling back to the gateway launch dir. + resolved = cwd if cwd is not None else _cwd_for_session_key(session_key) + return set_session_vars(session_key=session_key, cwd=resolved) except Exception: return [] @@ -4640,7 +4645,7 @@ def _(rid, params: dict) -> dict: task_id = f"bg_{uuid.uuid4().hex[:6]}" def run(): - session_tokens = _set_session_context(task_id) + session_tokens = _set_session_context(task_id, cwd=_session_cwd(session)) try: from run_agent import AIAgent @@ -4726,7 +4731,7 @@ def _(rid, params: dict) -> dict: ) def run(): - session_tokens = _set_session_context(task_id) + session_tokens = _set_session_context(task_id, cwd=(cwd or _session_cwd(session))) try: from run_agent import AIAgent from tools.terminal_tool import register_task_env_overrides From b7e8916ef7a556ef03a07ba640b37ac5fb6e1031 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 14:57:11 -0500 Subject: [PATCH 5/6] 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. --- tui_gateway/server.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ea7c18969245..6e209825c7e5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4730,14 +4730,20 @@ def _(rid, params: dict) -> dict: if line ) + preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else "" + if preview_cwd and not os.path.isdir(preview_cwd): + preview_cwd = "" + def run(): - session_tokens = _set_session_context(task_id, cwd=(cwd or _session_cwd(session))) + # Pin the validated preview cwd, else the parent workspace — never an + # invalid client path, which would silently fall back to the launch dir. + session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session))) try: from run_agent import AIAgent from tools.terminal_tool import register_task_env_overrides - if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))): - register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))}) + if preview_cwd: + register_task_env_overrides(task_id, {"cwd": preview_cwd}) history_note = ( f" (with {len(parent_history)} parent-session messages of context)" From f94a31dd17800acd2a30573519210c71a0918edd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 15:18:39 -0500 Subject: [PATCH 6/6] 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. --- apps/desktop/src/app/session/hooks/use-cwd-actions.ts | 6 ++++++ tui_gateway/server.py | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index cbf2a8080285..ab301e593082 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -58,6 +58,12 @@ export function useCwdActions({ 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 { setCurrentBranch('') diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6e209825c7e5..7ef2a4cd1ad1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4730,8 +4730,13 @@ def _(rid, params: dict) -> dict: if line ) - preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else "" - if preview_cwd and not os.path.isdir(preview_cwd): + # Normalize defensively: a malformed client path (embedded NUL, etc.) must + # not blow up the whole restart — treat it as "no validated cwd". + try: + preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else "" + if preview_cwd and not os.path.isdir(preview_cwd): + preview_cwd = "" + except Exception: preview_cwd = "" def run():