fix(desktop): retain backend worktree project ownership
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
liveSessionProjectId,
|
||||
overlayLiveLanes,
|
||||
overlayLivePreviews,
|
||||
projectOwnerBySessionId,
|
||||
reconcileEnteredProjectSessions,
|
||||
sessionBucketId,
|
||||
sessionMatchesProjectFilter,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user