Merge pull request #63081 from NousResearch/bb/salvage-45744-workspace-target

fix(desktop): preserve sidebar workspace targets across new drafts (supersedes #45744)
This commit is contained in:
brooklyn! 2026-07-12 03:47:05 -05:00 committed by GitHub
commit 59686df8fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 406 additions and 57 deletions

View file

@ -47,7 +47,7 @@ import {
} from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
import { $startWorkSessionRequest, followActiveSessionCwd } from '../store/projects'
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
import {
$activeSessionId,
@ -65,8 +65,6 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages,
@ -117,6 +115,7 @@ import { useRouteResume } from './session/hooks/use-route-resume'
import { useSessionActions } from './session/hooks/use-session-actions'
import { useSessionListActions } from './session/hooks/use-session-list-actions'
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
import { startWorkspaceSession } from './session/workspace-session-target'
import { AppShell } from './shell/app-shell'
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
@ -735,42 +734,16 @@ export function DesktopController() {
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
// A worktree lane carries its own path; the trunk "+" can be path-less (the
// main checkout is implicit), so fall back to the active project's root
// instead of no-op'ing on null — that was "+ on main does nothing".
const target = path?.trim() || resolveNewSessionCwd()
if (!target) {
return
}
// The next message creates the backend session in $currentCwd, so seed
// it (and the branch) from the workspace the user clicked the + on.
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
const resolved = info.cwd || target
setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')
// An EXPLICIT target (a worktree/lane path — e.g. just-created via
// "convert a branch" / "new worktree") drills the sidebar into that
// project so the new lane is visible at once. Without this, a brand-new
// worktree session is invisible from the all-projects overview (the
// live overlay skips `.worktrees` rows, and the session.info cwd-follow
// only fires on a same-session move, not a fresh session). The
// path-less trunk "+" keeps the current scope untouched.
if (path?.trim()) {
restoreWorktree(resolved)
void followActiveSessionCwd(resolved)
}
})
.catch(() => undefined)
startWorkspaceSession({
activeSessionIdRef,
followActiveSessionCwd,
onExplicitWorkspace: restoreWorktree,
path,
requestGateway,
startFreshSessionDraft
})
},
[requestGateway, startFreshSessionDraft]
[activeSessionIdRef, requestGateway, startFreshSessionDraft]
)
// Composer "branch off into a new worktree": the composer already created the

View file

@ -0,0 +1,102 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$currentBranch,
$currentCwd,
$newChatWorkspaceTarget,
setCurrentBranch,
setCurrentCwd,
setCurrentCwdTransient,
setNewChatWorkspaceTarget
} from '@/store/session'
import { useCwdActions } from './use-cwd-actions'
type CwdActionsHandle = ReturnType<typeof useCwdActions>
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
function Harness({
activeSessionIdRef,
onReady,
requestGateway
}: {
activeSessionIdRef: MutableRefObject<string | null>
onReady: (handle: CwdActionsHandle) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const actions = useCwdActions({
activeSessionId: activeSessionIdRef.current,
activeSessionIdRef,
requestGateway
})
useEffect(() => {
onReady(actions)
}, [actions, onReady])
return null
}
describe('useCwdActions draft workspace target', () => {
beforeEach(() => {
setCurrentCwd('')
setCurrentBranch('')
setNewChatWorkspaceTarget(undefined)
})
afterEach(() => {
cleanup()
setCurrentCwd('')
setCurrentBranch('')
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
})
it('ignores stale draft cwd normalization after a newer no-workspace target wins', async () => {
const projectInfo = deferred<{ branch?: string; cwd?: string }>()
const requestGateway = vi.fn(async () => projectInfo.promise as never)
const activeSessionIdRef: MutableRefObject<string | null> = { current: null }
let handle: CwdActionsHandle | null = null
render(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(handle).not.toBeNull())
let pendingChange!: Promise<void>
await act(async () => {
pendingChange = handle!.changeSessionCwd('/stale-workspace')
})
expect($newChatWorkspaceTarget.get()).toBe('/stale-workspace')
setNewChatWorkspaceTarget(null)
setCurrentCwdTransient('')
projectInfo.resolve({ branch: 'main', cwd: '/normalized-stale-workspace' })
await act(async () => {
await pendingChange
})
expect($newChatWorkspaceTarget.get()).toBeNull()
expect($currentCwd.get()).toBe('')
expect($currentBranch.get()).toBe('')
})
})

View file

@ -2,7 +2,13 @@ import { type MutableRefObject, useCallback } from 'react'
import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import {
$currentCwd,
$newChatWorkspaceTargetGeneration,
setCurrentBranch,
setCurrentCwd,
setNewChatWorkspaceTarget
} from '@/store/session'
import type { SessionRuntimeInfo } from '@/types/hermes'
interface CwdActionsOptions {
@ -55,6 +61,7 @@ export function useCwdActions({
if (!activeSessionId) {
setCurrentCwd(trimmed)
const workspaceGeneration = setNewChatWorkspaceTarget(trimmed)
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
@ -62,15 +69,22 @@ export function useCwdActions({
cwd: trimmed
})
if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) {
return
}
// Adopt the backend's normalized cwd so the persisted workspace and
// branch stay consistent with what the agent will use.
if (info.cwd) {
setCurrentCwd(info.cwd)
setNewChatWorkspaceTarget(info.cwd)
}
setCurrentBranch(info.branch || '')
} catch {
setCurrentBranch('')
if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) {
setCurrentBranch('')
}
}
return
@ -103,7 +117,7 @@ export function useCwdActions({
})
}
},
[activeSessionId, copy, onSessionRuntimeInfo, requestGateway]
[activeSessionId, activeSessionIdRef, copy, onSessionRuntimeInfo, requestGateway]
)
return { changeSessionCwd, refreshProjectBranch }

View file

@ -1,4 +1,4 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@ -11,9 +11,12 @@ import {
$activeSessionId,
$currentCwd,
$messages,
$newChatWorkspaceTarget,
$resumeFailedSessionId,
setActiveSessionId,
setCurrentCwd,
setMessages,
setNewChatWorkspaceTarget,
setResumeFailedSessionId,
setSessions
} from '@/store/session'
@ -32,6 +35,7 @@ vi.mock('@/hermes', async importOriginal => ({
}))
const RUNTIME_SESSION_ID = 'rt-new-001'
type HarnessHandle = Pick<ReturnType<typeof useSessionActions>, 'createBackendSessionForSend' | 'startFreshSessionDraft'>
function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
@ -56,7 +60,7 @@ function Harness({
onReady,
requestGateway
}: {
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
onReady: (handle: HarnessHandle) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
@ -79,13 +83,16 @@ function Harness({
})
useEffect(() => {
onReady(actions.createBackendSessionForSend)
}, [actions.createBackendSessionForSend, onReady])
onReady(actions)
}, [actions, onReady])
return null
}
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
async function createWith(
profileSetup: () => void,
beforeCreate?: (handle: HarnessHandle) => Promise<void> | void
): Promise<Record<string, unknown> | undefined> {
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
@ -98,13 +105,23 @@ async function createWith(profileSetup: () => void): Promise<Record<string, unkn
return {} as never
})
$currentCwd.set('')
setCurrentCwd('')
setNewChatWorkspaceTarget(undefined)
profileSetup()
let create: ((preview?: string | null) => Promise<string | null>) | null = null
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
await waitFor(() => expect(create).not.toBeNull())
await create!()
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())
if (beforeCreate) {
await act(async () => {
await beforeCreate(handle!)
})
}
await act(async () => {
await handle!.createBackendSessionForSend()
})
return createParams
}
@ -117,6 +134,7 @@ describe('createBackendSessionForSend profile routing', () => {
$projectScope.set(ALL_PROJECTS)
$projectTree.set([])
$currentCwd.set('')
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
})
@ -613,3 +631,44 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
})
describe('createBackendSessionForSend workspace target', () => {
afterEach(() => {
cleanup()
$newChatProfile.set(null)
$activeGatewayProfile.set('default')
setCurrentCwd('')
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
})
it('omits cwd for an explicit no-workspace draft even when global cwd changes before send', async () => {
const params = await createWith(
() => {
$activeGatewayProfile.set('default')
},
handle => {
handle.startFreshSessionDraft({ workspaceTarget: null })
$currentCwd.set('/project-open-in-file-browser')
}
)
expect(params).not.toHaveProperty('cwd')
expect($newChatWorkspaceTarget.get()).toBeUndefined()
})
it('uses the clicked workspace target instead of a later global cwd value', async () => {
const params = await createWith(
() => {
$activeGatewayProfile.set('default')
},
handle => {
handle.startFreshSessionDraft({ workspaceTarget: '/clicked-workspace' })
$currentCwd.set('/project-open-in-file-browser')
}
)
expect(params).toMatchObject({ cwd: '/clicked-workspace' })
})
})

View file

@ -18,19 +18,23 @@ import {
$currentProvider,
$currentReasoningEffort,
$messages,
$newChatWorkspaceTarget,
$sessions,
$yoloActive,
type NewChatWorkspaceTarget,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentCwdTransient,
setCurrentServiceTier,
setCurrentUsage,
setFreshDraftReady,
setIntroSeed,
setMessages,
setNewChatWorkspaceTarget,
setResumeExhaustedSessionId,
setResumeFailedSessionId,
setSelectedStoredSessionId,
@ -83,6 +87,15 @@ interface SessionActionsOptions {
) => ClientSessionState
}
interface FreshSessionDraftOptions {
replaceRoute?: boolean
workspaceTarget?: NewChatWorkspaceTarget
}
function normalizeNewChatWorkspaceTarget(target: NewChatWorkspaceTarget): NewChatWorkspaceTarget {
return typeof target === 'string' ? target.trim() || null : target
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
@ -104,7 +117,17 @@ export function useSessionActions({
const resumeRequestRef = useRef(0)
const startFreshSessionDraft = useCallback(
(replaceRoute = false) => {
(options: boolean | FreshSessionDraftOptions = false) => {
const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options
const replaceRoute = draftOptions.replaceRoute ?? false
const hasWorkspaceTarget =
Object.hasOwn(draftOptions, 'workspaceTarget') && draftOptions.workspaceTarget !== undefined
const workspaceTarget = hasWorkspaceTarget
? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget)
: undefined
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
@ -132,10 +155,18 @@ export function useSessionActions({
// is cleared.
setCurrentServiceTier('')
setYoloActive(false)
// In a project → the repo's default-branch (main worktree) checkout; not in
// a project → detached. So cmd-n "knows" the project instead of inheriting
// whatever linked worktree the last session drifted into.
setCurrentCwd(resolveNewSessionCwd())
setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined)
if (!hasWorkspaceTarget) {
// In a project → the repo's default-branch checkout; not in a project →
// detached. So cmd-n does not inherit an unrelated linked worktree.
setCurrentCwd(resolveNewSessionCwd())
} else if (workspaceTarget === null) {
setCurrentCwdTransient('')
} else if (typeof workspaceTarget === 'string') {
setCurrentCwd(workspaceTarget)
}
setCurrentBranch('')
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
@ -162,7 +193,18 @@ export function useSessionActions({
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || resolveNewSessionCwd()
// An explicit one-shot workspace target (null → detached, string → that
// folder) wins; otherwise fall through to the live cwd, then the
// project-aware default (resolveNewSessionCwd).
const workspaceTarget = $newChatWorkspaceTarget.get()
const cwd =
workspaceTarget === null
? ''
: typeof workspaceTarget === 'string'
? workspaceTarget.trim()
: $currentCwd.get().trim() || resolveNewSessionCwd()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
@ -212,6 +254,7 @@ export function useSessionActions({
}
setFreshDraftReady(false)
setNewChatWorkspaceTarget(undefined)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())

View file

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
$currentBranch,
$currentCwd,
$newChatWorkspaceTarget,
setCurrentBranch,
setCurrentCwd,
setNewChatWorkspaceTarget
} from '@/store/session'
import { startWorkspaceSession } from './workspace-session-target'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
describe('startWorkspaceSession', () => {
afterEach(() => {
setCurrentBranch('')
setCurrentCwd('')
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
})
it('keeps a newer sidebar target when an older project lookup resolves', async () => {
const first = deferred<{ branch?: string; cwd?: string }>()
const second = deferred<{ branch?: string; cwd?: string }>()
const requestGateway = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise)
const activeSessionIdRef = { current: null }
const startFreshSessionDraft = vi.fn((options?: { workspaceTarget: string }) => {
setNewChatWorkspaceTarget(options?.workspaceTarget)
setCurrentCwd(options?.workspaceTarget || '')
})
const followActiveSessionCwd = vi.fn()
startWorkspaceSession({
activeSessionIdRef,
followActiveSessionCwd,
path: '/workspace-a',
requestGateway,
startFreshSessionDraft
})
startWorkspaceSession({
activeSessionIdRef,
followActiveSessionCwd,
path: '/workspace-b',
requestGateway,
startFreshSessionDraft
})
first.resolve({ branch: 'stale', cwd: '/normalized-a' })
await first.promise
await Promise.resolve()
expect($newChatWorkspaceTarget.get()).toBe('/workspace-b')
expect($currentCwd.get()).toBe('/workspace-b')
expect($currentBranch.get()).not.toBe('stale')
second.resolve({ branch: 'main', cwd: '/normalized-b' })
await second.promise
await Promise.resolve()
expect($newChatWorkspaceTarget.get()).toBe('/normalized-b')
expect($currentCwd.get()).toBe('/normalized-b')
expect($currentBranch.get()).toBe('main')
})
})

View file

@ -0,0 +1,64 @@
import type { MutableRefObject } from 'react'
import { followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
import {
$newChatWorkspaceTargetGeneration,
setCurrentBranch,
setCurrentCwd,
setNewChatWorkspaceTarget
} from '@/store/session'
interface WorkspaceSessionOptions {
activeSessionIdRef: MutableRefObject<string | null>
followActiveSessionCwd?: (cwd: string) => void | Promise<void>
onExplicitWorkspace?: (cwd: string) => void
path: null | string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
startFreshSessionDraft: (options?: { workspaceTarget: string }) => void
}
export function startWorkspaceSession({
activeSessionIdRef,
followActiveSessionCwd: followCwd = followActiveSessionCwd,
onExplicitWorkspace,
path,
requestGateway,
startFreshSessionDraft
}: WorkspaceSessionOptions): void {
// A worktree lane carries its own path; a project trunk can be path-less, so
// fall back to the active project's root for that existing controller path.
const explicitTarget = path?.trim()
const target = explicitTarget || resolveNewSessionCwd()
startFreshSessionDraft(target ? { workspaceTarget: target } : undefined)
if (!target) {
return
}
const workspaceGeneration = $newChatWorkspaceTargetGeneration.get()
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) {
return
}
const resolved = info.cwd || target
setCurrentCwd(resolved)
setNewChatWorkspaceTarget(resolved)
setCurrentBranch(info.branch || '')
if (explicitTarget) {
onExplicitWorkspace?.(resolved)
void followCwd(resolved)
}
})
.catch(() => {
if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) {
setCurrentBranch('')
}
})
}

View file

@ -42,6 +42,7 @@ function workspaceCwdKey(connection: HermesConnection | null = $connection.get()
}
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
export type NewChatWorkspaceTarget = null | string | undefined
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
@ -270,6 +271,8 @@ export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
// reflection of the truth the gateway reports rather than its own store.
export const $yoloActive = atom(false)
export const $currentCwd = atom(getRememberedWorkspaceCwd())
export const $newChatWorkspaceTarget = atom<NewChatWorkspaceTarget>(undefined)
export const $newChatWorkspaceTargetGeneration = atom(0)
export const $currentBranch = atom('')
export const $currentUsage = atom<UsageStats>({
calls: 0,
@ -338,6 +341,16 @@ export const setCurrentCwd = (next: Updater<string>) => {
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
}
export const setCurrentCwdTransient = (next: Updater<string>) => updateAtom($currentCwd, next)
export const setNewChatWorkspaceTarget = (next: NewChatWorkspaceTarget): number => {
const generation = $newChatWorkspaceTargetGeneration.get() + 1
$newChatWorkspaceTarget.set(next)
$newChatWorkspaceTargetGeneration.set(generation)
return generation
}
export const workspaceCwdForNewSession = (): string => {
if ($connection.get()?.mode === 'remote') {
return getRememberedWorkspaceCwd()