feat(desktop): sort and filter projects in the dashboard sidebar

Merged
AV
2mo ago
+4557-54

Summary

  • Adds a sort mode for the Projects list — Manual order (existing drag order, default), Last active, Date created — persisted per user in v2UserPreferences and 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 by notifications.hook on every normalized agent lifecycle event (30 s throttle per workspace) and served on workspace.list and every workspace:changed snapshot. 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 nullable last_activity_at column (drizzle migration 0030_workspace_last_activity_at) with an app-level default so every insert stamps creation as the first activity. touchLocalWorkspaceActivity writes 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 as workspace:changed / updated. Only lastActivityAt moves: updatedAt stays a metadata signal, no analytics fire, archived rows are ignored. notifications.hook calls it in a try/catch after terminalAgentStore.recordEvent, so a failed write can never break the chime or the status dots.
  • Renderer data path: lastActivityAt rides WorkspaceSnapshot → WorkspaceSnapshotPayload → applyWorkspaceChangedEvent, and workspace.list → mergeHostWorkspaces. Both entry points normalize an absent field (older host, or an IndexedDB snapshot cached before the column) to null in toHostWorkspaceItem, so the sidebar sees one shape. The builders thread it onto DashboardSidebarWorkspace as a single field.
  • Ranking (utils/sortDashboardSidebarProjects): a workspace's activity time is lastActivityAt ?? updatedAt — deliberately not max, 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 local main stays 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: orderedGroups and the persisted tabOrder are 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 DndContext runs 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 ephemeral useState, cleared when the sidebar collapses to the icon rail; expanding the filter un-collapses the Projects section.
  • Header: the shared DashboardSidebarSectionHeader gains labelHidden so the expanded input can take the row (the reference branch had forked it). New DashboardSidebarProjectsFilterInput and DashboardSidebarProjectsSortMenu live under the Projects header's components/. All strings go through Lingui under dashboard.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:changed reached 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 updatedAt but leave the rank and lastActivityAt untouched.
  • Dragging is inert in Last active and while filtering: project header drag, workspace-row drag, folder drag — DOM order and persisted tabOrder unchanged. 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's CloudWorkspaceItem extends the served row type, so useCloudWorkspaceItems threads lastActivityAt from the sandbox's served row (null until it answers), the same way the desktop Cloud section does.
  • bun run lint over every changed file: clean.
  • bun test in packages/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, updatedAt untouched, archived/unknown rows ignored, broadcast only on write) and two notifications.test.ts cases over a real migrated in-memory DB (hook stamps and broadcasts; a throwing write does not fail the hook).
  • bun test in apps/desktop: 3387 pass, 1 fail (LeaderboardRank, passes in isolation; unrelated). New/extended: sort util (28 cases incl. lastActivityAt vs updatedAt, old-host null fallback, NaN/string/null timestamps, identity reuse), filter util, useHostWorkspaces.utils normalization, schema heal of a retired "updated" value, builder fixtures, and useProjectFilterExpanded (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-registered v2-user-preferences-* row).
  • bun run --cwd packages/i18n check: extract is clean; compile --strict fails only on the 11 new strings' missing translations. Neither .env on this machine carries a real ANTHROPIC_API_KEY (both hold a placeholder), so translate could 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 useDashboardSidebarData on tabOrder preserves its referential-stability machinery and the optimistic drag path.
  • lastActivityAt ?? updatedAt, never max: once the host has stamped a row, housekeeping must not out-rank agent work. Rows that predate the column keep ranking by updatedAt until their first agent event (per spec they stay NULL at 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 Detached is a one-line change in the hook.
  • Gated sensors instead of an empty sensor list: dnd-kit's useSensors spreads 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 while disabled keeps the array constant with the same effect and no per-sortable flags.
  • Unknown timestamps sink, they do not interleave: NaN maps to -Infinity in 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.
  • lastActivityAt is served on workspace.list explicitly, not added to toCloudShape, 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: DashboardSidebarBulkActions renders its toolbar instead of the header while rows are selected, so the header unmounts with the query still filtering the list. useProjectFilterExpanded initializes 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 lastActivityAt from the sandbox host's served row (needed for the type; the Cloud section itself is not sorted).
  • getWorkspaceActivityTime is 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_at for 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_SECRET and the R2 group (CLOUDFLARE_ACCOUNT_ID, R2_*, STATIC_URL) mandatory in packages/trpc/src/env.ts without provisioning them in .env.example defaults, 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) and LeaderboardRank (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 v2UserPreferences and 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 lastActivityAt column 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 to updatedAt until 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 lastActivityAt survives an older host's updated event 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.