diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index c8cb9facc13f..aa812d28cb00 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -56,11 +56,12 @@ import { $gatewayState, $messages, $messagingSessions, - $resumeFailedSessionId, $resumeExhaustedSessionId, + $resumeFailedSessionId, $selectedStoredSessionId, $sessions, $workingSessionIds, + applyWorkspaceForActiveProfile, CRON_SECTION_LIMIT, getRecentlySettledSessionIds, mergeSessionPage, @@ -726,7 +727,8 @@ export function DesktopController() { // already shows the previous profile's model. void refreshCurrentModel(true) void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel]) + void applyWorkspaceForActiveProfile(requestGateway) + }, [activeGatewayProfile, refreshCurrentModel, requestGateway]) const composer = useComposerActions({ activeSessionId, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 593e7a36f748..a278c2d0fbba 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -36,7 +36,8 @@ import { setConnection, setCurrentBranch, setCurrentCwd, - setSessionsLoading + setSessionsLoading, + setWorkspaceProfileContext } from '@/store/session' import type { RpcEvent } from '@/types/hermes' @@ -345,10 +346,12 @@ export function useGatewayBoot({ try { const pref = await desktop.profile?.get?.() const profileKey = (pref?.profile ?? '').trim() || 'default' + setWorkspaceProfileContext(profileKey) $activeGatewayProfile.set(profileKey) setPrimaryGateway(gateway, profileKey) void ensureGatewayForProfile(profileKey) } catch { + setWorkspaceProfileContext('default') $activeGatewayProfile.set('default') } diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 4c8ffc3540fa..c8d2201952c4 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -12,7 +12,7 @@ import { storedStringRecord } from '@/lib/storage' import { $gateway, ensureGatewayForProfile } from '@/store/gateway' -import { setConnection } from '@/store/session' +import { setConnection, setWorkspaceProfileContext } from '@/store/session' import type { ProfileInfo } from '@/types/hermes' // Canonical key for a profile: trimmed, empty → "default". Used everywhere we @@ -244,6 +244,7 @@ export async function ensureGatewayProfile(profile: string | null | undefined): // ensureGatewayForProfile opens (or reuses) the target's socket and points // the active gateway at it — without closing the profile you came from. await ensureGatewayForProfile(target) + setWorkspaceProfileContext(target) $activeGatewayProfile.set(target) // The active backend just changed; resync $connection so remote-aware // paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow. @@ -288,6 +289,7 @@ export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile], // $activeGatewayProfile → name, so $profileScope follows). export function selectProfile(name: string): void { const target = normalizeProfileKey(name) + setWorkspaceProfileContext(target) // Switching profiles (or coming back from the all-profiles browse view) starts // fresh; re-tapping the profile you're already in leaves your session be. const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get()) @@ -309,6 +311,7 @@ export function selectProfile(name: string): void { // message lands in the right place. export function newSessionInProfile(name: string): void { const target = normalizeProfileKey(name) + setWorkspaceProfileContext(target) $newChatProfile.set(target) requestFreshSession() void ensureGatewayProfile(target) diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index bc7866e60b12..1a0f38362d45 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -10,11 +10,13 @@ import { $workingSessionIds, applyConfiguredDefaultProjectDir, getRecentlySettledSessionIds, + getRememberedWorkspaceCwd, mergeSessionPage, sessionPinId, setCurrentCwd, setSessionAttention, setSessionWorking, + setWorkspaceProfileContext, workspaceCwdForNewSession } from './session' @@ -152,6 +154,7 @@ describe('mergeSessionPage', () => { session({ id: 'tip-4', _lineage_root_id: 'root' }), session({ id: 'other' }), ] as SessionInfo[] + const incoming = [ session({ id: 'tip-5', _lineage_root_id: 'root' }), ] as SessionInfo[] @@ -173,6 +176,7 @@ describe('mergeSessionPage', () => { session({ id: 'a-old', _lineage_root_id: 'lineage-a' }), session({ id: 'b', _lineage_root_id: 'lineage-b' }), ] as SessionInfo[] + const incoming = [ session({ id: 'a-new', _lineage_root_id: 'lineage-a' }), ] as SessionInfo[] @@ -189,24 +193,43 @@ describe('workspaceCwdForNewSession', () => { $connection.set(null) $currentCwd.set('') $activeSessionId.set(null) - window.localStorage.removeItem('hermes.desktop.workspace-cwd') - window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default') - window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default') + setWorkspaceProfileContext('default') + + for (let i = window.localStorage.length - 1; i >= 0; i -= 1) { + const key = window.localStorage.key(i) + + if (key?.startsWith('hermes.desktop.workspace-cwd')) { + window.localStorage.removeItem(key) + } + } }) - it('prefers the configured default over the sticky remembered workspace', () => { - window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky') + it('prefers the configured default over the remembered workspace', () => { + setCurrentCwd('/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') + setCurrentCwd('/home/user/sticky') expect(workspaceCwdForNewSession()).toBe('/home/user/sticky') }) + it('isolates remembered workspace across workspace profile context changes', () => { + setWorkspaceProfileContext('ctx-one') + setCurrentCwd('/tmp/ws-one') + setWorkspaceProfileContext('ctx-two') + setCurrentCwd('/tmp/ws-two') + + setWorkspaceProfileContext('ctx-one') + expect(getRememberedWorkspaceCwd()).toBe('/tmp/ws-one') + + setWorkspaceProfileContext('ctx-two') + expect(getRememberedWorkspaceCwd()).toBe('/tmp/ws-two') + }) + it('falls back to the live cwd when neither configured nor remembered values exist', () => { $currentCwd.set('/home/user/live') @@ -223,7 +246,7 @@ describe('workspaceCwdForNewSession', () => { }) it('keeps remote workspace memory separate from local and other remotes', () => { - window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project') + window.localStorage.setItem('hermes.desktop.workspace-cwd.local.default', '/local/project') $currentCwd.set('/live/session/path') $connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 958801df1f36..17db1cbc1e65 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -10,12 +10,22 @@ import type { SessionInfo, UsageStats } from '@/types/hermes' type Updater = T | ((current: T) => T) const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' +const LEGACY_WORKSPACE_CWD_KEY = WORKSPACE_CWD_KEY + +// Which profile's workspace localStorage row we read/write. Updated on profile +// switch before fresh-session drafts so Cmd+N and new chats stay scoped. +export const $workspaceProfileKey = atom('default') + +export function setWorkspaceProfileContext(name: string | null | undefined): void { + const value = (name ?? '').trim() || 'default' + $workspaceProfileKey.set(value) +} // The composer's model/effort/fast is sticky UI state, NOT the profile default // (that lives in Settings → Model). Persisting it in localStorage makes a pick // follow across Cmd+N and app restarts instead of snapping back to the default. -// It's deliberately global (not per-profile): a profile switch force-reseeds to -// that profile's default, while within a profile new chats keep your last pick. +// Workspace cwd IS per-profile (local + remote): switching profiles restores that +// profile's last folder, or terminal.cwd from its config when none is remembered. const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model' const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider' const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort' @@ -24,16 +34,40 @@ const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast' let configuredDefaultProjectDir = '' function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { + const profile = encodeURIComponent($workspaceProfileKey.get()) + if (connection?.mode !== 'remote') { - return WORKSPACE_CWD_KEY + return `${WORKSPACE_CWD_KEY}.local.${profile}` } const base = encodeURIComponent(connection.baseUrl || 'remote') - const profile = encodeURIComponent(connection.profile || 'default') + return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}` } -export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || '' +function readRememberedWorkspaceCwd(connection: HermesConnection | null = $connection.get()): string { + const key = workspaceCwdKey(connection) + const scoped = storedString(key)?.trim() + + if (scoped) { + return scoped + } + + // Migrate the pre-per-profile single key into the default profile slot once. + if (connection?.mode !== 'remote' && $workspaceProfileKey.get() === 'default') { + const legacy = storedString(LEGACY_WORKSPACE_CWD_KEY)?.trim() + + if (legacy) { + persistString(key, legacy) + + return legacy + } + } + + return '' +} + +export const getRememberedWorkspaceCwd = (): string => readRememberedWorkspaceCwd() export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir @@ -321,6 +355,57 @@ export const workspaceCwdForNewSession = (): string => { return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim() } +/** After the gateway swaps to a profile, seed the draft workspace from that + * profile's remembered folder or its config `terminal.cwd`. */ +export async function applyWorkspaceForActiveProfile( + requestGateway: (method: string, params?: Record) => Promise +): Promise { + if ($activeSessionId.get()) { + return + } + + const remembered = getRememberedWorkspaceCwd() + + if (remembered) { + setCurrentCwd(remembered) + + try { + const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { + key: 'project', + cwd: remembered + }) + + if (!$activeSessionId.get()) { + if (info.cwd) { + setCurrentCwd(info.cwd) + } + + setCurrentBranch(info.branch || '') + } + } catch { + if (!$activeSessionId.get()) { + setCurrentBranch('') + } + } + + return + } + + try { + const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { + key: 'project' + }) + + if (!$activeSessionId.get() && info.cwd?.trim()) { + setCurrentCwd(info.cwd.trim()) + setCurrentBranch(info.branch || '') + } + } catch { + // Leave the prior draft cwd; session.create can omit cwd and let the gateway + // resolve from the bound profile when the user sends the first message. + } +} + export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next) export const setCurrentUsage = (next: Updater) => updateAtom($currentUsage, next) export const setSessionStartedAt = (next: Updater) => updateAtom($sessionStartedAt, next)