mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
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:
+31
-6
@@ -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));
|
||||
|
||||
@@ -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);}
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
Reference in New Issue
Block a user