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:
@@ -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>
|
||||
|
||||
Vendored
+2
-1
@@ -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 }>
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user