From d63a1c4ccb1c06f3d5ed187136f36b6df3858fcd Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 22 Jul 2026 22:44:26 -0400 Subject: [PATCH] fix(desktop): separate workspace defaults from live cwd (#69765) --- .../e2e/worktree-branch-status.spec.ts | 2 +- apps/desktop/src/app/contrib/wiring.tsx | 5 +- .../session/hooks/use-hermes-config.test.ts | 120 ++---------------- .../app/session/hooks/use-hermes-config.ts | 17 +-- apps/desktop/src/store/session.test.ts | 9 ++ apps/desktop/src/store/session.ts | 6 - 6 files changed, 23 insertions(+), 136 deletions(-) diff --git a/apps/desktop/e2e/worktree-branch-status.spec.ts b/apps/desktop/e2e/worktree-branch-status.spec.ts index 8188a074cf01..a3d5dac217e1 100644 --- a/apps/desktop/e2e/worktree-branch-status.spec.ts +++ b/apps/desktop/e2e/worktree-branch-status.spec.ts @@ -73,7 +73,6 @@ test('creating a branch with ctrl-shift-b updates the composer git-status branch const codingRow = page.locator('.coding-status-bar') const composer = page.locator('[contenteditable="true"]').first() - await expect(codingRow).toContainText('main') await composer.click() await composer.type('create a repo-backed e2e session', { delay: 2 }) await page.keyboard.press('Enter') @@ -82,6 +81,7 @@ test('creating a branch with ctrl-shift-b updates the composer git-status branch 'create a repo-backed e2e session', { timeout: 15_000 }, ) + await expect(codingRow).toContainText('main') await page.keyboard.press('Control+Shift+B') const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first() diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 21e866c3f4d3..e10a998bf32f 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -253,10 +253,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { requestGateway }) - const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ - activeSessionIdRef, - refreshProjectBranch - }) + const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef }) const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ queryClient, diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts index 576fcad5ccd8..d562fae960f5 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts @@ -47,133 +47,38 @@ describe('useHermesConfig refreshHermesConfig', () => { persistString(WORKSPACE_CWD_KEY, null) }) - it('applies terminal.cwd from config even when localStorage has a stale value', async () => { - // Simulate a stale remembered workspace cwd - persistString(WORKSPACE_CWD_KEY, '/Users/old/stale-project') - setCurrentCwd('/Users/old/stale-project') + it('does not let terminal.cwd replace an inactive selected workspace', async () => { + setCurrentCwd('/Users/example/repo/.worktrees/feature') mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } }) - - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: null }, - refreshProjectBranch: vi.fn().mockResolvedValue(undefined) - }) - ) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) await act(async () => { await result.current.refreshHermesConfig() }) - // The configured terminal.cwd must override the stale localStorage value - expect($currentCwd.get()).toBe('/Users/example/new-workspace') + expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/feature') }) - it('keeps the active session workspace when a session is running', async () => { - setCurrentCwd('/workspace/attached-project') + it('does not let terminal.cwd replace an active session workspace', async () => { + setCurrentCwd('/Users/example/repo/.worktrees/attached') mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } }) - - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: 'session-1' }, - refreshProjectBranch: vi.fn().mockResolvedValue(undefined) - }) - ) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: 'session-1' } })) await act(async () => { await result.current.refreshHermesConfig() }) - // Config refreshes mid-session must not yank the workspace out from - // under the attached session. - expect($currentCwd.get()).toBe('/workspace/attached-project') + expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/attached') }) - it('uses empty string when terminal.cwd is not set and localStorage is empty', async () => { - mockConfig({}) - - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: null }, - refreshProjectBranch: vi.fn().mockResolvedValue(undefined) - }) - ) - - await act(async () => { - await result.current.refreshHermesConfig() - }) - - expect($currentCwd.get()).toBe('') - }) - - it('ignores terminal.cwd when it is "."', async () => { - mockConfig({ terminal: { cwd: '.' } }) - - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: null }, - refreshProjectBranch: vi.fn().mockResolvedValue(undefined) - }) - ) - - await act(async () => { - await result.current.refreshHermesConfig() - }) - - expect($currentCwd.get()).toBe('') - }) - - it('calls refreshProjectBranch with the configured cwd', async () => { - const refreshProjectBranch = vi.fn().mockResolvedValue(undefined) - setCurrentCwd('') - - mockConfig({ terminal: { cwd: '/workspace/project-a' } }) - - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: null }, - refreshProjectBranch - }) - ) - - await act(async () => { - await result.current.refreshHermesConfig() - }) - - expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/project-a') - }) - - it('refreshes the branch for the session cwd (not config) when a session is active', async () => { - const refreshProjectBranch = vi.fn().mockResolvedValue(undefined) - setCurrentCwd('/workspace/attached-project') - - mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } }) - - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: 'session-1' }, - refreshProjectBranch - }) - ) - - await act(async () => { - await result.current.refreshHermesConfig() - }) - - expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/attached-project') - }) it('does not let a stale forced config refresh overwrite newer draft selector intent', async () => { const profileConfig = deferred>>() vi.mocked(getHermesConfig).mockReturnValueOnce(profileConfig.promise) - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: null }, - refreshProjectBranch: vi.fn().mockResolvedValue(undefined) - }) - ) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) let pendingRefresh!: Promise act(() => { @@ -203,12 +108,7 @@ describe('useHermesConfig refreshHermesConfig', () => { const profileC = deferred>>() vi.mocked(getHermesConfig).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise) - const { result } = renderHook(() => - useHermesConfig({ - activeSessionIdRef: { current: null }, - refreshProjectBranch: vi.fn().mockResolvedValue(undefined) - }) - ) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) let refreshB!: Promise let refreshC!: Promise diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 52681e20a858..5d78fb1c6c51 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -4,11 +4,9 @@ import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' import { normalize } from '@/lib/text' import { - $currentCwd, getComposerSelectionGeneration, getCurrentModelSource, setAvailablePersonalities, - setCurrentCwd, setCurrentFastMode, setCurrentPersonality, setCurrentReasoningEffort, @@ -43,10 +41,9 @@ function normalizeConfigEffort(value: unknown): string { interface HermesConfigOptions { activeSessionIdRef: MutableRefObject - refreshProjectBranch: (cwd: string) => Promise } -export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) { +export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS) const [sttEnabled, setSttEnabled] = useState(true) const profileRefreshEpochRef = useRef(0) @@ -83,16 +80,6 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He ]) ]) - const cwd = (config.terminal?.cwd ?? '').trim() - - if (cwd && cwd !== '.') { - // Configured terminal.cwd beats a stale remembered workspace cwd - // (#38855) — but never yank the workspace out from under an active - // session; those keep their own cwd until the user detaches. - setCurrentCwd(prev => (activeSessionIdRef.current ? prev : cwd)) - void refreshProjectBranch($currentCwd.get() || cwd) - } - const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort) const tier = (config.agent?.service_tier ?? '').trim() @@ -115,7 +102,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He // Config is nice-to-have; chat still works without it. } }, - [activeSessionIdRef, refreshProjectBranch] + [activeSessionIdRef] ) return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index e4b9b08b66d0..6de3e67ed092 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -222,6 +222,15 @@ describe('workspaceCwdForNewSession', () => { expect(workspaceCwdForNewSession()).toBe('/home/user/configured') }) + it('keeps the configured default separate from a selected workspace', () => { + setCurrentCwd('/home/user/repo/.worktrees/feature') + + applyConfiguredDefaultProjectDir('/home/user/configured') + + expect(workspaceCwdForNewSession()).toBe('/home/user/configured') + expect($currentCwd.get()).toBe('/home/user/repo/.worktrees/feature') + }) + it('starts detached (no inherited cwd) when no default project dir is configured', () => { // A bare new chat must NOT inherit the sticky/remembered or live workspace — // that's the "why is my new session already on a branch" bug. Only an diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 3c8ea0b714e5..0f0a64ae6b47 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -113,12 +113,6 @@ export async function ensureDefaultWorkspaceCwd(): Promise { 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 {