Skip to content

fix(desktop): honest sidebar load-more — source-scoped lists, matching totals, pin-aware counts - #151

Merged
OmarB97 merged 2 commits into
mainfrom
fix/sidebar-load-more-honesty
Jun 10, 2026
Merged

fix(desktop): honest sidebar load-more — source-scoped lists, matching totals, pin-aware counts#151
OmarB97 merged 2 commits into
mainfrom
fix/sidebar-load-more-honesty

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Why

The operator reported "Load more" appearing when nothing visibly overflows, and count labels that never settle (the morning recording showed Sessions 17/18 with everything loaded). Investigation found the affordance was structurally dishonest, for three independent reasons:

  1. The recents universe was polluted. The unified session fetch had no source filter, so cron and messaging-platform sessions rode the recents list and its totals — the primary profile today carries 129 cron + 92 telegram sessions. Meanwhile the cron/messaging sidebar slices documented in store/session.ts had no writers at all (setCronSessions/setMessagingSessions are referenced only in the store): the per-platform messaging sections never rendered, and a pinned cron run resolved only while it happened to sit in the recents page. The source-scoping machinery upstream carries (source=/exclude_sources= on both session endpoints + the controller slice fetches) was dropped in a fork merge.
  2. Counts and pages could disagree. Without exclude_sources on session_count/surfaced_session_count, any filtered page was paired with an unfiltered total — "Load more" then advertises pages that can never arrive.
  3. Pinned rows broke the label math. The Sessions label counted unpinned rows against a pin-inclusive total (17/18 forever after pinning one row), and the messaging sections (post drag-to-pin) treated pinned rows as not-loaded, over-reporting their pagers.

What changed

Commit 1 — fix(desktop): restore source-scoped session lists… (port of upstream's design, fork-only):

  • hermes_state.pysession_count + surfaced_session_count gain exclude_sources (mirroring list_sessions_rich, which already had it), so counts can apply the exact filter their paired page uses.
  • hermes_cli/web_server.py/api/sessions and /api/profiles/sessions accept source= / exclude_sources= (comma-separated) and thread them into BOTH the page query and the count, per profile on the unified endpoint.
  • apps/desktop/src/hermes.tsSessionSourceFilter + the filter argument on listAllProfileSessions.
  • apps/desktop/src/app/desktop-controller.tsx — recents fetch excludes cron + messaging sources; refreshCronSessions (source=cron, pin resolution), refreshMessagingSessions (inverse exclusion, split per platform, truncation flag), and loadMoreMessagingForPlatform (per-source pager resolving exact totals) now actually populate the store slices; recents are scoped to the active profile with a scope-change refetch effect (identity-stable variant of upstream's reactive dep — refreshSessions flows into useGatewayBoot/usePromptActions, so its identity is kept stable and the effect owns the refetch); onLoadMoreMessaging is finally passed to the sidebar.
  • tests/hermes_cli/test_web_server.py — wiring-capture test asserting both params reach page AND count on /api/sessions (the exact "param silently dropped in a merge" failure mode), plus a seeded-DB test pinning total == listable rows for all three slices (recents-excluded, cron, telegram) on /api/profiles/sessions.

Commit 2 — fix(desktop): make sidebar count labels and load-more honest about pinned rows (upstream-relevant):

  • apps/desktop/src/app/chat/sidebar/index.tsx — pinned rows are dropped from BOTH sides of the Sessions label (they're always loaded via the refresh keep-set, so the unpinned remainder is identical in both universes and the label now agrees with the footer); messaging sections count pinned rows as loaded (loadedCount) for their label + pager while still housing the rows in Pinned, take section recency from every loaded row (no reshuffle when the newest thread gets pinned), and only drop a section when it has nothing to show AND nothing more on disk.

How to review

  1. hermes_state.py — the two count functions; the added clause is byte-identical to list_sessions_rich's.
  2. web_server.py — both handlers thread source/exclude_list to page + count; nothing else moved.
  3. desktop-controller.tsxSIDEBAR_EXCLUDED_SOURCES/MESSAGING_EXCLUDED_SOURCES constants and the three new callbacks; note the comment explaining the deliberate deviation from upstream (scope read at call time + separate effect, instead of a reactive dep).
  4. sidebar/index.tsxpinnedFromRecentsCount/agentKnownTotal for the label; loadedCount/latestActivity in messagingGroups and their two render-site uses.
  5. Tests last — they encode the invariant: a section's total counts only rows that section can ever list.

Evidence

  • New endpoint tests: 3/3 pass; full tests/hermes_cli/test_web_server.py: 248 passed; tests/test_hermes_state.py -k "count or list_sessions": 21 passed.
  • Live-data check (TestClient against a copy of the operator's real state.db, auth header included): old vs new recents paths return consistent rows == total with zero rows lost; source=cron / source=telegram slices return exactly their own rows with matching totals. (The live DB currently lists few unarchived roots — the operator ran the freshly-restored Archive All earlier today — so the synthetic-DB test carries the cardinality assertions.)
  • Renderer harness (real Chromium, seeded stores): 5 local loaded / total 5 / 1 pinned → header reads Sessions 4 with no Load more (previously 4/5 + phantom footer); bumping the total to 9 → header 4/8 + footer "Load 4 more" (label and footer agree in the unpinned universe); Discord section with both conversations loaded and one pinned → header Discord 2, one row rendered, no phantom per-platform Load more.
  • npm run type-check, npx eslint (all touched files), production vite build: pass. Full renderer vitest suite: failure set byte-identical to the feat(desktop): drag-to-pin follow-ups — positional drop, messaging pins render, drag hint #146 baseline run (diff-verified).

Verification

  • …/.venv/bin/python -m pytest tests/hermes_cli/test_web_server.py -q → 248 passed.
  • npx vitest run --environment jsdom → no new failures vs the recorded baseline set (diff clean).
  • npm run type-check / npx eslint … / npm run build → pass.
  • Re-runnable live check: HERMES_HOME=<copy> python -c "...TestClient..." with X-Hermes-Session-Token header — /api/profiles/sessions?exclude_sources=cron,telegram,… returns total == rows for the operator's data.

Risks / gaps

  • First refresh after deploy may briefly keep stale messaging/cron rows inside $sessions (the merge keep-set preserves pinned ones indefinitely); they don't render in recents (pin-filtered) and only feed pin resolution — accepted, self-corrects for unpinned rows on the next page fetch.
  • exclude_sources uses SQL NOT IN, which drops NULL-source rows; the operator's DBs have zero NULL sources (verified: cli/tui/cron/telegram/api_server/unknown) and the schema declares source TEXT NOT NULL — non-actionable here, and matching upstream's exact semantics keeps the port faithful.
  • Messaging platforms whose threads all have message_count=0 (the operator's 92 telegram rows today) stay invisible under the min_messages=1 policy shared with recents — pre-existing policy, out of scope; noted on MeshBoard task hermes-desktop-sidebar-load-more-honesty.
  • The sidebar-honesty commit applies to the upstream port PR (feat(desktop): refine sidebar session drag reordering NousResearch/hermes-agent#43661) and is cherry-picked there; the source-scoping commit is fork-only (upstream already ships it).

Collaborators

  • @OmarB97 (operator — reported the phantom Load more)
  • Claude Fable 5 (ko-mac.claude — investigation, port, tests, verification)

Omar Baradei and others added 2 commits June 10, 2026 11:25
… match listable rows

The sidebar's load-more math was structurally dishonest: the recents
fetch had no source filter, so cron and messaging-platform sessions
(129 cron + 92 telegram in the primary profile today) rode the unified
list and inflated every total, while the cron/messaging sidebar slices
documented in the session store had NO writers at all — the messaging
sections never rendered and a pinned cron run could only resolve while
it happened to sit in the recents page.

Restore the source-scoping machinery the fork merge dropped (upstream
carries it): /api/sessions and /api/profiles/sessions accept source= /
exclude_sources= and thread them into BOTH the page query and the
count (session_count/surfaced_session_count gain exclude_sources), so a
section's total can only count rows that section can ever list. The
desktop fetches recents with exclude_sources=cron+messaging, the cron
slice with source=cron, the messaging slice with the inverse exclusion
split per platform, and per-platform pagers resolve exact totals. The
recents fetch is also scoped to the active profile, with a scope-change
effect (identity-stable refreshSessions variant of upstream's reactive
dep) so switching to a small profile refetches instead of showing the
previous scope's page.

Tests: wiring-capture test for both params on page+count (the exact
'param silently dropped in a merge' failure mode this repairs), plus a
real-DB test pinning rows==total for all three sidebar slices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nned rows

The Sessions label counted unpinned rows against a total that includes
pinned ones, so pinning a single row left the count stuck at '17/18'
forever — advertising a page that can never arrive (the operator's
exact report). Pinned rows are always loaded (the refresh keep-set
preserves them), so drop them from BOTH sides of the label; the footer
already used pin-consistent math and now agrees with the label in the
unpinned universe.

Messaging sections get the same honesty: pinned rows are hidden from
the section (they live in Pinned) but still count as LOADED for the
count label and per-platform load-more, so hiding them can't make the
pager think more rows remain on disk. Section recency now comes from
every loaded row so a platform doesn't reshuffle when its newest thread
gets pinned, and a platform only drops its section when it has nothing
left to show AND nothing more on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🔎 Lint report: fix/sidebar-load-more-honesty vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10597 on HEAD, 10583 on base (🆕 +14)

🆕 New issues (7):

Rule Count
invalid-argument-type 6
invalid-parameter-default 1
First entries
hermes_cli/web_server.py:1814: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.list_sessions_rich` is incorrect: Expected `list[str]`, found `(list[str] & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1750: [invalid-parameter-default] invalid-parameter-default: Default value of type `None` is not assignable to annotated parameter type `str`
hermes_cli/web_server.py:1717: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.surfaced_session_count` is incorrect: Expected `list[str]`, found `(list[str] & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1813: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.list_sessions_rich` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1824: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.session_count` is incorrect: Expected `list[str]`, found `(list[str] & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1823: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.session_count` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1716: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.surfaced_session_count` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`

✅ Fixed issues: none

Unchanged: 5544 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@OmarB97
OmarB97 merged commit 215193d into main Jun 10, 2026
17 of 23 checks passed
@OmarB97
OmarB97 deleted the fix/sidebar-load-more-honesty branch August 2, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant