Skip to content

fix(desktop): stop rendering an unhydrated project as lane headers with no rows - #107176

Closed
thirstycrow wants to merge 1 commit into
NousResearch:mainfrom
thirstycrow:fix/entered-project-empty-lanes
Closed

thirstycrow wants to merge 1 commit into
NousResearch:mainfrom
thirstycrow:fix/entered-project-empty-lanes

Conversation

@thirstycrow

Copy link
Copy Markdown

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 main

  1. The overview tree ships lanes without rows on purpose. projects.tree builds with hydrate=False (hydrate=True is drill-in-only), and tui_gateway/project_tree.py:271-272 then empties every lane (group["sessions"] = []). Only projects.project_sessions returns rows. Verified against a real backend: the overview node for a project reports sessionCount: 43 with main → sessions: [], while the drill-in RPC returns the same lane with all 43 rows.
  2. The sidebar falls back to that node while the drill-in read is pending (apps/desktop/src/app/chat/sidebar/index.tsx: enteredProjectTree?.id === overviewEnteredProject.id ? enteredProjectTree : overviewEnteredProject).
  3. hasProjectContent counted the fallback as content — projectContent.sessionCount > 0 || repos.some(r => r.groups.length > 0) — and the overview node does carry sessionCount. So EnteredProjectContent rendered instead of the loading/empty state.
  4. A lane with zero rows renders collapsed: 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 fetchProjectSessions resolved null for a superseded read exactly as it did for "no such project", and the hook committed that null as 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 44 git worktree entries produce 16 lanes where the first (main) carries 43 rows — on the unhydrated fallback the same shape renders as 16 empty lanes.

Fix

  • fetchProjectSessions reports a superseded read as ProjectSessionsSuperseded instead of resolving it. A stale failure is no more evidence than a stale answer, so it is reported the same way.
  • The drill-in hook retries a superseded read (bounded: 3 × 150 ms, inside the existing effect) and reports failure once the budget is spent — which the sidebar already renders as its Retry affordance (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 --check on 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 (the null wins).
  • 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 old null contract 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/model mock now spreads the real module, so a new export cannot silently vanish from the mock.

… 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.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) area/sessions Session lifecycle, resume, persistence, history labels Sep 10, 2026
@thirstycrow

Copy link
Copy Markdown
Author

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: fetchProjectSessions returns null for a read that was superseded (generation bump / no profile scope), which the hook commits via setProject(null) — indistinguishable from an authoritative empty project. Reproduced on the current base (reverting this PR's two source files): the superseded read resolves null instead of rejecting, and the structure-only overview node (sessionCount 43, zero lane rows) still counts as content. Diff is left intact for reference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants