Skip to content

feat(sidebar): add unread-only session filter toggle (#3910)#4068

Open
rodboev wants to merge 8 commits into
nesquena:masterfrom
rodboev:pr/3910-unread-only-sidebar-filter
Open

feat(sidebar): add unread-only session filter toggle (#3910)#4068
rodboev wants to merge 8 commits into
nesquena:masterfrom
rodboev:pr/3910-unread-only-sidebar-filter

Conversation

@rodboev

@rodboev rodboev commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • The sidebar already knows which sessions are unread, but that signal only decorates rows and the nav badge today.
  • The repo already has the right persistence and chip-style patterns through the existing source filter, so this does not need a brand-new filtering framework.
  • The right slice is to add one composable unread-only filter in the existing sidebar pipeline instead of bolting unread logic onto individual row rendering paths.

What Changed

  • static/sessions.js: add unread-only filter state, persistence, UI wiring, and filter application in the sidebar pipeline without mutating viewed-count state during filtering; also clear project scope on toggle and preserve the CLI-settings hint when unread-only is combined with the CLI source tab.
  • static/style.css: style the unread-only toggle to match the existing sidebar filter controls.
  • tests/test_issue3910_unread_only_sidebar_filter.py: cover unread-only filtering, composition with source filters, persisted state restore, and the unread-only empty-state/source-filter interactions.

Why It Matters

Users can jump straight to conversations that need attention instead of scanning long session lists for unread dots, which is especially useful on mobile and high-volume installs.

Verification

pytest tests/test_issue3910_unread_only_sidebar_filter.py -v --timeout=60
npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs static/sessions.js static/style.css

Full-suite CI context, not a required local check unless requested: pytest tests/ -v --timeout=60.

Risks / Follow-ups

  • This should compose cleanly with existing source/project/profile/archive filters; if reviewers want more generalized multi-filter state later, that should build on this path instead of replacing it immediately.
  • If active-session visibility needs a special case while the unread-only filter is enabled, keep that rule minimal and covered explicitly in tests.

Model Used

GPT 5.5 via Codex CLI

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds an unread-only filter toggle to the sidebar session list, allowing users to quickly surface conversations that need attention. The implementation hooks into the existing _partitionSidebarSessionRows pipeline, adds persistent localStorage state, and wires up a chip-style toggle button consistent with the existing source-filter UI.

  • _partitionSidebarSessionRows now builds a Map (unreadById) of per-session unread state in one pass and applies the unread predicate before pushing to sessionsRaw; the map is returned to renderSessionListFromCache so _renderOneSession no longer needs to re-compute unread status on its own (replacing the direct _hasUnreadForSession call at render time).
  • Active-lineage preservation is handled by a new _sessionMatchesActiveLineageForUnreadFilter helper that keeps the active session, its direct parent, and any session sharing the same lineage key, preventing the active child's parent row from vanishing when the filter is toggled.
  • Tests cover the unread-only toggle's composition with source filters and a node-executed lineage collapse + attach end-to-end path; source-code pattern assertions verify wiring without executing the filter predicate in a browser context.

Confidence Score: 5/5

Safe to merge — the filter composes cleanly with existing source/project/archive gates and the previously identified crash (unreadById undefined) is fully resolved in this revision.

The partition loop builds and returns unreadById correctly; _renderOneSession consumes it via Boolean(unreadById.get(...)) with safe fallback; the active-lineage bypass prevents the active session from vanishing when its conversation is read; and localStorage persistence mirrors the established source-filter pattern. No new defects were found in the net diff.

No files require special attention — the main logic change in static/sessions.js is self-contained and the lineage test in tests/test_session_lineage_collapse.py provides end-to-end coverage of the trickiest path.

Important Files Changed

Filename Overview
static/sessions.js Core filter implementation: new helpers for unread detection, label, set/restore state, and active-lineage bypass; partition loop now builds unreadById Map and applies unread predicate; render path swapped to use pre-computed Map. Previously flagged P0 (unreadById undefined crash) is fully resolved.
static/style.css Adds flex-wrap to the source-tab container and a .session-source-toggle modifier that gives the unread button fixed sizing, consistent with existing chip style.
tests/test_issue3910_unread_only_sidebar_filter.py Source-code pattern tests verify wiring (state variable, localStorage key, UI construction, predicate string). Does not execute the filter logic, so an inversion in the predicate would silently pass, but covers the structural surface area of the feature.
tests/test_session_lineage_collapse.py New node-backed end-to-end test validates that with unread-only active and a child as the active session, partition → collapse → attach correctly keeps parent "tip" in sessionsRaw and attaches "child" to it without orphaning.
tests/test_sidebar_session_partition.py Existing assertion updated to match the extended guard condition (!_sessionUnreadOnlyFilter) in the auto-reset of _sessionSourceFilter; no new logic added.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[renderSessionListFromCache] --> B[_partitionSidebarSessionRows]
    B --> C{_sidebarRowHasVisibleMessages?}
    C -- No --> SKIP1[skip]
    C -- Yes --> D{project / archive filters}
    D -- filtered --> SKIP2[skip]
    D -- passes --> E[_sessionHasUnreadForSidebar\nunreadById.set]
    E --> F{_sessionUnreadOnlyFilter?}
    F -- No --> PUSH[sessionsRaw.push]
    F -- Yes --> G{hasUnread OR keepActiveLineage?}
    G -- Yes --> PUSH
    G -- No --> SKIP3[skip]
    PUSH --> H[return unreadById + sessionsRaw + unreadCount]
    H --> I[_renderSidebarRowsFromRawSessions]
    I --> J[_renderOneSession\nunreadById.get to set .unread class]
    B --> K{_sessionUnreadOnlyFilter toggle UI}
    K --> L[Unread only N button\npersisted in localStorage]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[renderSessionListFromCache] --> B[_partitionSidebarSessionRows]
    B --> C{_sidebarRowHasVisibleMessages?}
    C -- No --> SKIP1[skip]
    C -- Yes --> D{project / archive filters}
    D -- filtered --> SKIP2[skip]
    D -- passes --> E[_sessionHasUnreadForSidebar\nunreadById.set]
    E --> F{_sessionUnreadOnlyFilter?}
    F -- No --> PUSH[sessionsRaw.push]
    F -- Yes --> G{hasUnread OR keepActiveLineage?}
    G -- Yes --> PUSH
    G -- No --> SKIP3[skip]
    PUSH --> H[return unreadById + sessionsRaw + unreadCount]
    H --> I[_renderSidebarRowsFromRawSessions]
    I --> J[_renderOneSession\nunreadById.get to set .unread class]
    B --> K{_sessionUnreadOnlyFilter toggle UI}
    K --> L[Unread only N button\npersisted in localStorage]
Loading

Reviews (10): Last reviewed commit: "Re-run opaque shard-2 CI failures" | Re-trigger Greptile

Comment thread static/sessions.js Outdated
Comment thread static/sessions.js
Comment thread tests/test_issue3910_unread_only_sidebar_filter.py
@rodboev rodboev force-pushed the pr/3910-unread-only-sidebar-filter branch from f5c406d to 5d55c36 Compare June 12, 2026 18:22
Comment thread static/sessions.js
@rodboev

rodboev commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

The 3 red CI runs look like a flaky shard to me since they are all shard-2-only. I can dive deeper if needed, let me know.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

The three red shards aren't flaky — they're a deterministic failure, and a single change is responsible for all three. pytest-shard partitions by a hash of the test id, so a given test always lands on the same shard regardless of Python version (see .github/workflows/tests.yml:69-76). A truly flaky test fails on random shards; "shard-2-only across 3.11/3.12/3.13" means one specific test is failing every time on shard 2.

Root cause

This is a string-snapshot test that asserts on a literal in sessions.js that your PR edited. On origin/master:

# tests/test_sidebar_session_partition.py:31
assert "if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0)" in block

Your diff changes that exact line in static/sessions.js (it was :4738 on master) to add the unread-only guard:

// static/sessions.js (PR head, _partitionSidebarSessionRows)
if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0 && !_sessionUnreadOnlyFilter){

The assertion looks for cliSessionCount===0) with the closing paren glued directly to 0. After your edit the source reads cliSessionCount===0 && !_sessionUnreadOnlyFilter){, so that substring no longer exists and the in check returns False. Deterministic fail, every run.

Notably your own new test in tests/test_issue856_background_completion_unread.py already asserts the new form:

# test_unread_only_composes_with_source_filter_before_row_rendering
assert (
    "if(_sessionSourceFilter==='cli' && !window._showCliSessions && "
    "cliSessionCount===0 && !_sessionUnreadOnlyFilter){"
) in SESSIONS_JS

So the two tests now disagree about what that line should say — test_sidebar_session_partition.py was just never updated to match the change.

Fix

Update the stale assertion at tests/test_sidebar_session_partition.py:31 to the new line. Minimal patch:

    assert (
        "if(_sessionSourceFilter==='cli' && !window._showCliSessions && "
        "cliSessionCount===0 && !_sessionUnreadOnlyFilter){"
    ) in block

That keeps the intent of the assertion (the source-filter auto-reset gate still lives inside _partitionSidebarSessionRows) while reflecting the new unread-only composition. Once that's updated, shard 2 should go green on all three versions. I grepped the rest of tests/ and that line at test_sidebar_session_partition.py:31 is the only place still pinning the old cliSessionCount===0) literal, so it should be the single fix needed.

@rodboev rodboev force-pushed the pr/3910-unread-only-sidebar-filter branch from 28f4aaf to 2a3c15d Compare June 12, 2026 19:47
Comment thread static/sessions.js Outdated
@rodboev

rodboev commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Updated the stale sidebar snapshot assertion in 2a3c15d so it matches the unread-filter gate now present in _partitionSidebarSessionRows. Focused local pytest passed for ests/test_sidebar_session_partition.py -v --timeout=60 and ests/test_issue856_background_completion_unread.py -v --timeout=60.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

CI is green now — the stale snapshot assertion at tests/test_sidebar_session_partition.py:31 is fixed in 2a3c15d1, so shard 2 is resolved. Reading the partition + lineage path at HEAD (static/sessions.js _partitionSidebarSessionRows, _collapseSessionLineageForSidebar, _attachChildSessionsToSidebarRows), I think the Greptile lineage concern is real but overstated — the active session does not vanish, but its lineage anchor row can, which is a subtler UX regression worth handling.

What actually happens

The unread bypass protects the active session by exact id:

// static/sessions.js:4798
if(_sessionUnreadOnlyFilter&&!hasUnread&&s.session_id!==activeSidForSidebar) continue;

activeSidForSidebar is the exact S.session.session_id (_activeSessionIdForSidebar, sessions.js:4518). The renderer, though, treats a row as "active" whenever it contains the active session anywhere in its lineage:

// static/sessions.js:4227
function _sessionLineageContainsSession(s, sid){
  if(s.session_id===sid) return true;
  if(Array.isArray(s._lineage_segments)&&s._lineage_segments.some(seg=>seg&&seg.session_id===sid)) return true;
  if(Array.isArray(s._child_sessions)&&s._child_sessions.some(child=>child&&child.session_id===sid)) return true;
  return false;
}

So when the active session is a child under parent P, or a compression snapshot under tip B, the bypass keeps the active row but drops the read parent/tip (it has no unread and its id ≠ the active id). Tracing what follows:

  • Child case: the active child C survives sessionsRaw, but in _attachChildSessionsToSidebarRows the parent lookup visibleBySid.get(parentSid) misses (P was dropped), so C falls into the orphans branch and re-renders as a top-level _orphan_child_session row. C stays visible but pops out of its parent grouping.
  • Compression case: with only the active segment left in sessionsRaw, _collapseSessionLineageForSidebar sees a one-item group and emits a plain row with no _lineage_segments/_lineage_collapsed_count. The "N prior turns" lineage affordance disappears and the visible tip can flip to the snapshot.

So "active session disappears" isn't right — the exact-id guard does its job. The regression is that the lineage anchor vanishes, re-parenting the child as an orphan or flattening the segment grouping mid-view. That's still jarring when the filter is toggled on while you're inside a lineage.

Why this isn't a one-line swap

You can't just substitute _sessionLineageContainsSession(s, activeSid) into line 4798. At partition time the rows are still flat_lineage_segments and _child_sessions aren't attached until _attachChildSessionsToSidebarRows, which runs after the partition (sessions.js:4844). At that point that helper only matches on s.session_id===sid, so it'd behave identically to the current guard.

Suggested direction

Protect rows on the active session's lineage path using the flat fields available pre-collapse. Resolve the active row once, then keep any row that shares its lineage:

const activeRow=allMatched.find(r=>r&&r.session_id===activeSidForSidebar);
const activeRootId=activeRow&&(activeRow._lineage_root_id||activeRow.lineage_root_id||activeRow.parent_session_id);
const keepsActiveLineage = s.session_id===activeSidForSidebar
  || s.session_id===activeRow?.parent_session_id
  || (activeRootId && (s._lineage_root_id===activeRootId || s.lineage_root_id===activeRootId || s.session_id===activeRootId));
if(_sessionUnreadOnlyFilter && !hasUnread && !keepsActiveLineage) continue;

That keeps the parent/tip/root that anchors the active conversation, so the lineage grouping and child nesting survive the filter toggle.

Test gap

tests/test_issue3910_unread_only_sidebar_filter.py is all source-substring assertions (the file pins the exact if(...) literal at line 48). None of the six tests exercises the active-as-child or active-as-snapshot lineage case, so this edge would pass silently either way. If you take the fix above, a small partition-level test that seeds a read parent + active child and asserts the parent stays in sessionsRaw would lock the behavior — the current string tests can't.

Everything else (the batched viewedCounts read, the flex-wrap chip CSS, the empty-state) looks clean.

@rodboev rodboev force-pushed the pr/3910-unread-only-sidebar-filter branch from 2a3c15d to 6433ef5 Compare June 13, 2026 11:34
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Pulled the branch at 966302cd and read the diff against origin/master. This commit resolves the lineage-anchor regression I raised in my last review — the active session no longer pops out of its grouping when the unread-only filter is toggled mid-conversation. The approach is cleaner than the sketch I'd suggested.

What changed

The bypass at the partition gate now keeps the active session's lineage anchor, not just its exact id:

// static/sessions.js:4823-4835
function _sessionMatchesActiveLineageForUnreadFilter(
  s, activeSidForSidebar, sessionIdsInList, sessionsById, activeLineageKey, activeParentSid,
){
  if(!s||!activeSidForSidebar) return false;
  if(s.session_id===activeSidForSidebar) return true;
  if(activeParentSid&&s.session_id===activeParentSid) return true;
  if(!activeLineageKey||_isChildSession(s)) return false;
  return _sessionLineageKey(s, sessionIdsInList, sessionsById)===activeLineageKey;
}

and the gate consumes it:

// static/sessions.js:4884
if(_sessionUnreadOnlyFilter&&!hasUnread&&!keepActiveLineage) continue;

The key resolution handles both branches I flagged. For the child case it keeps the parent via activeParentSid (and _parent_lineage_root_id for the compression-snapshot lineage). For the tip/root case it reuses _sessionLineageKey against activeLineageKey, computed once for the active row at sessions.js:4854-4857. Because _sessionLineageKey already encodes the pre-compression-snapshot walk and the fork/subagent exclusions (sessions.js:4224-4252), the parent/tip that anchors the active conversation survives the filter, so _attachChildSessionsToSidebarRows finds it in visibleBySid instead of dropping the child into the orphan branch.

Test coverage now matches the concern

test_unread_only_filter_keeps_active_child_attached_to_lineage_parent (tests/test_session_lineage_collapse.py) is exactly the behavioral case the old string-only tests couldn't catch — it seeds a read parent tip + active child child, runs the real partition + collapse + attach pipeline, and asserts:

assert result["sessionsRaw"] == ["tip", "child"]
assert [row["session_id"] for row in result["rows"]] == ["tip"]
assert result["rows"][0]["_child_sessions"][0]["session_id"] == "child"
assert result["rows"][0]["_child_sessions"][0].get("_orphan_child_session") is not True

That last assertion specifically locks the "doesn't re-parent as orphan" behavior. Good — this is the partition-level test I asked for, not another source-substring pin.

One small thing

_partitionSidebarSessionRows now resolves activeLineageKey/activeParentSid once per render (sessions.js:4849-4857) by building sessionsById/sessionIdsInList over allMatched. That's fine. The only nuance: _sessionMatchesActiveLineageForUnreadFilter is keyed off the active row's lineage as computed from the current allMatched, so if a sibling tip in the same lineage has no unread it's still kept (correct — it anchors the group), but a read sibling in a different lineage that merely shares parent_session_id===null won't match since _sessionLineageKey returns null and the !activeLineageKey short-circuits. I traced a couple of null-parent rows through and it behaves — just noting it as the edge I checked.

No blockers from me. The regression I flagged is closed and the new test pins it.

@rodboev rodboev force-pushed the pr/3910-unread-only-sidebar-filter branch from 966302c to 446c376 Compare June 15, 2026 04:42
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Two of the PR's own tests are red on the current head (446c3760)

Thanks for the Keep unread-only lineage rows attached to sidebar unread state follow-up — the product change is good (it adds the missing unreadById producer and the _sessionHasUnreadForSidebar read, which fixes a latent render crash, and the child-unread term is the right call). But the same stale-snapshot pattern from the earlier rounds recurred: the new commit changed sessions.js and left two of this PR's own tests asserting the old shape. Both fail on the head:

tests/test_issue3910_unread_only_sidebar_filter.py::test_unread_only_composes_with_source_filter_before_row_rendering  FAILED
tests/test_session_lineage_collapse.py::test_unread_only_filter_keeps_active_child_attached_to_lineage_parent          FAILED
2 failed, 39 passed

Both are stale-test defects, not product bugs — but they're a deterministic CI-red merge blocker. Two small mechanical fixes:

1. test_unread_only_composes_with_source_filter_before_row_rendering — stale hasUnread snapshot

The test asserts the old literal:

assert "const hasUnread=Boolean(unreadById.get(s.session_id))&&!isActive;" in js

but your new commit (correctly) widened the product line to include the child-unread term (sessions.js:5520):

const hasUnread=(Boolean(unreadById.get(s.session_id))||!!s._child_session_has_unread)&&!isActive;

Fix: update the assertion to match the current product string (keep the ||!!s._child_session_has_unread term).

2. test_unread_only_filter_keeps_active_child_attached_to_lineage_parent — harness missing a dependency

The Node harness evals the extracted functions, but _attachChildSessionsToSidebarRows calls _isForkWithResolvableParent (sessions.js:4384), which the test never extracts → ReferenceError: _isForkWithResolvableParent is not defined.

Fix: add the missing extraction alongside the others in that test's setup:

eval(extractFunc('_isForkWithResolvableParent'));

(It exists in sessions.js and is used by _attachChildSessionsToSidebarRows / _collapseSessionLineageForSidebar, so the harness just needs to load it.)

After both, python3 -m pytest tests/test_issue3910_unread_only_sidebar_filter.py tests/test_session_lineage_collapse.py -q should be green. Everything else on the branch looks clean (no XSS, the lineage-attach logic is gated on _sessionUnreadOnlyFilter, no non-filtered regression) and it merges cleanly on master. Just these two test edits before it can re-gate.

@nesquena-hermes nesquena-hermes added the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Jun 15, 2026
@rodboev

rodboev commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Applied the two stale-test fixes from the current head review on

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Confirmed both stale-test fixes landed on the current head 0870a3b7 (the rebase-snapshot refresh on top of 446c3760). Re-ran the two checks from my last comment against the actual source:

1. hasUnread snapshot now matches the widened product line. tests/test_issue3910_unread_only_sidebar_filter.py asserts the child-unread term, and it matches static/sessions.js:5520 exactly:

const hasUnread=(Boolean(unreadById.get(s.session_id))||!!s._child_session_has_unread)&&!isActive;

The earlier _renderSidebarRowsFromRawSessions snapshot at sessions.js:5085 (const hasUnread=_sessionHasUnreadForSidebar(s, viewedCounts);) is also asserted verbatim, so both producers are pinned.

2. The lineage harness now extracts the missing dep. tests/test_session_lineage_collapse.py adds eval(extractFunc('_isForkWithResolvableParent')); (10 call sites, including the one that exercises _attachChildSessionsToSidebarRows), so the ReferenceError: _isForkWithResolvableParent is not defined is gone.

I also string-matched every assert "<literal>" in js in both files against static/sessions.js - no remaining stale literals, which was the recurring failure mode on this PR across the last few rounds. And the CI matrix is now green 11/11 (browser-smoke, lint, and all 3.11/3.12/3.13 shards SUCCESS), so the deterministic red I called out is cleared.

Nothing else stands out: the unread-only predicate still composes after the source filter (the source_gate < unread_gate ordering assertion is in place), the lineage-attach is gated on _sessionUnreadOnlyFilter, and it merges cleanly on master. This looks ready from a correctness + test-integrity standpoint.

@rodboev rodboev force-pushed the pr/3910-unread-only-sidebar-filter branch from 0870a3b to 1a28c4e Compare June 17, 2026 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants