Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ import {
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
applyWorkspaceForActiveProfile,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import {
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
setSessionsLoading,
setWorkspaceProfileContext
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'

Expand Down Expand Up @@ -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')
}

Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/store/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand Down
37 changes: 30 additions & 7 deletions apps/desktop/src/store/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
getRememberedWorkspaceCwd,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSessionAttention,
setSessionWorking,
setWorkspaceProfileContext,
workspaceCwdForNewSession
} from './session'

Expand Down Expand Up @@ -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[]
Expand All @@ -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[]
Expand All @@ -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')

Expand All @@ -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)

Expand Down
95 changes: 90 additions & 5 deletions apps/desktop/src/store/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = 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'
Expand All @@ -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

Expand Down Expand Up @@ -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: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<void> {
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<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
Expand Down
Loading