mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(desktop): preserve sidebar workspace targets across new drafts
Squashed salvage of #45744 (@harjothkhara), rebased onto current main and resolved against #58241 (which swapped the new-session cwd fallback to the project-aware resolveNewSessionCwd). An explicitly clicked sidebar workspace stays authoritative until session.create: a one-shot $newChatWorkspaceTarget (null → detached, string → that folder) plus a generation counter so a stale async `config.get project` normalization can't overwrite a newer draft target. The start-workspace-session action is extracted out of desktop-controller.tsx into a testable workspace-session-target module. Integrated with #58241: the no-explicit-target branch now falls through to the project-aware resolveNewSessionCwd() instead of the old workspaceCwdForNewSession. Co-authored-by: harjoth <harjoth.khara@gmail.com>
This commit is contained in:
parent
095b9eed38
commit
a5c0715835
8 changed files with 406 additions and 57 deletions
|
|
@ -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
|
||||
|
|
|
|||
102
apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx
Normal file
102
apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx
Normal 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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
64
apps/desktop/src/app/session/workspace-session-target.ts
Normal file
64
apps/desktop/src/app/session/workspace-session-target.ts
Normal 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('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue