fix(desktop): stop rendering an unhydrated project as lane headers with no rows - #107176
thirstycrow wants to merge 1 commit into
Conversation
… no rows The drill-in read (`projects.project_sessions`) resolved `null` both for "no such project" and for "a newer request superseded this one", and the hook committed that as the project. The sidebar then fell back to the overview node — whose lanes carry no rows by design (`hydrate=False`) — while `hasProjectContent` still counted it as content, because the overview node keeps its `sessionCount`. Entering a project could therefore show every branch header with nothing under it, silently and permanently. - `fetchProjectSessions` reports a superseded read as `ProjectSessionsSuperseded` instead of resolving it (a stale FAILURE is no more evidence than a stale answer), so no caller can mistake a discarded read for an empty project. - The drill-in hook retries a superseded read (bounded) and, once the budget is spent, reports failure — the sidebar already renders that as its Retry affordance rather than as an empty project. - `enteredProjectHasContent` keeps the structure-only fallback from counting as content, so the loading skeleton / empty state renders instead of empty lanes.
|
Closing in favour of keeping this as a local fix on my machine. Context for anyone arriving here later: the symptom class is already covered upstream by #104964 (merged, issue #104280) for the case where the drill-in fetch rejects, and #104301 covers the reporting side. The narrow path this PR closed is still open on top of both: |
What
Entering a project could render every branch/worktree lane with no session rows under it — permanently — while the drill-in read was returning the project's sessions the whole time.
Why it happens on current
mainprojects.treebuilds withhydrate=False(hydrate=Trueis drill-in-only), andtui_gateway/project_tree.py:271-272then empties every lane (group["sessions"] = []). Onlyprojects.project_sessionsreturns rows. Verified against a real backend: the overview node for a project reportssessionCount: 43withmain → sessions: [], while the drill-in RPC returns the same lane with all 43 rows.apps/desktop/src/app/chat/sidebar/index.tsx:enteredProjectTree?.id === overviewEnteredProject.id ? enteredProjectTree : overviewEnteredProject).hasProjectContentcounted the fallback as content —projectContent.sessionCount > 0 || repos.some(r => r.groups.length > 0)— and the overview node does carrysessionCount. SoEnteredProjectContentrendered instead of the loading/empty state.workspace-group.tsx—defaultOpen = isProfileGroup || group.sessions.length > 0.Net effect: the lane headers/tree render, nothing under them. And it can stay that way forever, because
fetchProjectSessionsresolvednullfor a superseded read exactly as it did for "no such project", and the hook committed thatnullas the project — so the empty fallback became the permanent state, with no error and no Retry affordance.Reproduced end-to-end with real data: the drill-in payload + the app's own lane-merge helpers (
mergeRepoWorktreeGroups,overlayLiveLanes) against a repo with 44git worktreeentries produce 16 lanes where the first (main) carries 43 rows — on the unhydrated fallback the same shape renders as 16 empty lanes.Fix
fetchProjectSessionsreports a superseded read asProjectSessionsSupersededinstead of resolving it. A stale failure is no more evidence than a stale answer, so it is reported the same way.SidebarLoadErrorState), instead of a project that looks empty.enteredProjectHasContent(project, hydrated)keeps the structure-only fallback from counting as content, so the skeleton/empty state renders instead of empty lanes. Rows placed by the live overlay still count as content, and a hydrated project whose rows were all filtered out (pinned) keeps rendering its real lanes.Tests
npx vitest run src/app/chat/sidebar src/store→ 2561 passed (209 files).npx tsc -p . --noEmit,eslint,prettier --checkon the touched files → clean.use-entered-project-sessions.test.ts: a superseded read is retried and never committed as an empty project. Proven red on base — with the old source and these tests, the project is never set (thenullwins).projects/model.test.ts: the structure-only fallback is not content; the same node with rows, and a hydrated project whose rows were filtered away, still are.store/projects.test.ts: the two tests asserting the oldnullcontract now assert the superseded one. Their intent (a late answer from the previous profile must not land; an all-profiles read must not fire) is unchanged.sessions-section-new-session-drag.test.tsx: its./projects/modelmock now spreads the real module, so a new export cannot silently vanish from the mock.