feat(desktop): sort and filter projects in the dashboard sidebar
Summary
- Adds a sort mode for the Projects list — Manual order (existing drag order, default), Last active, Date created — persisted per user in
v2UserPreferencesand switchable from a radio menu in the PROJECTS header. - Adds an inline filter: a magnifier in the PROJECTS header expands into a text input; case-insensitive substring match over project, workspace, and folder names. Matches auto-expand, non-matching siblings are pruned, and a "No projects match" row covers the empty case.
- "Last active" runs on a real activity signal. Each host-service now persists
workspaces.last_activity_at, stamped bynotifications.hookon every normalized agent lifecycle event (30 s throttle per workspace) and served onworkspace.listand everyworkspace:changedsnapshot. Sending a prompt to an agent reorders the sidebar in well under a second, with no polling.
Third landing of this feature: #5956 sorted on updatedAt (metadata only) and crashed on string timestamps; #5996 reverted it; #6005 re-landed it by polling live terminal-agent bindings every 10 s, which drop a workspace the moment its session ends. This supersedes #6005.
Why / Context
The project list is ordered solely by manual drag (tabOrder), with no way to find a project or workspace by name or to surface recently active work. Since Electric sync was removed the host-service owns workspace rows and streams them to every renderer, so recording activity next to the row makes the renderer's data path one extra field instead of a per-host poll.
How it works
- Host (
packages/host-service): new nullablelast_activity_atcolumn (drizzle migration0030_workspace_last_activity_at) with an app-level default so every insert stamps creation as the first activity.touchLocalWorkspaceActivitywrites at most once per 30 s per workspace — the first event after a quiet period writes immediately, chatty tool-use events inside the window are dropped — and broadcasts the row asworkspace:changed/updated. OnlylastActivityAtmoves:updatedAtstays a metadata signal, no analytics fire, archived rows are ignored.notifications.hookcalls it in a try/catch afterterminalAgentStore.recordEvent, so a failed write can never break the chime or the status dots. - Renderer data path:
lastActivityAtridesWorkspaceSnapshot→WorkspaceSnapshotPayload→applyWorkspaceChangedEvent, andworkspace.list→mergeHostWorkspaces. Both entry points normalize an absent field (older host, or an IndexedDB snapshot cached before the column) tonullintoHostWorkspaceItem, so the sidebar sees one shape. The builders thread it ontoDashboardSidebarWorkspaceas a single field. - Ranking (
utils/sortDashboardSidebarProjects): a workspace's activity time islastActivityAt ?? updatedAt— deliberately notmax, so renames and folder moves never jump a stamped row. Projects rank by their most recently active workspace (including inside folders), folders rank among loose workspaces by their newest member, the localmainstays pinned first, ties break by name then id. Timestamps are coerced NaN-safely; an unparseable value sinks to the bottom instead of throwing. Manual returns the input by reference and already-ordered projects keep their identity, so memoized rows are undisturbed. - Sort/filter as derived views in
DashboardSidebar:orderedGroupsand the persistedtabOrderare never rewritten, which is what lets Manual restore the exact prior drag order. Sort applies first, then filter. ⌘1–⌘9 labels come from the sorted-but-unfiltered list; bulk selection's selectable set comes from the displayed list; the workspace-status subscriptions stay unfiltered. - Drag gating: while the mode is not Manual or a filter is active, the shared
DndContextruns on gated sensors whose activators decline to start a drag (useSidebarDnd({ disabled })). Projects, workspaces, folders, pinned rows, and sessions all go inert at once with no per-sortable flags, and the sensor list never changes shape (see design decisions). - Filter (
utils/filterDashboardSidebarProjects): a name-matched project is kept whole; otherwise it is pruned to matching workspaces/folders (a matching folder keeps its members). Survivors render expanded through derived objects; persisted collapse state is never written. Filter state is ephemeraluseState, cleared when the sidebar collapses to the icon rail; expanding the filter un-collapses the Projects section. - Header: the shared
DashboardSidebarSectionHeadergainslabelHiddenso the expanded input can take the row (the reference branch had forked it). NewDashboardSidebarProjectsFilterInputandDashboardSidebarProjectsSortMenulive under the Projects header'scomponents/. All strings go through Lingui underdashboard.sidebar.workspacesHeader.*. - Docs:
apps/desktop/docs/SIDEBAR_SORT_FILTER.md.
Manual QA checklist
Verified live over CDP against this workspace's dev app (renderer localhost:9585, signed in to the real org), real mouse/keyboard input, screenshots in the worker's VERIFICATION.md.
Sort
- Manual → Last active → Date created reorder projects and their children; both non-manual orders matched the order derived independently from the host DB.
- Sort mode survives an app restart (full Electron restart: came back as Last active, correct order, icon highlighted).
- Switching back to Manual restores the exact prior drag order; dragging works again (project drag and workspace-row drag both reordered and persisted).
- Sending a prompt to Claude in an idle workspace moved it to the top: DB stamped 57 ms after Enter,
workspace:changedreached the renderer at 227 ms, the list was reordered within ~0.5 s (poll granularity 200 ms). No polling. - Closing an agent session never drops its rank (SessionEnd is an activity event, so it bumps the row's own stamp).
- Restarting the host-service keeps the stamps and the order.
- Renaming a stamped workspace (context menu → Rename) and moving it into a folder (tag write, the same mutation the UI issues) bump
updatedAtbut leave the rank andlastActivityAtuntouched. - Dragging is inert in Last active and while filtering: project header drag, workspace-row drag, folder drag — DOM order and persisted
tabOrderunchanged. An oscillating drag with dwells in Manual produced no console errors (no "Maximum update depth exceeded").
Filter
- A workspace-name match auto-expands its project and prunes non-matching siblings; a project-name match keeps all children; a folder-name match keeps the folder whole.
- Garbage query shows
No projects match "…"; Escape clears, collapses the input, and restores the previous collapse state (a collapsed project stayed collapsed). - Expanding the filter while the PROJECTS section is collapsed expands the section and focuses the input.
- Collapsing the sidebar to the icon rail with an active filter clears it; re-expanding shows every project.
- ⌘1–⌘9 stay on the same rows while a query is typed.
- The × button clears without losing focus; the header label hides while the input is open.
- Filter survives the bulk-selection toolbar: with a query active, ⌘-click a row (the toolbar replaces the header, the list stays pruned), clear the selection — the header comes back with the input open and the query intact, focus is not moved into the input, × then clears and keeps it open, Escape collapses.
Testing
- Root
bun run typecheck(turbo, all 39 tasks): green. Mobile'sCloudWorkspaceItemextends the served row type, souseCloudWorkspaceItemsthreadslastActivityAtfrom the sandbox's served row (null until it answers), the same way the desktop Cloud section does. bun run lintover every changed file: clean.bun testinpackages/host-service: 1497 pass, 2 fail (agents.test.ts › buildTerminalAgentLaunch, pass in isolation; untouched by this change). New:workspace-activity.test.ts(insert stamp, throttle window semantics,updatedAtuntouched, archived/unknown rows ignored, broadcast only on write) and twonotifications.test.tscases over a real migrated in-memory DB (hook stamps and broadcasts; a throwing write does not fail the hook).bun testinapps/desktop: 3387 pass, 1 fail (LeaderboardRank, passes in isolation; unrelated). New/extended: sort util (28 cases incl.lastActivityAtvsupdatedAt, old-host null fallback, NaN/string/null timestamps, identity reuse), filter util,useHostWorkspaces.utilsnormalization, schema heal of a retired"updated"value, builder fixtures, anduseProjectFilterExpanded(renderHook: opens on request, stays open while × clears, collapses on Escape, mounts open when a query is already active, forced open by a query). The persisted-key registry test passes (the preference lives in the already-registeredv2-user-preferences-*row).bun run --cwd packages/i18n check: extract is clean;compile --strictfails only on the 11 new strings' missing translations. Neither.envon this machine carries a realANTHROPIC_API_KEY(both hold a placeholder), sotranslatecould not run — the Translate Catalogs workflow fills them on the PR.
Design decisions
- Sort in the component, not the data hook: sorting is a view preference with one consumer; keeping
useDashboardSidebarDataontabOrderpreserves its referential-stability machinery and the optimistic drag path. lastActivityAt ?? updatedAt, nevermax: once the host has stamped a row, housekeeping must not out-rank agent work. Rows that predate the column keep ranking byupdatedAtuntil their first agent event (per spec they stayNULLat migration), so a rename before that first event still moves such a row — see follow-ups for the backfill that would remove even that.- Every lifecycle event counts, including SessionEnd: the spec lists attach/detach; closing a session therefore bumps the closed workspace rather than dropping it (the #6005 failure mode). If that ever reads as surprising, excluding
Detachedis a one-line change in the hook. - Gated sensors instead of an empty sensor list: dnd-kit's
useSensorsspreads the list into a memo dependency array, so swapping in[]fires React's dev warning "The final argument passed to useEffect changed size between renders" on every mode switch (caught live). Wrapping each sensor's activators to decline whiledisabledkeeps the array constant with the same effect and no per-sortable flags. - Unknown timestamps sink, they do not interleave: NaN maps to
-Infinityin the comparator so the order is a consistent total order; the reference branch fell through to the name comparison, which is not transitive against dated items. .catch("manual")on the schema field and again at heal time: the localStorage collection only runs the schema on writes, so a"updated"value persisted by #5956 must be healed on read too, without dropping the row.lastActivityAtis served onworkspace.listexplicitly, not added totoCloudShape, which is documented as the frozen cloud column set.- Renderer row type keeps the field optional, the merged item makes it required: honest about what an old host or a stale IndexedDB snapshot can carry, with one normalization point (
toHostWorkspaceItem) so no consumer has to know the host version. - Extended the shared section header (
labelHidden) instead of forking it, keeping the GH #6009 chevron behaviour. - The filter's open/closed flag is derived, not just stored:
DashboardSidebarBulkActionsrenders its toolbar instead of the header while rows are selected, so the header unmounts with the query still filtering the list.useProjectFilterExpandedinitializes the flag from the query on mount and forces the input open while the query is non-empty (Escape clears the query, so it still collapses). The input autofocuses only on a user-initiated open, so clearing a selection never moves focus into the filter. - Sort menu uses radio items,
HiOutlineBarsArrowDown(a sort glyph, since the magnifier already means "filter"), a muted "Drag to reorder in Manual order" hint while non-manual, and the trigger reads as active whenever the mode is not Manual. - Cloud-sandbox rows in the Cloud section take
lastActivityAtfrom the sandbox host's served row (needed for the type; the Cloud section itself is not sorted). getWorkspaceActivityTimeis exported alongside the sort helpers so the rule lives in one place for any future consumer.
Follow-ups
- Sorting/filtering for the Sessions and Pinned sections (the controls are scoped to Projects by design).
- Optional backfill migration
last_activity_at = updated_atfor pre-existing rows, so renames before a row's first agent event stop moving it too. - Translations for the 11 new strings (workflow-filled).
- Dev environment: #6954 made
USERCONTENT_TOKEN_SECRETand the R2 group (CLOUDFLARE_ACCOUNT_ID,R2_*,STATIC_URL) mandatory inpackages/trpc/src/env.tswithout provisioning them in.env.exampledefaults,docs/environment-variables.md, or the workspace setup; every cloud tRPC route in a fresh workspace's dev API 500s until they are added by hand (this worker added fake local values to its own gitignored.env). - Two order-dependent test flakes noticed in full runs, both pass in isolation and are untouched here:
agents.test.ts › buildTerminalAgentLaunch(host-service) andLeaderboardRank(desktop). - Close #6005 in favour of this PR.
Worker session: https://claude.ai/code/session_018Qkro1SUexTCVJpw4CE58i
https://claude.ai/code/session_014L81qoPdokfvg4eszf9xgu
Summary by cubic
Adds sort and filter controls to the dashboard sidebar Projects list — previously ordered only by manual drag, it can now be ordered by last activity or creation date and filtered by name. "Last active" runs on a new host-persisted activity timestamp instead of the 10-second polling from #6005, which this supersedes; drag is disabled whenever a non-manual sort or a filter is active.
New Features
- Sort menu in the Projects header offers Manual order (default), Last active, and Date created; the choice persists per user in
v2UserPreferencesand survives restarts. - The inline filter matches project, workspace, and folder names case-insensitively, auto-expanding matches and pruning non-matching siblings, with a "No projects match" row for empty results.
- Returning to Manual restores the exact prior drag order; drag is inert in every other sort mode and while filtering.
- "Last active" ranks by a new
lastActivityAtcolumn stamped by the host on every agent lifecycle event, throttled to one write per 30 s per workspace. - All new UI strings are translated across every enabled locale.
- The filter closes when the Projects section collapses, the header keeps its "Projects" accessible name while the input takes the row, and ⌘1–⌘9 skip the persisted collapse toggle while a filter is active.
Migration
- Host service adds nullable
last_activity_at; new workspaces stamp creation as first activity, pre-existing rows stay null and fall back toupdatedAtuntil their first agent event. - Apply drizzle migration
0030_workspace_last_activity_at. - Timestamps are coerced NaN-safely so cached string dates can't crash the sidebar (the failure mode of the first landing).
- A row's cached
lastActivityAtsurvives an older host'supdatedevent that omits the field.
Summary by CodeRabbit
-
New Features
- Added project filtering in the dashboard sidebar, including matching workspaces and folders with an empty-results state.
- Added project sorting by manual order, last activity, or creation date, with preferences saved automatically.
- Activity-based ordering now reflects recent workspace activity.
- Drag-and-drop reordering is unavailable while filtering or using automatic sorting.
- Added localized labels and accessibility text for the new controls.
-
Documentation
- Added documentation covering sidebar project filtering and sorting behavior.