From 6a75907802b68f467e50d3eab45f2f3817e73809 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sun, 3 May 2026 07:08:08 +0000 Subject: [PATCH] feat(sidebar): add "Unassigned" project-filter chip for sessions without a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spliced from contributor PRs #1497 (Thanatos-Z) and #1513 (AlexeyDsov), which both added the ability to filter the sidebar to sessions with no project_id assigned. Lands here as a focused PR with the best of both: ## Synthesis decisions - **Sentinel constant approach** (from #1497, Thanatos-Z): single state variable (`_activeProject` set to `NO_PROJECT_FILTER` sentinel) instead of a parallel `_showNoneProject` boolean. No two-state-machine ambiguity, no risk of "All" + "Unassigned" both reading active. Clicking "All" automatically clears the unassigned filter because there is only one variable to reset. - **Conditional rendering** (from #1497): the chip only appears when there are actually unassigned sessions to filter to (`hasUnprojected`). Common case where every session is organized → chip stays hidden, uncluttered chip bar. The project-bar itself also renders when there are unassigned sessions (was previously gated on `_allProjects.length`). - **Dashed-border visual treatment** (from #1497): `.project-chip.no-project {border-style:dashed;}` distinguishes the chip from real project chips so it reads as a meta-filter ("things without a project") rather than another project. Subtle but present. - **"Unassigned" label** (new): clearer than #1497s "No project" (which reads like a status filter) or #1513s "None" (which is ambiguous — none of what?). Matches the conventional file-manager / task-tracker mental model: "things not yet assigned to a category." Tooltip elaborates: "Show conversations not yet assigned to a project." - **Branched empty-state copy**: when the Unassigned filter is active and the result is empty, show "No unassigned sessions." instead of the generic "No sessions in this project yet." ## Tests 7 new tests in tests/test_sidebar_unassigned_filter.py pin every contract: sentinel constant declared; filter logic uses !s.project_id when sentinel is active; chip only renders when hasUnprojected; chip label and click handler; visual treatment (dashed border + .no-project class); empty-state copy branches on the active filter; All chip handler clears _activeProject to null (would catch a regression if a parallel _showNoneProject boolean is ever reintroduced). Local full suite: 3929 → 3936 passing (+7). Live verified at port 8789 with seeded data (5 projects + 73 unassigned sessions in active profile): chip appears between "All" and project chips when unassigned sessions exist; click cycles correctly; clicking a real project hides the Unassigned chip from active state; clicking "All" deactivates everything; dashed border present per getComputedStyle. Co-authored-by: Thanatos-Z Co-authored-by: Alexey Denisov --- static/sessions.js | 37 +++++- static/style.css | 5 + tests/test_sidebar_unassigned_filter.py | 146 ++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 tests/test_sidebar_unassigned_filter.py diff --git a/static/sessions.js b/static/sessions.js index 551e16a8..318d88ca 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -672,7 +672,14 @@ let _showArchived = false; // toggle to show archived sessions let _sessionSelectMode = false; // batch select mode const _selectedSessions = new Set(); // selected session IDs let _allProjects = []; // cached project list -let _activeProject = null; // project_id filter (null = show all) +// Sentinel value for the _activeProject state when filtering to sessions +// that have no project_id assigned. Distinct from real project IDs so the +// equality check below can branch cleanly on it. The literal string is +// not user-visible (the chip renders the localized label) — it just has +// to be something a user-created project_id can never collide with, which +// double-underscore prefixes provide. +const NO_PROJECT_FILTER = '__none__'; +let _activeProject = null; // project_id filter (null = show all, NO_PROJECT_FILTER = unassigned only) let _showAllProfiles = false; // false = filter to active profile only let _sessionActionMenu = null; let _sessionActionAnchor = null; @@ -1429,8 +1436,13 @@ function renderSessionListFromCache(){ // Server backfills profile='default' for legacy sessions, so every session has a profile. // Show only sessions tagged to the active profile; 'All profiles' toggle overrides. const profileFiltered=_showAllProfiles?withMessages:withMessages.filter(s=>s.is_cli_session||s.profile===S.activeProfile); - // Filter by active project - const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered; + // Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions + // with no project_id; otherwise filter to the matching project_id, or + // pass through when no filter is active. + const projectFiltered= + _activeProject===NO_PROJECT_FILTER + ?profileFiltered.filter(s=>!s.project_id) + :(_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered); // Filter archived unless toggle is on const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived); const sessions=_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw); @@ -1456,8 +1468,10 @@ function renderSessionListFromCache(){ list.appendChild(batchBar); if(_sessionSelectMode&&_selectedSessions.size>0){batchBar.style.display='flex';_renderBatchActionBar();} else{batchBar.style.display='none';} - // Project filter bar (only when projects exist) - if(_allProjects.length>0){ + // Project filter bar — show when there are real projects OR there are + // unassigned sessions (so the Unassigned chip has something to filter to). + const hasUnprojected=profileFiltered.some(s=>!s.project_id); + if(_allProjects.length>0||hasUnprojected){ const bar=document.createElement('div'); bar.className='project-bar'; // "All" chip @@ -1466,6 +1480,17 @@ function renderSessionListFromCache(){ allChip.textContent='All'; allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();}; bar.appendChild(allChip); + // "Unassigned" chip — only when there are sessions with no project to + // filter to. Hidden in the common case where every session is already + // organized, to keep the chip bar uncluttered. + if(hasUnprojected){ + const noneChip=document.createElement('span'); + noneChip.className='project-chip no-project'+(_activeProject===NO_PROJECT_FILTER?' active':''); + noneChip.textContent='Unassigned'; + noneChip.title='Show conversations not yet assigned to a project'; + noneChip.onclick=()=>{_activeProject=NO_PROJECT_FILTER;renderSessionListFromCache();}; + bar.appendChild(noneChip); + } // Project chips for(const p of _allProjects){ const chip=document.createElement('span'); @@ -1524,7 +1549,7 @@ function renderSessionListFromCache(){ if(_activeProject&&sessions.length===0){ const empty=document.createElement('div'); empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;'; - empty.textContent='No sessions in this project yet.'; + empty.textContent=_activeProject===NO_PROJECT_FILTER?'No unassigned sessions.':'No sessions in this project yet.'; list.appendChild(empty); } const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); diff --git a/static/style.css b/static/style.css index fa89fdb7..bae5cfb7 100644 --- a/static/style.css +++ b/static/style.css @@ -2411,6 +2411,11 @@ main.main.showing-profiles > #mainProfiles{display:flex;} .project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;} .project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);} .project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);} +/* "Unassigned" filter chip — dashed border distinguishes it from real + project chips so it reads as a meta-filter ("things without a project") + rather than another project. Keeps full color treatment in the active + state so it's still obviously selected. */ +.project-chip.no-project{border-style:dashed;} .project-chip .color-dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0;} .project-create-btn{font-size:10px;padding:3px 6px;border-radius:12px;cursor:pointer;border:1px dashed var(--border2);background:none;color:var(--muted);opacity:.6;transition:all .15s;} .project-create-btn:hover{opacity:1;border-color:var(--blue);color:var(--blue);} diff --git a/tests/test_sidebar_unassigned_filter.py b/tests/test_sidebar_unassigned_filter.py new file mode 100644 index 00000000..dd543863 --- /dev/null +++ b/tests/test_sidebar_unassigned_filter.py @@ -0,0 +1,146 @@ +"""Regression tests for the sidebar "Unassigned" project-filter chip. + +Spliced from contributor PRs #1497 (Thanatos-Z) and #1513 (AlexeyDsov), which +both added the ability to filter the sidebar to sessions with no project_id +assigned. Lands here as a focused PR with the best of both: + +- #1497's `NO_PROJECT_FILTER` sentinel (single state variable, no parallel + boolean to keep in sync) and conditional rendering (only show the chip + when there ARE unassigned sessions). +- #1497's dashed-border visual treatment to distinguish from real project + chips. +- AlexeyDsov #1513's user need framing — "easy way to view sessions + not yet organized into projects." + +UI choice: label is "Unassigned" rather than #1497's "No project" or +#1513's "None" — clearer than both ("None" is ambiguous, "No project" +sounds like a status). Matches the conventional file-manager / task-tracker +mental model: "things not yet assigned to a category." + +These tests pin the feature contract so a future refactor can't silently +break the chip. +""" + +from __future__ import annotations + +import pathlib + +JS = pathlib.Path(__file__).parent.parent / "static" / "sessions.js" +CSS = pathlib.Path(__file__).parent.parent / "static" / "style.css" + + +def _js() -> str: + return JS.read_text(encoding="utf-8") + + +def _css() -> str: + return CSS.read_text(encoding="utf-8") + + +def test_no_project_filter_sentinel_declared(): + """A stable sentinel constant identifies the "no project" filter state. + + Using a sentinel on the existing `_activeProject` variable (rather than + a parallel `_showNoneProject` boolean) keeps the filter state to one + place — no two-state-machine ambiguity, no risk of "All" + "Unassigned" + both appearing active. + """ + js = _js() + assert "const NO_PROJECT_FILTER = '__none__';" in js, ( + "static/sessions.js must declare a NO_PROJECT_FILTER sentinel for " + "the unassigned-sessions filter state" + ) + + +def test_unassigned_chip_filter_logic(): + """The render function must filter to !s.project_id when the sentinel is active.""" + js = _js() + assert "_activeProject===NO_PROJECT_FILTER" in js, ( + "renderSessionListFromCache must branch on the NO_PROJECT_FILTER sentinel" + ) + assert "profileFiltered.filter(s=>!s.project_id)" in js, ( + "The Unassigned filter must select sessions without a project_id" + ) + + +def test_unassigned_chip_only_shown_when_relevant(): + """The Unassigned chip should only render when there are unassigned sessions. + + In the common case where every session is already organized, hiding the + chip keeps the project-bar uncluttered. The conditional also keeps the + project-bar from rendering at all when there are NO projects AND NO + unassigned sessions (e.g. brand-new install with one organized session + — though that's vanishingly rare). + """ + js = _js() + assert "const hasUnprojected=profileFiltered.some(s=>!s.project_id);" in js, ( + "The render function must compute whether unassigned sessions exist" + ) + assert "if(_allProjects.length>0||hasUnprojected){" in js, ( + "The project-bar must render when EITHER there are real projects OR " + "there are unassigned sessions to filter to" + ) + assert "if(hasUnprojected){" in js, ( + "The Unassigned chip must be conditionally rendered on hasUnprojected" + ) + + +def test_unassigned_chip_label_and_handler(): + """The chip label should be 'Unassigned' and clicking it should set the sentinel.""" + js = _js() + assert "noneChip.textContent='Unassigned';" in js, ( + "The Unassigned chip must display the label 'Unassigned'" + ) + assert "_activeProject=NO_PROJECT_FILTER" in js, ( + "Clicking the Unassigned chip must set _activeProject to the sentinel" + ) + # Active-state contract — the chip must reflect when it's the active filter. + assert "_activeProject===NO_PROJECT_FILTER?' active':''" in js, ( + "The Unassigned chip must apply the .active class when the filter is the " + "current state" + ) + + +def test_unassigned_chip_visual_treatment(): + """A dashed border distinguishes the Unassigned chip from real project chips.""" + css = _css() + assert ".project-chip.no-project{border-style:dashed;}" in css, ( + "The Unassigned chip must have a dashed border to read as a meta-filter " + "rather than a real project" + ) + js = _js() + assert "noneChip.className='project-chip no-project" in js, ( + "The Unassigned chip must have the .no-project class for the dashed-border styling" + ) + + +def test_empty_state_message_for_unassigned_filter(): + """When the Unassigned filter is active and no sessions match, the empty-state + message should be specific to that filter rather than generic project text.""" + js = _js() + assert "'No unassigned sessions.'" in js, ( + "Empty-state copy must be specific when the Unassigned filter is active" + ) + assert "_activeProject===NO_PROJECT_FILTER?'No unassigned sessions.':'No sessions in this project yet.'" in js, ( + "Empty-state copy must branch on the active filter" + ) + + +def test_all_chip_clear_clears_unassigned_filter_too(): + """Clicking 'All' must reset the filter unconditionally — including when + the Unassigned filter is currently active. + + Using a sentinel value on `_activeProject` (rather than a parallel + `_showNoneProject` boolean) makes this automatic: there's only one + variable to clear, and 'All' already sets `_activeProject = null`. + A regression where 'All' didn't reset the unassigned state would + only happen if someone migrated to a parallel boolean. + """ + js = _js() + # Find the "All" chip handler. It must clear _activeProject to null and + # NOT preserve any unassigned-flag state. + assert "allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};" in js, ( + "The All chip handler must reset _activeProject to null. If a parallel " + "_showNoneProject boolean is reintroduced, this test will catch it because " + "the handler will need additional state to reset." + )