fix(desktop): honor default project directory for new sessions (#43234)

* fix(desktop): honor default project directory for new sessions

The Settings picker persisted project-dir.json but the renderer kept
seeding new chats from sticky localStorage home. Prefer the configured
default on boot and session.create, pin TERMINAL_CWD at backend spawn,
and reject packaged install-dir paths that regressed after #37536.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): address review on default project dir PR

Add workspace cwd precedence tests, extract isPackagedInstallPath for
platform test coverage, and stop rewriting live $currentCwd when a
session is already active (cache-only until the next new chat).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-06-09 23:28:59 -05:00
committed by GitHub
co-authored by Cursor
parent 8f73d0d945
commit 1770263ccc
12 changed files with 263 additions and 12 deletions
@@ -29,6 +29,7 @@ import {
$connection,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setSessionsLoading
} from '@/store/session'
@@ -351,6 +352,7 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await ensureDefaultWorkspaceCwd()
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
@@ -20,6 +20,7 @@ import {
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
workspaceCwdForNewSession,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
@@ -311,8 +312,9 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
// New chats start in the configured default project dir when set,
// otherwise the sticky last-used workspace (PR #37586).
setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
@@ -333,7 +335,7 @@ export function useSessionActions({
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
@@ -8,7 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
@@ -196,6 +196,7 @@ function DefaultProjectDirSetting() {
setDir(result.dir)
setFallback(result.defaultLabel)
applyConfiguredDefaultProjectDir(result.dir)
})
return () => {
@@ -221,7 +222,8 @@ function DefaultProjectDirSetting() {
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
applyConfiguredDefaultProjectDir(result.dir)
notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
} catch (err) {
notifyError(err, s.updateDirFailed)
} finally {
@@ -241,6 +243,8 @@ function DefaultProjectDirSetting() {
try {
await settings.setDefaultProjectDir(null)
setDir(null)
applyConfiguredDefaultProjectDir(null)
await ensureDefaultWorkspaceCwd()
} catch (err) {
notifyError(err, s.clearDirFailed)
} finally {
@@ -268,7 +272,7 @@ function DefaultProjectDirSetting() {
)}
</div>
}
description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
description={dir || s.defaultsTo(fallback || '~')}
title={dir ? dir : s.notSet}
/>
</div>
+2 -1
View File
@@ -55,8 +55,9 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string; resolvedCwd: string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
+1 -1
View File
@@ -519,7 +519,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: 'Default project directory updated',
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
+42 -1
View File
@@ -3,13 +3,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import {
$activeSessionId,
$attentionSessionIds,
$currentCwd,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setSessionAttention,
setSessionWorking
setSessionWorking,
workspaceCwdForNewSession
} from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
@@ -138,6 +142,43 @@ describe('mergeSessionPage', () => {
})
})
describe('workspaceCwdForNewSession', () => {
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$currentCwd.set('')
$activeSessionId.set(null)
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
})
it('prefers the configured default over the sticky remembered workspace', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
applyConfiguredDefaultProjectDir('/home/user/configured')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('falls back to the remembered workspace when no configured default is set', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
expect(workspaceCwdForNewSession()).toBe('/home/user/sticky')
})
it('falls back to the live cwd when neither configured nor remembered values exist', () => {
$currentCwd.set('/home/user/live')
expect(workspaceCwdForNewSession()).toBe('/home/user/live')
})
it('does not rewrite the live cwd while a session is active', () => {
$activeSessionId.set('sess-1')
$currentCwd.set('/live/session/path')
applyConfiguredDefaultProjectDir('/home/user/configured')
expect($currentCwd.get()).toBe('/live/session/path')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
})
describe('getRecentlySettledSessionIds', () => {
afterEach(() => {
vi.useRealTimers()
+68
View File
@@ -10,8 +10,71 @@ type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// Cached copy of Settings → Sessions → Default project directory. The main
// process persists this in project-dir.json, but the renderer must also honor it
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
// wins and new sessions ignore the user's explicit picker choice.
let configuredDefaultProjectDir = ''
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
export async function syncConfiguredDefaultProjectDir(): Promise<string> {
const settings = window.hermesDesktop?.settings?.getDefaultProjectDir
if (!settings) {
configuredDefaultProjectDir = ''
return ''
}
const { dir } = await settings()
configuredDefaultProjectDir = dir?.trim() || ''
return configuredDefaultProjectDir
}
/** Align the renderer workspace with the main-process default (home dir when
* packaged, optional Settings override). Clears stale install-dir paths that
* PR #37586's localStorage stickiness can preserve across the #37536 fix. */
export async function ensureDefaultWorkspaceCwd(): Promise<void> {
const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
if (!sanitize) {
return
}
await syncConfiguredDefaultProjectDir()
const configured = getConfiguredDefaultProjectDir()
const seedLiveCwd = (cwd: string) => {
if (cwd && !$activeSessionId.get()) {
setCurrentCwd(cwd)
}
}
if (configured) {
const { cwd } = await sanitize(configured)
seedLiveCwd(cwd)
return
}
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
seedLiveCwd(cwd)
}
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
configuredDefaultProjectDir = dir?.trim() || ''
// Cache only — new chats read this via workspaceCwdForNewSession(). Do not
// rewrite the live workspace (or localStorage) while a session is active.
if (configuredDefaultProjectDir && !$activeSessionId.get()) {
setCurrentCwd(configuredDefaultProjectDir)
}
}
interface AppAtom<T> {
get: () => T
set: (value: T) => void
@@ -171,6 +234,11 @@ export const setCurrentCwd = (next: Updater<string>) => {
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
}
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
* fall back to the sticky last-used folder, then whatever is already live. */
export const workspaceCwdForNewSession = (): string =>
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
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)