fix(desktop): keep root-only project chats out of Home

A session row with an empty cwd but a persisted git_repo_root fell to Home:
_project_for_session bailed on the empty cwd before matching the repo root,
and _place_session refused to place it, so the backend owner map made Home
the chat's single owner while the renderer (root-aware) still labeled it with
its project. Match and place on cwd, else git_repo_root; a row is homeless
only when it has neither, matching the renderer's isDetachedSession.

Entered Home's live overlay also upserted every briefly-detached live row
without consulting the authoritative owner map, so a named-project chat whose
session.info landed with an empty cwd listed in Home as well. overlayHomeLane
now takes the owner map from overlayLiveLanes and skips rows (by id or
lineage root) owned by a named project.

Refs #77591 (symptom D, entered-project refetch lag, is not addressed).

Co-authored-by: networthexplained <300128320+networthexplained@users.noreply.github.com>
This commit is contained in:
brooklyn!
2026-09-24 03:56:30 -05:00
parent 94dcb170fe
commit 03add1fe7a
4 changed files with 67 additions and 12 deletions

View File

@@ -1080,6 +1080,31 @@ describe('overlayLiveLanes', () => {
expect(overlaid.sessionCount).toBe(2)
})
it('never lists a chat owned by a named project in Home, even while its live copy is detached', () => {
// #77591: the snapshot assigns the chat to p_app, but session.info can land
// with an empty cwd + root. Home must defer to the one owner, including by
// lineage root after compression rotates the live id.
const home = homeNode([makeCwdSession(null, { id: 'stale' })])
const owners = new Map([
['owned', 'p_app'],
['root', 'p_app'],
['stale', 'p_app'],
['homeless', NO_PROJECT_ID]
])
const live = [
makeCwdSession(null, { id: 'owned' }),
makeCwdSession(null, { id: 'tip', _lineage_root_id: 'root' }),
makeCwdSession(null, { id: 'homeless' })
]
const overlaid = overlayLiveLanes(home, live, new Set(), owners)
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['homeless'])
expect(overlaid.sessionCount).toBe(1)
})
it('leaves Home alone for a session that has a cwd', () => {
// A cwd-carrying row the backend hasn't placed yet (junk root, deleted
// workspace) needs its probes — guessing here would flicker it into Home

View File

@@ -711,16 +711,27 @@ export function overlayRepoLanes(
/**
* Home's overlay: its rows have no cwd to place, so this is a plain upsert of
* detached live sessions into its single lane — a brand-new project-less chat
* shows the instant it's created, matching the flat Recents list.
* shows the instant it's created, matching the flat Recents list. A chat the
* authoritative owner map gives to a named project stays out, even while its
* live copy is briefly detached (else it lists in both places).
*/
function overlayHomeLane(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string>
removed: ReadonlySet<string>,
owners: ReadonlyMap<string, string>
): SidebarProjectTree {
const ownedElsewhere = (session: SessionInfo): boolean => {
const owner =
owners.get(session.id) ?? (session._lineage_root_id ? owners.get(session._lineage_root_id) : undefined)
return Boolean(owner) && owner !== NO_PROJECT_ID
}
const belongs = (session: SessionInfo): boolean => !removed.has(session.id) && !ownedElsewhere(session)
const lane = project.repos[0]?.groups[0]
const detached = live.filter(session => isDetachedSession(session) && !removed.has(session.id))
const kept = (lane?.sessions ?? []).filter(session => !removed.has(session.id))
const detached = live.filter(session => isDetachedSession(session) && belongs(session))
const kept = (lane?.sessions ?? []).filter(belongs)
if (!detached.length && kept.length === (lane?.sessions.length ?? 0)) {
return project
@@ -803,7 +814,7 @@ export function overlayLiveLanes(
authoritativeOwners: ReadonlyMap<string, string> = NO_OWNERS
): SidebarProjectTree {
if (project.isNoProject) {
return overlayHomeLane(project, live, removed)
return overlayHomeLane(project, live, removed, authoritativeOwners)
}
let changed = false

View File

@@ -659,3 +659,19 @@ def test_equivalent_windows_spellings_derive_one_lane_key():
b = pt._place_by_heuristic("C:\\work\\notes\\")
assert a is not None and b is not None
assert pt._lane_key(a["lane_key"]) == pt._lane_key(b["lane_key"])
def test_cwdless_session_with_repo_root_stays_in_its_explicit_project():
"""A row with an empty cwd but a persisted git_repo_root belongs to the project owning
that root, not Home (#77591). Home keeps only rows with neither anchor, matching the
renderer's ``isDetachedSession``."""
project = _project("p_app", "App", ["/www/app"])
owned = _session(None, repo_root="/www/app", branch="main")
detached = _session(None)
tree = pt.build_tree([project], [owned, detached], [], resolve=lambda _cwd: None, hydrate=True)
explicit = next(p for p in tree["projects"] if p["id"] == "p_app")
assert owned["id"] in explicit["sessionIds"]
assert owned["id"] in [s["id"] for s in _sessions_of(explicit)]
assert _home_session_ids(tree) == [detached["id"]]

View File

@@ -190,11 +190,13 @@ def _place(
def _place_session(session: dict, resolve: Optional[Resolve]) -> Optional[dict]:
"""``_place`` for a session row; ``None`` when it has no cwd."""
cwd = _field(session, "cwd")
if not cwd:
"""``_place`` for a session row, anchored on its cwd or else its persisted repo root;
``None`` only when it has neither (the renderer's ``isDetachedSession``)."""
root = _field(session, "git_repo_root")
anchor = _field(session, "cwd") or root
if not anchor:
return None
return _place(cwd, _field(session, "git_branch"), resolve, _field(session, "git_repo_root"))
return _place(anchor, _field(session, "git_branch"), resolve, root)
def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
@@ -321,10 +323,11 @@ class _FolderIndex:
def _project_for_session(
session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
cwd = _field(session, "cwd")
if not cwd:
return None
repo_root = _session_repo_root(session, resolve)
candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd]
# A root-only row (empty cwd) still belongs to the project owning its root.
candidates = [t for t in dict.fromkeys((cwd, repo_root)) if t]
if not candidates:
return None
# Longest folder match wins; ties keep the cwd match (max() keeps the first maximum).
return max((index.match(t) for t in candidates), key=lambda hit: hit[1])[0]