Merge pull request #81247 from NousResearch/bb/desktop-files-pane-cwd-ownership
fix(desktop): rebind the Files pane workspace when switching sessions
This commit is contained in:
commit
10a2b3d7a2
|
|
@ -47,6 +47,8 @@ import {
|
|||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
|
|
@ -58,6 +60,7 @@ import {
|
|||
setMessages,
|
||||
setSessions,
|
||||
setTurnStartedAt,
|
||||
setWorkspaceCwdOwner,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { dropSessionState } from '@/store/session-states'
|
||||
|
|
@ -81,6 +84,42 @@ function firstBillingLine(text: string): string {
|
|||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `session.info` payload's `stored_session_id` may be treated as the
|
||||
* selected conversation's, so its cwd can be claimed for it (#71254).
|
||||
*
|
||||
* Absent is not the same as different: the backend omits the id on a
|
||||
* not-yet-built (`lazy`) session, and refusing there would leave the workspace
|
||||
* marked un-owned for the rest of the conversation. Matching goes through the
|
||||
* lineage (`sessionMatchesStoredId`) so a compression-rotated tip and the root
|
||||
* a pinned-row selection may hold still read as one conversation.
|
||||
*/
|
||||
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined): boolean {
|
||||
const infoStoredSessionId = storedSessionId?.trim() || null
|
||||
const selected = $selectedStoredSessionId.get() ?? null
|
||||
|
||||
if (!infoStoredSessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// A named session cannot describe a fresh draft. Treating a null selection as
|
||||
// a wildcard let a background tile's `session.info` rehome the draft to the
|
||||
// tile's workspace.
|
||||
if (!selected) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (infoStoredSessionId === selected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Either id may be the live tip or the lineage root, so ask whether ONE row
|
||||
// answers to both rather than assuming which side rotated.
|
||||
return $sessions
|
||||
.get()
|
||||
.some(session => sessionMatchesStoredId(session, infoStoredSessionId) && sessionMatchesStoredId(session, selected))
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn failed on a billing wall (out of credits / payment required). The
|
||||
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
|
||||
|
|
@ -396,7 +435,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
// Active-session model/provider still flows through the session state
|
||||
// cache via updateSessionState → syncRuntimeMetadataToView below.
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
if (typeof payload?.cwd === 'string' && sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
|
||||
// The active session's agent can relocate itself (new repo/worktree
|
||||
// via the terminal). When the SAME active session's cwd actually
|
||||
// moves, follow it — refresh the project tree + scope so the sidebar
|
||||
|
|
@ -408,6 +447,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
lastCwdInfoSessionRef.current = sessionId
|
||||
setCurrentCwd(payload.cwd)
|
||||
|
||||
// The backend just confirmed the selected conversation's real
|
||||
// workspace, so it owns the path we wrote. Without the claim the
|
||||
// marker keeps naming whoever held it before — including the
|
||||
// released state a detached resume leaves behind — and the primary
|
||||
// workspace-derived surfaces stay hidden against a folder the
|
||||
// backend has confirmed (#71254).
|
||||
setWorkspaceCwdOwner($selectedStoredSessionId.get())
|
||||
|
||||
if (cwdMoved && sameSession) {
|
||||
void followActiveSessionCwd(payload.cwd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
setSessions,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt,
|
||||
setWorkspaceCwdOwner,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import {
|
||||
|
|
@ -350,6 +351,11 @@ export function useSessionActions({
|
|||
setCurrentCwd(workspaceTarget)
|
||||
}
|
||||
|
||||
// A fresh draft resolves its own workspace right here, so it owns it. The
|
||||
// selected stored id is null for a draft, and so is the owner — they match,
|
||||
// which keeps workspace surfaces live on a new chat instead of treating the
|
||||
// draft as an un-re-homed switch (#71254).
|
||||
setWorkspaceCwdOwner(null)
|
||||
setCurrentBranch('')
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
setFreshDraftReady(true)
|
||||
|
|
@ -514,13 +520,23 @@ export function useSessionActions({
|
|||
upsertOptimisticSession(created, stored, null, null)
|
||||
}
|
||||
|
||||
// A tile lives in its OWN worktree — it must not publish its cwd/branch
|
||||
// into the composer atoms the main pane renders from.
|
||||
// A tile lives in its OWN worktree, so it must not run the full
|
||||
// foreground composer publish. A CENTER tile is the focused surface,
|
||||
// though, and the Files pane still keys off the global `$currentCwd` —
|
||||
// so the right rail kept showing the previous session's tree when a
|
||||
// Project "+" created a session while the main chat was occupied
|
||||
// (#76696). Split/side tiles deliberately stay isolated.
|
||||
const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false })
|
||||
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
|
||||
|
||||
openSessionTile(stored, dir)
|
||||
patchSessionTile(stored, { runtimeId: created.session_id })
|
||||
|
||||
if (dir === 'center' && runtimeInfo?.cwd) {
|
||||
setCurrentCwd(runtimeInfo.cwd)
|
||||
setWorkspaceCwdOwner(stored)
|
||||
}
|
||||
|
||||
revealTreePane(`session-tile:${stored}`)
|
||||
|
||||
if (listed) {
|
||||
|
|
@ -701,6 +717,12 @@ export function useSessionActions({
|
|||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedViewState)
|
||||
setCurrentCwd(cachedViewState.cwd)
|
||||
// The warm cache IS this conversation's own workspace truth, so the
|
||||
// switch is already re-homed here. This claim cannot wait for
|
||||
// `session.activate`: its missing-RPC compat branch returns before
|
||||
// `applyRuntimeInfo` runs, which would leave the workspace marked
|
||||
// un-owned for the life of the session (#71254).
|
||||
setWorkspaceCwdOwner(storedSessionId)
|
||||
setCurrentBranch(cachedViewState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
|
||||
|
|
@ -844,7 +866,7 @@ export function useSessionActions({
|
|||
const stored =
|
||||
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
|
||||
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
applyStoredSessionPreviewRuntimeInfo(stored, storedSessionId)
|
||||
|
||||
if (stored) {
|
||||
applyStoredUsage(stored)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,20 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/c
|
|||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $currentBranch, $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
|
||||
import {
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setSelectedStoredSessionId,
|
||||
workspaceCwdBelongsToSelectedSession
|
||||
} from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
appendLiveSessionProjection,
|
||||
applyRuntimeInfo,
|
||||
applyStoredSessionPreviewRuntimeInfo,
|
||||
chatMessageArraysEquivalent,
|
||||
chatMessagesEquivalent,
|
||||
chatPartsEquivalent,
|
||||
|
|
@ -110,6 +118,90 @@ describe('applyRuntimeInfo foreground scoping', () => {
|
|||
// ...while the caller still gets everything it needs for its own session.
|
||||
expect(patch).toMatchObject({ branch: 'bb/tile', cwd: '/other-worktree' })
|
||||
})
|
||||
|
||||
// #71254: `if (info.cwd)` treated '' as "no opinion", so a detached session
|
||||
// never released the previous project and the Files pane stayed on it forever.
|
||||
it('treats an empty runtime cwd as authoritative and releases ownership', () => {
|
||||
setSelectedStoredSessionId('session-detached')
|
||||
const patch = applyRuntimeInfo({ cwd: '' })
|
||||
|
||||
expect(patch).toMatchObject({ cwd: '' })
|
||||
expect(workspaceCwdBelongsToSelectedSession()).toBe(false)
|
||||
})
|
||||
|
||||
// The release must NOT blank the path: setCurrentCwd persists, so writing ''
|
||||
// would also wipe the remembered workspace that seeds $currentCwd on boot.
|
||||
it('leaves the path in place when releasing, so panes do not collapse', () => {
|
||||
setSelectedStoredSessionId('session-detached')
|
||||
applyRuntimeInfo({ cwd: '' })
|
||||
|
||||
expect($currentCwd.get()).toBe('/main-repo')
|
||||
})
|
||||
|
||||
it('claims ownership for the selected session when a real cwd arrives', () => {
|
||||
setSelectedStoredSessionId('session-b')
|
||||
applyRuntimeInfo({ cwd: '/project-b' })
|
||||
|
||||
expect($currentCwd.get()).toBe('/project-b')
|
||||
expect(workspaceCwdBelongsToSelectedSession()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyStoredSessionPreviewRuntimeInfo workspace paint', () => {
|
||||
beforeEach(() => {
|
||||
setCurrentCwd('/previous-project')
|
||||
setSelectedStoredSessionId(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentCwd('')
|
||||
setSelectedStoredSessionId(null)
|
||||
})
|
||||
|
||||
// The core of the report: cold resume paints before session.resume returns.
|
||||
it('rebinds the workspace from the selected session row before resume settles', () => {
|
||||
applyStoredSessionPreviewRuntimeInfo({ cwd: '/next-project', model: 'gpt' }, 'session-next')
|
||||
setSelectedStoredSessionId('session-next')
|
||||
|
||||
expect($currentCwd.get()).toBe('/next-project')
|
||||
expect(workspaceCwdBelongsToSelectedSession()).toBe(true)
|
||||
})
|
||||
|
||||
it('releases ownership when the selected session row reports no workspace', () => {
|
||||
applyStoredSessionPreviewRuntimeInfo({ cwd: '', model: 'gpt' }, 'session-detached')
|
||||
setSelectedStoredSessionId('session-detached')
|
||||
|
||||
expect(workspaceCwdBelongsToSelectedSession()).toBe(false)
|
||||
})
|
||||
|
||||
// Regression guard: a session outside the loaded sidebar page has no row at
|
||||
// all. Blanking $currentCwd here would drop file-tree state on every switch
|
||||
// into older history, so the path must survive and ownership carry the signal.
|
||||
it('does not blank the pane when the session row is not loaded', () => {
|
||||
applyStoredSessionPreviewRuntimeInfo(undefined, 'session-off-page')
|
||||
setSelectedStoredSessionId('session-off-page')
|
||||
|
||||
expect($currentCwd.get()).toBe('/previous-project')
|
||||
expect(workspaceCwdBelongsToSelectedSession()).toBe(false)
|
||||
})
|
||||
|
||||
// Regression guard: git_repo_root is documented null for non-git workspaces
|
||||
// and not-yet-backfilled rows, so it must never stand in for a real cwd —
|
||||
// doing so reads as "no workspace" and blanks a pane that was correct.
|
||||
it('uses the row cwd for a non-git workspace with no repo root', () => {
|
||||
applyStoredSessionPreviewRuntimeInfo({ cwd: '/plain/folder', git_repo_root: null, model: 'gpt' } as never, 'session-nongit')
|
||||
setSelectedStoredSessionId('session-nongit')
|
||||
|
||||
expect($currentCwd.get()).toBe('/plain/folder')
|
||||
expect(workspaceCwdBelongsToSelectedSession()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the branch label so the previous project does not leak across a switch', () => {
|
||||
setCurrentBranch('bb/previous')
|
||||
applyStoredSessionPreviewRuntimeInfo({ cwd: '/next-project', model: 'gpt' }, 'session-next')
|
||||
|
||||
expect($currentBranch.get()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionGoneError', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
|
|||
import {
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
commitWorkspaceCwdForSelectedSession,
|
||||
releaseWorkspaceCwdOwner,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
|
|
@ -20,6 +22,7 @@ import {
|
|||
setCurrentServiceTier,
|
||||
setCurrentUsage,
|
||||
setSessions,
|
||||
setWorkspaceCwdOwner,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
|
|
@ -959,7 +962,17 @@ function publishRuntimeToComposer(state: SessionRuntimeStatePatch): void {
|
|||
}
|
||||
|
||||
if (state.cwd !== undefined) {
|
||||
setCurrentCwd(state.cwd)
|
||||
if (state.cwd) {
|
||||
// The runtime named a real folder for the session in the main pane, so
|
||||
// that conversation owns the path.
|
||||
commitWorkspaceCwdForSelectedSession(state.cwd)
|
||||
} else {
|
||||
// A detached session: the path on screen is provably still the previous
|
||||
// conversation's. Release rather than write `''` — `setCurrentCwd`
|
||||
// persists, so blanking here would also wipe the remembered workspace
|
||||
// that seeds `$currentCwd` on next boot.
|
||||
releaseWorkspaceCwdOwner()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.branch !== undefined) {
|
||||
|
|
@ -1017,7 +1030,12 @@ export function applyRuntimeInfo(
|
|||
sessionState.provider = info.provider
|
||||
}
|
||||
|
||||
if (info.cwd) {
|
||||
// Empty string is authoritative, not "no opinion": a detached/bare session
|
||||
// reports `cwd: ''`, and the truthy-only test left `$currentCwd` — and so the
|
||||
// Files pane — pinned to the PREVIOUS project for the rest of the session
|
||||
// (#71254). Empty is routed through ownership release below rather than
|
||||
// persisted, so the pane hides a path it no longer owns instead of blanking.
|
||||
if (typeof info.cwd === 'string') {
|
||||
sessionState.cwd = info.cwd
|
||||
}
|
||||
|
||||
|
|
@ -1056,7 +1074,10 @@ export function applyRuntimeInfo(
|
|||
return sessionState
|
||||
}
|
||||
|
||||
export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
|
||||
export function applyStoredSessionPreviewRuntimeInfo(
|
||||
stored: { cwd?: null | string; model?: null | string } | undefined,
|
||||
storedSessionId: null | string
|
||||
) {
|
||||
setCurrentModel(stored?.model || '')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
|
|
@ -1064,6 +1085,35 @@ export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | st
|
|||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentPersonality('')
|
||||
|
||||
// Cold resume paints the transcript before `session.resume` returns, so
|
||||
// without this the Files pane shows the PREVIOUS project's tree for the whole
|
||||
// round-trip (#71254 / #76696). The sidebar row already knows this
|
||||
// conversation's workspace — `cwd` is part of the compact row projection — so
|
||||
// mirror it on the same tick the selection changes.
|
||||
//
|
||||
// Only `cwd` is consulted. `git_repo_root` is documented as null for non-git
|
||||
// workspaces and not-yet-backfilled history rows, so falling back to it would
|
||||
// read as "no workspace" for those sessions and blank a pane that was correct.
|
||||
const storedCwd = stored?.cwd?.trim() || ''
|
||||
|
||||
if (storedCwd) {
|
||||
setCurrentCwd(storedCwd)
|
||||
setWorkspaceCwdOwner(storedSessionId)
|
||||
} else {
|
||||
// Either a genuinely detached session, or a row outside the loaded sidebar
|
||||
// page (`stored` is undefined) — neither says anything about the workspace,
|
||||
// while `$currentCwd` still holds the previous conversation's folder.
|
||||
// Release so workspace-derived surfaces stop trusting it; `applyRuntimeInfo`
|
||||
// publishes the truth a moment later. The path is deliberately left in place
|
||||
// — clearing it collapses the workspace/review panes and drops file-tree
|
||||
// state on every switch.
|
||||
releaseWorkspaceCwdOwner()
|
||||
}
|
||||
|
||||
// Same window, same reasoning: the branch is derived from the workspace, so
|
||||
// carrying the previous conversation's label across a switch is never right.
|
||||
setCurrentBranch('')
|
||||
}
|
||||
|
||||
// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped /
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ import {
|
|||
ALL_PROJECTS,
|
||||
projectRootCwd
|
||||
} from './projects'
|
||||
import { $busy, $currentCwd, $selectedStoredSessionId } from './session'
|
||||
import {
|
||||
$busy,
|
||||
$currentCwd,
|
||||
$selectedStoredSessionId,
|
||||
$workspaceCwdOwner,
|
||||
workspaceCwdBelongsToSelectedSession
|
||||
} from './session'
|
||||
import { $focusedRuntimeId, $sessionStates } from './session-states'
|
||||
import { $workspaceChangeTick } from './workspace-events'
|
||||
|
||||
|
|
@ -40,10 +46,13 @@ export const $repoWorktreesByCwd = atom<Record<string, HermesGitWorktree[]>>({})
|
|||
// The PRIMARY (main pane) view — the active session's slice of the per-cwd
|
||||
// truth. Existing consumers (keybind gate, base-branch picker, file tree) keep
|
||||
// reading these; only surfaces that can live in ANOTHER worktree (tile rails)
|
||||
// need the per-cwd accessors below.
|
||||
// need the per-cwd accessors below. During a conversation switch `$currentCwd`
|
||||
// can still name the previous conversation's path, so ownership hides only this
|
||||
// primary slice; the per-cwd cache stays available to any tile that genuinely
|
||||
// owns that worktree (#71254).
|
||||
export const $repoStatus: ReadableAtom<HermesRepoStatus | null> = computed(
|
||||
[$repoStatusByCwd, $currentCwd],
|
||||
(byCwd, cwd) => byCwd[normalizeCwd(cwd) ?? ''] ?? null
|
||||
[$repoStatusByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner],
|
||||
(byCwd, cwd) => (workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? null) : null)
|
||||
)
|
||||
|
||||
export const $repoStatusLoading = atom(false)
|
||||
|
|
@ -51,8 +60,9 @@ export const $repoStatusLoading = atom(false)
|
|||
// The repo's real worktrees (for the coding rail's "jump to a worktree" menu).
|
||||
// Refreshed on the same edges as the status probe; empty off a repo.
|
||||
export const $repoWorktrees: ReadableAtom<HermesGitWorktree[]> = computed(
|
||||
[$repoWorktreesByCwd, $currentCwd],
|
||||
(byCwd, cwd) => byCwd[normalizeCwd(cwd) ?? ''] ?? EMPTY_WORKTREES
|
||||
[$repoWorktreesByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner],
|
||||
(byCwd, cwd) =>
|
||||
workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES
|
||||
)
|
||||
|
||||
// Reference-stable per-cwd slices, so any number of rails can each subscribe
|
||||
|
|
|
|||
|
|
@ -505,6 +505,28 @@ 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())
|
||||
|
||||
// Which conversation the live `$currentCwd` is known to describe. Three
|
||||
// inhabitants, and the difference between the last two is load-bearing:
|
||||
// a stored-session id (that conversation owns the path), `null` (the fresh-draft
|
||||
// state, which MATCHES a null selection and therefore reads as OWNED — a draft's
|
||||
// workspace is immediately usable), and the released marker
|
||||
// `WORKSPACE_CWD_UNOWNED` below, which matches no selection and so reads as
|
||||
// owned by nobody. `null` cannot double as the release value precisely because
|
||||
// it matches: releasing to `null` while a draft is selected would hand the
|
||||
// leftover path to the draft as its own workspace.
|
||||
//
|
||||
// A conversation switch publishes the new stored id immediately, but the new
|
||||
// workspace only arrives when the resume settles, so for that whole window
|
||||
// `$currentCwd` still holds the PREVIOUS conversation's folder. Without a way to
|
||||
// say "this path is not this conversation's yet", workspace-derived surfaces
|
||||
// treat the leftover path as authoritative and show the old repo's cached Git
|
||||
// facts under the newly selected chat (#71254).
|
||||
//
|
||||
// Ownership, not emptiness, is what makes the switch atomic: clearing the path
|
||||
// would collapse the workspace panes and drop file-tree state on every switch,
|
||||
// so the path stays put and is simply marked as not-yet-owned.
|
||||
export const $workspaceCwdOwner = atom<null | string>(null)
|
||||
export const $newChatWorkspaceTarget = atom<NewChatWorkspaceTarget>(undefined)
|
||||
export const $newChatWorkspaceTargetGeneration = atom(0)
|
||||
export const $currentBranch = atom('')
|
||||
|
|
@ -628,6 +650,49 @@ export const setCurrentCwd = (next: Updater<string>) => {
|
|||
|
||||
export const setCurrentCwdTransient = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||||
|
||||
// Released-ownership marker: the live path belongs to no conversation. `null`
|
||||
// cannot serve as the release value because it MATCHES a fresh draft (whose
|
||||
// selected id is also null), which would declare a leftover path to be the
|
||||
// draft's own workspace — #71254, one selection over. Kept here beside the atom
|
||||
// and the comparison so a release site cannot reinvent a subtly different value.
|
||||
const WORKSPACE_CWD_UNOWNED = 'desktop:workspace-cwd-unowned'
|
||||
|
||||
/** Mark the live workspace as belonging to `storedSessionId`.
|
||||
*
|
||||
* Call this wherever a cwd is established for a conversation (resume settling,
|
||||
* a warm switch, an explicit folder pick). Until it is called for the newly
|
||||
* selected conversation, primary workspace-derived selectors hide the previous
|
||||
* conversation's cached facts rather than publishing them (#71254).
|
||||
*/
|
||||
export const setWorkspaceCwdOwner = (storedSessionId: null | string) => updateAtom($workspaceCwdOwner, storedSessionId)
|
||||
|
||||
/** Declare that no conversation owns the live workspace path.
|
||||
*
|
||||
* For a conversation whose workspace is not known yet: the path on screen is
|
||||
* provably still the previous conversation's, so workspace-derived surfaces must
|
||||
* hide it rather than adopt it. The path itself is deliberately left alone —
|
||||
* clearing it would collapse the workspace/review panes and drop file-tree
|
||||
* state on every switch.
|
||||
*/
|
||||
export const releaseWorkspaceCwdOwner = () => updateAtom($workspaceCwdOwner, WORKSPACE_CWD_UNOWNED)
|
||||
|
||||
/** Commit `cwd` as the workspace of the conversation the user is looking at.
|
||||
*
|
||||
* The single primitive for "this path IS the selected conversation's" — a folder
|
||||
* pick, a project entry, the agent relocating itself. Prefer it over a bare
|
||||
* `setCurrentCwd`, which moves the path while leaving ownership naming whatever
|
||||
* held it before; workspace-derived slices then stay hidden even though the
|
||||
* path is correct (#71254).
|
||||
*/
|
||||
export const commitWorkspaceCwdForSelectedSession = (cwd: string) => {
|
||||
setCurrentCwd(cwd)
|
||||
setWorkspaceCwdOwner($selectedStoredSessionId.get())
|
||||
}
|
||||
|
||||
/** True when `$currentCwd` is known to describe the selected conversation. */
|
||||
export const workspaceCwdBelongsToSelectedSession = (): boolean =>
|
||||
($workspaceCwdOwner.get() ?? null) === ($selectedStoredSessionId.get() ?? null)
|
||||
|
||||
export const setNewChatWorkspaceTarget = (next: NewChatWorkspaceTarget): number => {
|
||||
const generation = $newChatWorkspaceTargetGeneration.get() + 1
|
||||
$newChatWorkspaceTarget.set(next)
|
||||
|
|
|
|||
|
|
@ -16375,3 +16375,38 @@ def test_prompt_submit_releases_old_history_before_heap_trim(monkeypatch):
|
|||
assert cleanup_order == ["trim", "reset_home"]
|
||||
finally:
|
||||
server._sessions.pop("sid_trim", None)
|
||||
|
||||
|
||||
def test_fallback_session_info_reports_session_cwd_not_launch_dir(monkeypatch):
|
||||
"""A lazily-resumed session must report ITS workspace, not the gateway's.
|
||||
|
||||
``_fallback_session_info`` used ``_default_session_cwd()`` — the directory the
|
||||
gateway process happened to start in — so the desktop Files pane painted the
|
||||
wrong project for any session resumed without a built agent (#71254).
|
||||
"""
|
||||
monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir")
|
||||
monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "bb/feature")
|
||||
monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None)
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
info = server._fallback_session_info({"cwd": "/projects/session-own-repo"})
|
||||
|
||||
assert info["cwd"] == "/projects/session-own-repo"
|
||||
assert info["branch"] == "bb/feature"
|
||||
|
||||
|
||||
def test_fallback_session_info_always_emits_branch(monkeypatch):
|
||||
"""``branch`` is always present so a client can CLEAR a stale label.
|
||||
|
||||
Omitting the key left the desktop showing the previous conversation's branch
|
||||
after switching into a non-git session.
|
||||
"""
|
||||
monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir")
|
||||
monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "")
|
||||
monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None)
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
info = server._fallback_session_info({"cwd": "/plain/folder"})
|
||||
|
||||
assert "branch" in info
|
||||
assert info["branch"] == ""
|
||||
|
|
|
|||
|
|
@ -7968,9 +7968,17 @@ def _fallback_session_info(session: dict) -> dict:
|
|||
agent = session.get("agent")
|
||||
if agent is not None:
|
||||
return _session_info(agent)
|
||||
cwd = _default_session_cwd()
|
||||
# The SESSION's own workspace, not the gateway's launch directory. Reporting
|
||||
# `_default_session_cwd()` here told a lazily-resumed session's client that
|
||||
# its workspace was wherever the gateway process happened to start, so the
|
||||
# desktop Files pane painted the wrong project even after the renderer
|
||||
# rebound correctly (#71254). `branch` is always emitted ("" outside a git
|
||||
# repo) so a client can clear a stale label instead of retaining it — the
|
||||
# same contract `_lazy_session_info` above already follows.
|
||||
cwd = _session_cwd(session)
|
||||
return {
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"project": _project_info_for_cwd(cwd),
|
||||
"lazy": True,
|
||||
"model": _resolve_model(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue