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
49 changes: 11 additions & 38 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,8 +65,6 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -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('')
})
})
20 changes: 17 additions & 3 deletions apps/desktop/src/app/session/hooks/use-cwd-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -55,22 +61,30 @@ export function useCwdActions({

if (!activeSessionId) {
setCurrentCwd(trimmed)
const workspaceGeneration = setNewChatWorkspaceTarget(trimmed)

try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
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
Expand Down Expand Up @@ -103,7 +117,7 @@ export function useCwdActions({
})
}
},
[activeSessionId, copy, onSessionRuntimeInfo, requestGateway]
[activeSessionId, activeSessionIdRef, copy, onSessionRuntimeInfo, requestGateway]
)

return { changeSessionCwd, refreshProjectBranch }
Expand Down
81 changes: 70 additions & 11 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,9 +10,12 @@ import {
$activeSessionId,
$currentCwd,
$messages,
$newChatWorkspaceTarget,
$resumeFailedSessionId,
setActiveSessionId,
setCurrentCwd,
setMessages,
setNewChatWorkspaceTarget,
setResumeFailedSessionId,
setSessions
} from '@/store/session'
Expand All @@ -31,6 +34,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 {
Expand All @@ -55,7 +59,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 })
Expand All @@ -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<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>) => {
Expand All @@ -97,13 +104,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
}
Expand All @@ -113,7 +130,8 @@ describe('createBackendSessionForSend profile routing', () => {
cleanup()
$newChatProfile.set(null)
$activeGatewayProfile.set('default')
$currentCwd.set('')
setCurrentCwd('')
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
})

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

})
Loading
Loading