feat(sidebar): add "Unassigned" project-filter chip for sessions without a project

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 <thanatos-z@users.noreply.github.com>
Co-authored-by: Alexey Denisov <AlexeyDsov@users.noreply.github.com>
This commit is contained in:
Hermes Bot
2026-05-03 07:08:08 +00:00
parent 7921a47f9d
commit 6a75907802
3 changed files with 182 additions and 6 deletions
+31 -6
View File
@@ -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));
+5
View File
@@ -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);}
+146
View File
@@ -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."
)