From 133181f00deeaf331c7635cde10f3ae549c6f207 Mon Sep 17 00:00:00 2001 From: harjoth Date: Sat, 13 Jun 2026 10:39:53 -0700 Subject: [PATCH 1/4] fix(desktop): honor no-workspace sidebar new sessions --- apps/desktop/src/app/desktop-controller.tsx | 21 +++- .../session/hooks/use-cwd-actions.test.tsx | 102 ++++++++++++++++++ .../src/app/session/hooks/use-cwd-actions.ts | 20 +++- .../hooks/use-session-actions.test.tsx | 81 ++++++++++++-- .../hooks/use-session-actions/index.ts | 50 +++++++-- apps/desktop/src/store/session.ts | 13 +++ 6 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index cb7b10f655b7..6b9f02aa77e4 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -57,6 +57,7 @@ import { $gatewayState, $messages, $messagingSessions, + $newChatWorkspaceTargetGeneration, $resumeExhaustedSessionId, $resumeFailedSessionId, $selectedStoredSessionId, @@ -70,6 +71,7 @@ import { setCurrentModel, setCurrentProvider, setMessages, + setNewChatWorkspaceTarget, setRememberedSessionId } from '../store/session' import { onSessionsChanged } from '../store/session-sync' @@ -735,25 +737,32 @@ 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() + startFreshSessionDraft({ workspaceTarget: target || null }) + if (!target) { return } + const workspaceGeneration = $newChatWorkspaceTargetGeneration.get() + // 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 => { + if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) { + return + } + const resolved = info.cwd || target setCurrentCwd(resolved) + setNewChatWorkspaceTarget(resolved) setCurrentBranch(info.branch || '') // An EXPLICIT target (a worktree/lane path — e.g. just-created via @@ -768,9 +777,13 @@ export function DesktopController() { void followActiveSessionCwd(resolved) } }) - .catch(() => undefined) + .catch(() => { + if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { + setCurrentBranch('') + } + }) }, - [requestGateway, startFreshSessionDraft] + [activeSessionIdRef, requestGateway, startFreshSessionDraft] ) // Composer "branch off into a new worktree": the composer already created the diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx new file mode 100644 index 000000000000..aba64c51738d --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx @@ -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 + +function deferred() { + let resolve!: (value: T) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + +function Harness({ + activeSessionIdRef, + onReady, + requestGateway +}: { + activeSessionIdRef: MutableRefObject + onReady: (handle: CwdActionsHandle) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + 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 = { current: null } + let handle: CwdActionsHandle | null = null + + render( + (handle = h)} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + let pendingChange!: Promise + + 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('') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index 2308191b8b1b..8226a2f59501 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -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 } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index e00960de2f2a..115d306264d7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -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' @@ -10,9 +10,12 @@ import { $activeSessionId, $currentCwd, $messages, + $newChatWorkspaceTarget, $resumeFailedSessionId, setActiveSessionId, + setCurrentCwd, setMessages, + setNewChatWorkspaceTarget, setResumeFailedSessionId, setSessions } from '@/store/session' @@ -31,6 +34,7 @@ vi.mock('@/hermes', async importOriginal => ({ })) const RUNTIME_SESSION_ID = 'rt-new-001' +type HarnessHandle = Pick, 'createBackendSessionForSend' | 'startFreshSessionDraft'> function storedSession(overrides: Partial = {}): SessionInfo { return { @@ -55,7 +59,7 @@ function Harness({ onReady, requestGateway }: { - onReady: (create: (preview?: string | null) => Promise) => void + onReady: (handle: HarnessHandle) => void requestGateway: (method: string, params?: Record) => Promise }) { const ref = (value: T): MutableRefObject => ({ current: value }) @@ -78,13 +82,16 @@ function Harness({ }) useEffect(() => { - onReady(actions.createBackendSessionForSend) - }, [actions.createBackendSessionForSend, onReady]) + onReady(actions) + }, [actions, onReady]) return null } -async function createWith(profileSetup: () => void): Promise | undefined> { +async function createWith( + profileSetup: () => void, + beforeCreate?: (handle: HarnessHandle) => Promise | void +): Promise | undefined> { let createParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { @@ -97,13 +104,23 @@ async function createWith(profileSetup: () => void): Promise Promise) | null = null - render( (create = c)} requestGateway={requestGateway} />) - await waitFor(() => expect(create).not.toBeNull()) - await create!() + let handle: HarnessHandle | null = null + render( (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 } @@ -113,7 +130,8 @@ describe('createBackendSessionForSend profile routing', () => { cleanup() $newChatProfile.set(null) $activeGatewayProfile.set('default') - $currentCwd.set('') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) vi.restoreAllMocks() }) @@ -592,3 +610,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' }) + }) + +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 2b51f002e4b8..7d95c02abb93 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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, @@ -84,6 +88,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, @@ -105,7 +118,15 @@ 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') + + const workspaceTarget = hasWorkspaceTarget + ? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget) + : undefined + busyRef.current = false setBusy(false) setAwaitingResponse(false) @@ -133,10 +154,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) @@ -163,7 +192,15 @@ 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() || workspaceCwdForNewSession() + const workspaceTarget = $newChatWorkspaceTarget.get() + + const cwd = + workspaceTarget === null + ? '' + : typeof workspaceTarget === 'string' + ? workspaceTarget.trim() + : $currentCwd.get().trim() || workspaceCwdForNewSession() + // 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 @@ -213,6 +250,7 @@ export function useSessionActions({ } setFreshDraftReady(false) + setNewChatWorkspaceTarget(undefined) setActiveSessionId(created.session_id) setSelectedStoredSessionId(stored) setSessionStartedAt(Date.now()) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 2be408530541..91b27ca56afd 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -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(undefined) +export const $newChatWorkspaceTargetGeneration = atom(0) export const $currentBranch = atom('') export const $currentUsage = atom({ calls: 0, @@ -338,6 +341,16 @@ export const setCurrentCwd = (next: Updater) => { persistString(workspaceCwdKey(), $currentCwd.get().trim() || null) } +export const setCurrentCwdTransient = (next: Updater) => 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() From 4781c292243f9a0e66022a4a6213df96226beaa7 Mon Sep 17 00:00:00 2001 From: harjoth Date: Fri, 10 Jul 2026 15:23:22 -0700 Subject: [PATCH 2/4] fix(desktop): disambiguate no-project session target --- apps/desktop/src/app/chat/sidebar/index.tsx | 2 +- .../sidebar/projects/overview-row.test.tsx | 32 +++++++++++++++++++ .../chat/sidebar/projects/overview-row.tsx | 7 ++-- .../src/app/chat/sidebar/sessions-section.tsx | 2 +- apps/desktop/src/app/desktop-controller.tsx | 4 +-- .../hooks/use-session-actions/index.ts | 4 ++- 6 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427..904d54ec6c1d 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -208,7 +208,7 @@ interface ChatSidebarProps extends React.ComponentProps { onDeleteSession: (sessionId: string) => void onArchiveSession: (sessionId: string) => void onBranchSession: (sessionId: string) => void - onNewSessionInWorkspace: (path: null | string) => void + onNewSessionInWorkspace: (path: null | string, explicitNoWorkspace?: boolean) => void onManageCronJob: (jobId: string) => void onTriggerCronJob: (jobId: string) => void } diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx new file mode 100644 index 000000000000..70c6f7cf2ac6 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx @@ -0,0 +1,32 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ProjectOverviewRow } from './overview-row' + +describe('ProjectOverviewRow new session target', () => { + afterEach(() => { + cleanup() + }) + + it('marks the synthetic no-project row as an explicit no-workspace target', () => { + const onNewSession = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: /new session/i })) + + expect(onNewSession).toHaveBeenCalledWith(null, true) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index b3f779f2f2e3..8c09e6ae593f 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -64,7 +64,7 @@ export function ProjectBackRow({ label, onClick }: { label: string; onClick: () interface ProjectOverviewRowProps { project: SidebarProjectTree onEnter?: (id: string) => void - onNewSession?: (path: null | string) => void + onNewSession?: (path: null | string, explicitNoWorkspace?: boolean) => void renderRows?: (sessions: SessionInfo[]) => React.ReactNode activeProjectId?: null | string previewSessions?: SessionInfo[] @@ -117,7 +117,10 @@ export function ProjectOverviewRow({ actions={ <> {onNewSession && ( - onNewSession(project.path)} /> + onNewSession(project.path, project.isNoProject)} + /> )} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index ffe729eb51e4..2a3091c8161b 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -91,7 +91,7 @@ interface SidebarSessionsSectionProps { onArchiveSession: (sessionId: string) => void onBranchSession?: (sessionId: string, profile?: string) => void onTogglePin: (sessionId: string) => void - onNewSessionInWorkspace?: (path: null | string) => void + onNewSessionInWorkspace?: (path: null | string, explicitNoWorkspace?: boolean) => void pinned: boolean rootClassName?: string contentClassName?: string diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 6b9f02aa77e4..5451ea060a20 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -736,11 +736,11 @@ export function DesktopController() { ) const startSessionInWorkspace = useCallback( - (path: null | string) => { + (path: null | string, explicitNoWorkspace = false) => { // 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() + const target = explicitNoWorkspace ? null : path?.trim() || resolveNewSessionCwd() startFreshSessionDraft({ workspaceTarget: target || null }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 7d95c02abb93..6957caaafea2 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -121,7 +121,9 @@ export function useSessionActions({ (options: boolean | FreshSessionDraftOptions = false) => { const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options const replaceRoute = draftOptions.replaceRoute ?? false - const hasWorkspaceTarget = Object.hasOwn(draftOptions, 'workspaceTarget') + + const hasWorkspaceTarget = + Object.hasOwn(draftOptions, 'workspaceTarget') && draftOptions.workspaceTarget !== undefined const workspaceTarget = hasWorkspaceTarget ? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget) From 0adfee907ab192eb1238e5d93aeda612826f7e97 Mon Sep 17 00:00:00 2001 From: harjoth Date: Fri, 10 Jul 2026 15:35:35 -0700 Subject: [PATCH 3/4] test(desktop): cover sidebar workspace target race --- apps/desktop/src/app/desktop-controller.tsx | 61 +++----------- .../session/workspace-session-target.test.ts | 81 +++++++++++++++++++ .../app/session/workspace-session-target.ts | 65 +++++++++++++++ 3 files changed, 157 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/src/app/session/workspace-session-target.test.ts create mode 100644 apps/desktop/src/app/session/workspace-session-target.ts diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 5451ea060a20..ea6bc3f2a009 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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, @@ -57,7 +57,6 @@ import { $gatewayState, $messages, $messagingSessions, - $newChatWorkspaceTargetGeneration, $resumeExhaustedSessionId, $resumeFailedSessionId, $selectedStoredSessionId, @@ -66,12 +65,9 @@ import { sessionPinId, setAwaitingResponse, setBusy, - setCurrentBranch, - setCurrentCwd, setCurrentModel, setCurrentProvider, setMessages, - setNewChatWorkspaceTarget, setRememberedSessionId } from '../store/session' import { onSessionsChanged } from '../store/session-sync' @@ -119,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' @@ -737,51 +734,15 @@ export function DesktopController() { const startSessionInWorkspace = useCallback( (path: null | string, explicitNoWorkspace = false) => { - // 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 = explicitNoWorkspace ? null : path?.trim() || resolveNewSessionCwd() - - startFreshSessionDraft({ workspaceTarget: target || null }) - - if (!target) { - return - } - - const workspaceGeneration = $newChatWorkspaceTargetGeneration.get() - - // 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 => { - if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) { - return - } - - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setNewChatWorkspaceTarget(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(() => { - if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { - setCurrentBranch('') - } - }) + startWorkspaceSession({ + activeSessionIdRef, + explicitNoWorkspace, + followActiveSessionCwd, + onExplicitWorkspace: restoreWorktree, + path, + requestGateway, + startFreshSessionDraft + }) }, [activeSessionIdRef, requestGateway, startFreshSessionDraft] ) diff --git a/apps/desktop/src/app/session/workspace-session-target.test.ts b/apps/desktop/src/app/session/workspace-session-target.test.ts new file mode 100644 index 000000000000..546745bed826 --- /dev/null +++ b/apps/desktop/src/app/session/workspace-session-target.test.ts @@ -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() { + let resolve!: (value: T) => void + + const promise = new Promise(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(({ workspaceTarget }: { workspaceTarget: null | string }) => { + setNewChatWorkspaceTarget(workspaceTarget) + setCurrentCwd(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') + }) +}) diff --git a/apps/desktop/src/app/session/workspace-session-target.ts b/apps/desktop/src/app/session/workspace-session-target.ts new file mode 100644 index 000000000000..b2cd07a2fdf4 --- /dev/null +++ b/apps/desktop/src/app/session/workspace-session-target.ts @@ -0,0 +1,65 @@ +import type { MutableRefObject } from 'react' + +import { followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { + $newChatWorkspaceTargetGeneration, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' + +interface WorkspaceSessionOptions { + activeSessionIdRef: MutableRefObject + explicitNoWorkspace?: boolean + followActiveSessionCwd?: (cwd: string) => void | Promise + onExplicitWorkspace?: (cwd: string) => void + path: null | string + requestGateway: (method: string, params?: Record) => Promise + startFreshSessionDraft: (options: { workspaceTarget: null | string }) => void +} + +export function startWorkspaceSession({ + activeSessionIdRef, + explicitNoWorkspace = false, + followActiveSessionCwd: followCwd = followActiveSessionCwd, + onExplicitWorkspace, + path, + requestGateway, + startFreshSessionDraft +}: WorkspaceSessionOptions): void { + // A worktree lane carries its own path; a project trunk can be path-less, so + // only the synthetic No project row bypasses the active-project fallback. + const target = explicitNoWorkspace ? null : path?.trim() || resolveNewSessionCwd() + + startFreshSessionDraft({ workspaceTarget: target || null }) + + 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 (path?.trim()) { + onExplicitWorkspace?.(resolved) + void followCwd(resolved) + } + }) + .catch(() => { + if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { + setCurrentBranch('') + } + }) +} From 7ccd839e2883d48f5fb24d3612911d350438d44b Mon Sep 17 00:00:00 2001 From: harjoth Date: Fri, 10 Jul 2026 15:50:08 -0700 Subject: [PATCH 4/4] fix(desktop): drop inert no-project target wiring --- apps/desktop/src/app/chat/sidebar/index.tsx | 2 +- .../sidebar/projects/overview-row.test.tsx | 32 ------------------- .../chat/sidebar/projects/overview-row.tsx | 7 ++-- .../src/app/chat/sidebar/sessions-section.tsx | 2 +- apps/desktop/src/app/desktop-controller.tsx | 3 +- .../session/workspace-session-target.test.ts | 6 ++-- .../app/session/workspace-session-target.ts | 13 ++++---- 7 files changed, 14 insertions(+), 51 deletions(-) delete mode 100644 apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 904d54ec6c1d..416483dde427 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -208,7 +208,7 @@ interface ChatSidebarProps extends React.ComponentProps { onDeleteSession: (sessionId: string) => void onArchiveSession: (sessionId: string) => void onBranchSession: (sessionId: string) => void - onNewSessionInWorkspace: (path: null | string, explicitNoWorkspace?: boolean) => void + onNewSessionInWorkspace: (path: null | string) => void onManageCronJob: (jobId: string) => void onTriggerCronJob: (jobId: string) => void } diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx deleted file mode 100644 index 70c6f7cf2ac6..000000000000 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { ProjectOverviewRow } from './overview-row' - -describe('ProjectOverviewRow new session target', () => { - afterEach(() => { - cleanup() - }) - - it('marks the synthetic no-project row as an explicit no-workspace target', () => { - const onNewSession = vi.fn() - - render( - - ) - - fireEvent.click(screen.getByRole('button', { name: /new session/i })) - - expect(onNewSession).toHaveBeenCalledWith(null, true) - }) -}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index 8c09e6ae593f..b3f779f2f2e3 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -64,7 +64,7 @@ export function ProjectBackRow({ label, onClick }: { label: string; onClick: () interface ProjectOverviewRowProps { project: SidebarProjectTree onEnter?: (id: string) => void - onNewSession?: (path: null | string, explicitNoWorkspace?: boolean) => void + onNewSession?: (path: null | string) => void renderRows?: (sessions: SessionInfo[]) => React.ReactNode activeProjectId?: null | string previewSessions?: SessionInfo[] @@ -117,10 +117,7 @@ export function ProjectOverviewRow({ actions={ <> {onNewSession && ( - onNewSession(project.path, project.isNoProject)} - /> + onNewSession(project.path)} /> )} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index 2a3091c8161b..ffe729eb51e4 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -91,7 +91,7 @@ interface SidebarSessionsSectionProps { onArchiveSession: (sessionId: string) => void onBranchSession?: (sessionId: string, profile?: string) => void onTogglePin: (sessionId: string) => void - onNewSessionInWorkspace?: (path: null | string, explicitNoWorkspace?: boolean) => void + onNewSessionInWorkspace?: (path: null | string) => void pinned: boolean rootClassName?: string contentClassName?: string diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ea6bc3f2a009..17eb3f0cd426 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -733,10 +733,9 @@ export function DesktopController() { ) const startSessionInWorkspace = useCallback( - (path: null | string, explicitNoWorkspace = false) => { + (path: null | string) => { startWorkspaceSession({ activeSessionIdRef, - explicitNoWorkspace, followActiveSessionCwd, onExplicitWorkspace: restoreWorktree, path, diff --git a/apps/desktop/src/app/session/workspace-session-target.test.ts b/apps/desktop/src/app/session/workspace-session-target.test.ts index 546745bed826..3a83f605a321 100644 --- a/apps/desktop/src/app/session/workspace-session-target.test.ts +++ b/apps/desktop/src/app/session/workspace-session-target.test.ts @@ -40,9 +40,9 @@ describe('startWorkspaceSession', () => { const activeSessionIdRef = { current: null } - const startFreshSessionDraft = vi.fn(({ workspaceTarget }: { workspaceTarget: null | string }) => { - setNewChatWorkspaceTarget(workspaceTarget) - setCurrentCwd(workspaceTarget || '') + const startFreshSessionDraft = vi.fn((options?: { workspaceTarget: string }) => { + setNewChatWorkspaceTarget(options?.workspaceTarget) + setCurrentCwd(options?.workspaceTarget || '') }) const followActiveSessionCwd = vi.fn() diff --git a/apps/desktop/src/app/session/workspace-session-target.ts b/apps/desktop/src/app/session/workspace-session-target.ts index b2cd07a2fdf4..da5028502a11 100644 --- a/apps/desktop/src/app/session/workspace-session-target.ts +++ b/apps/desktop/src/app/session/workspace-session-target.ts @@ -10,17 +10,15 @@ import { interface WorkspaceSessionOptions { activeSessionIdRef: MutableRefObject - explicitNoWorkspace?: boolean followActiveSessionCwd?: (cwd: string) => void | Promise onExplicitWorkspace?: (cwd: string) => void path: null | string requestGateway: (method: string, params?: Record) => Promise - startFreshSessionDraft: (options: { workspaceTarget: null | string }) => void + startFreshSessionDraft: (options?: { workspaceTarget: string }) => void } export function startWorkspaceSession({ activeSessionIdRef, - explicitNoWorkspace = false, followActiveSessionCwd: followCwd = followActiveSessionCwd, onExplicitWorkspace, path, @@ -28,10 +26,11 @@ export function startWorkspaceSession({ startFreshSessionDraft }: WorkspaceSessionOptions): void { // A worktree lane carries its own path; a project trunk can be path-less, so - // only the synthetic No project row bypasses the active-project fallback. - const target = explicitNoWorkspace ? null : path?.trim() || resolveNewSessionCwd() + // fall back to the active project's root for that existing controller path. + const explicitTarget = path?.trim() + const target = explicitTarget || resolveNewSessionCwd() - startFreshSessionDraft({ workspaceTarget: target || null }) + startFreshSessionDraft(target ? { workspaceTarget: target } : undefined) if (!target) { return @@ -52,7 +51,7 @@ export function startWorkspaceSession({ setNewChatWorkspaceTarget(resolved) setCurrentBranch(info.branch || '') - if (path?.trim()) { + if (explicitTarget) { onExplicitWorkspace?.(resolved) void followCwd(resolved) }