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:
@ -6,16 +6,42 @@ gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
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
|
||||
|
||||
@ -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
|
||||
@ -3222,6 +3229,51 @@ 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') {
|
||||
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 {
|
||||
mainWindow.webContents.reload()
|
||||
} catch (err) {
|
||||
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
|
||||
|
||||
// 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})`)
|
||||
})
|
||||
|
||||
if (DEV_SERVER) {
|
||||
mainWindow.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
@ -3372,13 +3424,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
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
91
apps/desktop/src/components/error-boundary.tsx
Normal file
91
apps/desktop/src/components/error-boundary.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -107,14 +107,17 @@ 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.
|
||||
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
|
||||
the previous values when the handler exits.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
@ -6,7 +6,12 @@ from pathlib import Path
|
||||
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 @@ class TestResolveContextCwd:
|
||||
# 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)
|
||||
|
||||
@ -10,6 +10,55 @@ from unittest.mock import patch
|
||||
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)
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
@ -809,11 +809,31 @@ def _save_cfg(cfg: dict):
|
||||
_cfg_mtime = None
|
||||
|
||||
|
||||
def _set_session_context(session_key: str) -> list:
|
||||
def _cwd_for_session_key(session_key: str) -> str:
|
||||
"""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 list(_sessions.values()):
|
||||
if sess.get("session_key") == session_key:
|
||||
return str(sess.get("cwd") or "")
|
||||
return ""
|
||||
|
||||
|
||||
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)
|
||||
# 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 []
|
||||
|
||||
@ -2764,6 +2784,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 +2803,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,
|
||||
@ -4624,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
|
||||
|
||||
@ -4709,14 +4730,25 @@ def _(rid, params: dict) -> dict:
|
||||
if line
|
||||
)
|
||||
|
||||
# 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():
|
||||
session_tokens = _set_session_context(task_id)
|
||||
# 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)"
|
||||
|
||||
Reference in New Issue
Block a user