fix(desktop): retain backend worktree project ownership

This commit is contained in:
KoNit-K
2026-09-18 12:07:46 +08:00
committed by Teknium
parent bfb01087d8
commit aed682629f
4 changed files with 89 additions and 5 deletions

View File

@@ -1045,8 +1045,15 @@ export function ChatSidebar({
// overlay always has a lane to place a missing in-project session into.
const enteredProjectContent = useMemo(
() =>
enteredProject ? overlayLiveLanes(enteredProject, enteredProjectOverlaySessions, removedSessionIds) : undefined,
[enteredProject, enteredProjectOverlaySessions, removedSessionIds]
enteredProject
? overlayLiveLanes(
enteredProject,
enteredProjectOverlaySessions,
removedSessionIds,
projectOwnerBySessionId(agentProjectTree)
)
: undefined,
[enteredProject, enteredProjectOverlaySessions, removedSessionIds, agentProjectTree]
)
const scopedRepoPaths = useMemo(

View File

@@ -15,6 +15,7 @@ export {
liveSessionProjectId,
overlayLiveLanes,
overlayLivePreviews,
projectOwnerBySessionId,
reconcileEnteredProjectSessions,
sessionBucketId,
sessionMatchesProjectFilter,

View File

@@ -13,6 +13,7 @@ import {
NO_PROJECT_ID,
overlayLiveLanes,
overlayLivePreviews,
projectOwnerBySessionId,
reconcileEnteredProjectSessions,
sessionMatchesProjectFilter,
sessionProjectColor,
@@ -615,6 +616,36 @@ describe('sessionProjectColor', () => {
})
describe('overlayLiveLanes', () => {
it('does not inject a backend-owned sibling worktree session into an ancestor project', () => {
const sibling = makeCwdSession('/work/repos/app-2/src', { id: 'sibling', git_repo_root: null })
const ancestor = projectNode({
id: 'p_work',
path: '/work',
repos: [{ id: '/work', label: 'work', path: '/work', groups: [], sessionCount: 0 }]
})
const repo = projectNode({
id: 'p_app',
path: '/work/repos/app',
repos: [
{
id: '/work/repos/app',
label: 'app',
path: '/work/repos/app',
groups: [lane({ id: '/work/repos/app-2', label: 'app-2', path: '/work/repos/app-2', sessions: [sibling] })],
sessionCount: 1
}
],
previewSessions: [sibling],
sessionCount: 1
})
const overlaid = overlayLiveLanes(ancestor, [sibling], new Set(), projectOwnerBySessionId([ancestor, repo]))
expect(overlaid.sessionCount).toBe(0)
})
it('keeps an overview preview visible when the hydrated drill-in is stale', () => {
const staleHistory = makeCwdSession('/www/app', {
id: 'stale-history',
@@ -1085,6 +1116,19 @@ describe('overlayLiveLanes', () => {
})
describe('overlayLivePreviews', () => {
it('keeps a backend-owned sibling worktree session out of an ancestor preview when git_repo_root is null', () => {
const sibling = makeCwdSession('/work/repos/app-2/src', { id: 'sibling', git_repo_root: null })
const ancestor = projectNode({ id: 'p_work', path: '/work', previewSessions: [], sessionCount: 0 })
const repo = projectNode({ id: 'p_app', path: '/work/repos/app', previewSessions: [sibling], sessionCount: 1 })
const previews = overlayLivePreviews([ancestor, repo], [sibling], [
makeProject('p_work', ['/work']),
makeProject('p_app', ['/work/repos/app'])
], 3)
expect(previews.p_work).toBeUndefined()
expect(previews.p_app?.map(session => session.id)).toEqual(['sibling'])
})
it('merges live sessions into a project preview, live first, capped to the limit', () => {
const project = projectNode({
id: '/www/app',

View File

@@ -476,6 +476,30 @@ export function sessionProjectColor(session: SessionInfo, projects: ProjectInfo[
return projects.find(project => project.id === projectId)?.color ?? null
}
/**
* Membership in a project-tree snapshot is backend-resolved: in particular,
* the git probe can identify a sibling worktree even while its persisted row
* has not yet been backfilled with `git_repo_root`. Keep that answer when the
* live cache refreshes the same row instead of re-inferring ownership from its
* cwd (which can make an umbrella project claim it as well).
*/
export function projectOwnerBySessionId(projects: SidebarProjectTree[]): ReadonlyMap<string, string> {
const owners = new Map<string, string>()
for (const project of projects) {
const sessions = [
...(project.previewSessions ?? []),
...project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions))
]
for (const session of sessions) {
owners.set(session.id, project.id)
}
}
return owners
}
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => sessionRecency(b) - sessionRecency(a))
@@ -746,7 +770,8 @@ export function excludeProjectSessions(
export function overlayLiveLanes(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
removed: ReadonlySet<string> = NO_REMOVED,
authoritativeOwners: ReadonlyMap<string, string> = new Map()
): SidebarProjectTree {
if (project.isNoProject) {
return overlayHomeLane(project, live, removed)
@@ -754,8 +779,14 @@ export function overlayLiveLanes(
let changed = false
const projectLive = live.filter(session => {
const owner = authoritativeOwners.get(session.id)
return !owner || owner === project.id
})
const repos = project.repos.map(repo => {
const next = overlayRepoLanes(repo, live, removed)
const next = overlayRepoLanes(repo, projectLive, removed)
changed ||= next !== repo
@@ -804,13 +835,14 @@ export function overlayLivePreviews(
{ removed = NO_REMOVED, rankIds }: PreviewOverlayOptions = {}
): Record<string, SessionInfo[]> {
const byProject = new Map<string, SessionInfo[]>()
const authoritativeOwners = projectOwnerBySessionId(projects)
for (const session of live) {
if (removed.has(session.id)) {
continue
}
const projectId = sessionBucketId(session, explicitProjects)
const projectId = authoritativeOwners.get(session.id) ?? sessionBucketId(session, explicitProjects)
if (!projectId) {
continue