fix(desktop): separate workspace defaults from live cwd (#69765)

This commit is contained in:
ethernet 2026-07-22 22:44:26 -04:00 committed by GitHub
parent b162371f50
commit d63a1c4ccb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 23 additions and 136 deletions

View file

@ -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()

View file

@ -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,

View file

@ -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<Awaited<ReturnType<typeof getHermesConfig>>>()
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<void>
act(() => {
@ -203,12 +108,7 @@ describe('useHermesConfig refreshHermesConfig', () => {
const profileC = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
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<void>
let refreshC!: Promise<void>

View file

@ -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<string | null>
refreshProjectBranch: (cwd: string) => Promise<void>
}
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 }

View file

@ -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

View file

@ -113,12 +113,6 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
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> {