Skip to content

feat(dashboard): opt-in sort control for the Projects zone - #528

Merged
schubydoo merged 1 commit into
mainfrom
feat/298-projects-sort
Jun 23, 2026
Merged

feat(dashboard): opt-in sort control for the Projects zone#528
schubydoo merged 1 commit into
mainfrom
feat/298-projects-sort

Conversation

@schubydoo

Copy link
Copy Markdown
Owner

What

Adds an opt-in sort control to the dashboard's Projects zone — a Name / Last used / Cost dropdown. It defaults to the existing A–Z order and never reorders on its own; only an explicit non-name choice reorders the list (most-recent / highest-cost first; projects with no recorded history sink to the bottom) and reveals every project. The default-name path is byte-identical to today.

Closes #298.

How

  • Backend: new read-only GET /api/projects/sortmeta — a batch endpoint returning {name: {last_used, cost_usd}} for every discovered project, sourced from the session-history rollup (db/stores.py rollup_for). Runs off the event loop via asyncio.to_thread; catches only infra errors (DB engine / IO) so a genuine bug surfaces as a 500 rather than a silently-empty sort (the client already falls back to name order on any non-OK response).
  • Frontend (Alpine): client-side reorder via CSS orderno DOM moves, so x-show and the Jinja idx cap stay intact. New state projectSort / sortMeta / projectRanks; recomputeProjectRanks() builds the order map (whole-object assignment, reactive-safe per test(e2e): TS-1 e2e maturity slice + fix live-log frame drop #310/fix(ui): stop the hosted live-view dropping frames + repaint through the proxy #315); projectVisible() shows the full list when a sort is active (the 6-row cap governs only the default name order).

Note on the stale "blocked" label

The issue body and its 2026-06-21 triage note say blocked by #308 / #363. That prose is stale: #363 merged via #514 (2026-06-23), so the last_used / total_cost_usd rollup this builds on exists. #308 is the open umbrella Epic, not a true blocker for this slice. Verified against git/gh, not the doc.

a11y trade-off

CSS order reorders visually only — DOM / tab / screen-reader order stays A–Z even when the list is visually sorted. This is deliberate for an opt-in cosmetic sort (a DOM reorder is the anti-pattern the Active zone avoids) and is called out in a code comment by the :style binding.

Tests

  • New /api/projects/sortmeta route tests (shape + all projects, null fields, degrade-to-empty on an infra error, literal-path-beats-name-route) plus a dashboard-render test asserting the select, its options, and the projectSort default.
  • Full suite green locally — 98.28% coverage (≥96% gate). Reviewed against clauster's frontend and safety invariants (auth-gating, validate-before-use, fail-closed-but-surface-bugs, Alpine scope/reactivity, Tabler .list-group-flush separators under CSS order, XSS via the project-name regex).

@schubydoo
schubydoo marked this pull request as ready for review June 23, 2026 07:49
@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds an opt-in sort control (Name / Last used / Cost) to the Projects zone. The backend exposes a new GET /api/projects/sortmeta endpoint returning per-project rollup keys; the frontend reorders rows via CSS order (no DOM moves) using Alpine reactive state.

  • Backend: new read-only /api/projects/sortmeta route placed before the {name}/\u2026 wildcard, validated through is_valid_project_name, offloaded with asyncio.to_thread, and degrades to {} on OSError/SQLAlchemyError. create_app always assigns a real SessionRunner before any route captures it, so the inner _collect closure is safe.
  • Frontend: projectSort / sortMeta / projectRanks state added; whole-object assignment on projectRanks is Alpine-reactive-safe per test(e2e): TS-1 e2e maturity slice + fix live-log frame drop #310/fix(ui): stop the hosted live-view dropping frames + repaint through the proxy #315; loadSortMeta() is lazy \u2014 fetched only on the first (and each subsequent) non-name selection, ensuring ranks reflect the latest history.
  • Tests: four new route tests cover shape, literal-path precedence, infra-error degradation, and dashboard-render assertions.

Confidence Score: 5/5

Safe to merge. The backend endpoint is read-only, correctly auth-gated, and degrades gracefully. The frontend sort is purely cosmetic with no mutation paths.

All changed paths are additive and read-only. The backend route is correctly ordered before the wildcard, offloaded safely to a thread, and fails closed to an empty dict. The Alpine state changes are isolated to the Projects zone with no cross-zone side effects. The one identified issue (newly created projects sinking under a non-name sort) is a cosmetic edge case that does not affect data integrity or crash the page.

No files require special attention. The _dashboard_script.html note about newly-created projects getting order:9999 is worth tracking as a follow-up.

Important Files Changed

Filename Overview
src/clauster/app.py Adds GET /api/projects/sortmeta — well-placed before the {name}/… wildcard route, guarded by is_valid_project_name, offloaded via asyncio.to_thread, and degrades to {} on OSError/SQLAlchemyError. runner is always non-None inside create_app (replaced at line 283).
src/clauster/templates/_dashboard_script.html Adds projectSort/sortMeta/projectRanks state plus loadSortMeta/recomputeProjectRanks/projectOrderRank. Whole-object assignment on projectRanks is Alpine-reactive-safe. Newly created projects won't appear in PROJECT_NAMES and therefore get order:9999 when a non-name sort is active.
src/clauster/templates/_project_row.html Adds :style CSS order binding for non-name sorts. p.name is HTML-escaped by Jinja autoescape, used only as a dict key inside projectOrderRank — no XSS surface. a11y trade-off (visual-only reorder) is explicitly documented in comment.
src/clauster/templates/dashboard.html Adds sort select with correct x-model/@change wiring; hides 'Show all' button when non-name sort is active; wraps list-group-flush in display:flex when sorting. CSS order reorder of list-group-flush separators is called out in PR description as a reviewed trade-off.
tests/test_app_routes.py Four new tests: shape/all-projects, literal-path-beats-name-route, infra-error degrades to {}, and dashboard render asserts. All use plain TestClient (no lifespan wrap), which is correct for synchronous GET routes per the custom rule.
.changeset/298-projects-sort.md Changeset marking this as a minor release. Accurate description of the feature.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant Alpine as Alpine (dashboard)
    participant API as GET /api/projects/sortmeta
    participant DB as SessionHistoryStore

    User->>Alpine: select Last used or Cost
    Note over Alpine: x-model sets projectSort, @change fires
    Alpine->>API: fetch /api/projects/sortmeta
    API->>DB: asyncio.to_thread rollup_for x N projects
    DB-->>API: name to last_used and cost_usd per project
    API-->>Alpine: JSON dict or empty on error
    Alpine->>Alpine: "sortMeta = response"
    Alpine->>Alpine: recomputeProjectRanks builds projectRanks map
    Alpine->>Alpine: CSS order applied per row
    Note over Alpine: Visual reorder, no DOM moves

    User->>Alpine: select Name A-Z
    Alpine->>Alpine: recomputeProjectRanks clears projectRanks
    Note over Alpine: CSS order removed, default A-Z restored
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant Alpine as Alpine (dashboard)
    participant API as GET /api/projects/sortmeta
    participant DB as SessionHistoryStore

    User->>Alpine: select Last used or Cost
    Note over Alpine: x-model sets projectSort, @change fires
    Alpine->>API: fetch /api/projects/sortmeta
    API->>DB: asyncio.to_thread rollup_for x N projects
    DB-->>API: name to last_used and cost_usd per project
    API-->>Alpine: JSON dict or empty on error
    Alpine->>Alpine: "sortMeta = response"
    Alpine->>Alpine: recomputeProjectRanks builds projectRanks map
    Alpine->>Alpine: CSS order applied per row
    Note over Alpine: Visual reorder, no DOM moves

    User->>Alpine: select Name A-Z
    Alpine->>Alpine: recomputeProjectRanks clears projectRanks
    Note over Alpine: CSS order removed, default A-Z restored
Loading

Reviews (2): Last reviewed commit: "feat(dashboard): opt-in sort control for..." | Re-trigger Greptile

Comment thread src/clauster/templates/_dashboard_script.html Outdated
Comment thread src/clauster/app.py
Adds a Name / Last used / Cost sort dropdown to the Projects zone. It defaults to the existing A-Z order and never reorders on its own; only an explicit non-name choice reorders rows (via CSS `order`, no DOM moves, so Alpine x-show/idx stay intact) and reveals the full list. Projects with no recorded history sink to the bottom.

Backed by a new read-only /api/projects/sortmeta batch endpoint sourced from the session-history rollup (db/stores.py rollup_for); the sort runs client-side and degrades to name order if the keys can't be read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@schubydoo
schubydoo force-pushed the feat/298-projects-sort branch from ecff94d to d349b48 Compare June 23, 2026 08:05
@schubydoo
schubydoo merged commit 0e0e466 into main Jun 23, 2026
22 checks passed
@schubydoo
schubydoo deleted the feat/298-projects-sort branch June 23, 2026 08:17
schubydoo pushed a commit that referenced this pull request Jun 23, 2026
Merging this PR tags and publishes **v0.12.2**.

Generated by knope from the `.changeset/` fragments — don't edit it by
hand; add or amend a changeset on the source PR instead.

---

## Features

- The Projects zone gains an optional sort control (Name / Last used /
Cost). It defaults to the existing A–Z order and never reorders on its
own — only when you pick a non-name sort does the list reorder
(most-recent or highest-cost first, projects with no recorded history
sinking to the bottom) and reveal every project. A new read-only
`/api/projects/sortmeta` endpoint supplies the last-used and cost keys
from the session-history rollup; the sort itself happens client-side and
degrades silently to name order if the data can't be read.
([#528](#528))
- Record bridge/session lifecycle events (spawned, ready, ended,
crashed) with mode and an end-of-session cost/token snapshot to a
persistent session-history table, with per-project "last used / total
cost" rollups readable from the DB.
([#514](#514))
- Expand the outbound webhook taxonomy beyond the four bridge events
with `bg-settled` (a `claude --bg` background job settled),
`permission-needed` (a hosted session parked a tool-permission prompt —
the "come look" signal), and `clone-done` (a project clone finished).
Each carries an `event_type` discriminator, is redacted before egress,
and **defaults OFF** — opt in per-key under `webhooks.events`.
([#518](#518))
- Webhooks gain an opt-in SSRF guard. Set
`webhooks.block_private_targets: true` to skip any webhook URL whose
host is an internal/non-routable IP literal — loopback, link-local
(incl. the `169.254.169.254` cloud-metadata IP), RFC1918 private,
unspecified (`0.0.0.0`/`::`), reserved, multicast, IPv6 ULA
(`fc00::/7`), and carrier-grade NAT (`100.64/10`). It also catches the
non-canonical IPv4 encodings the OS resolver still dials but `ipaddress`
rejects (decimal-integer `2130706433`, hex, short `127.1`), and
normalizes IPv4-mapped IPv6, so none of those slip past to loopback or
the metadata endpoint. Defaults to **off**, so existing LAN/private
receivers keep working unchanged. DNS hostnames are not resolved
(rebinding) and exotic IPv6 embeddings (NAT64, IPv4-compatible) are not
normalized — out of scope for this literal-IP seam.
([#529](#529))
- New Clauster brand. A logo lockup (the carved-cells mark + `clauster`
wordmark) now appears in the dashboard navbar and on the login and 404
pages, with a refreshed mark as the favicon. The lockup adapts to the
theme — white on dark, black on light — and keeps the neon-green "live
session" accent on both. The full logo kit (mark, wordmark, app icon,
mono + accent variants, favicon) ships in `assets/logo/` alongside a
brand showcase. ([#494](#494))
- Extend the bridge subprocess `PATH`/env from `clauster.yml` via
`claude.path_append` and `claude.env` (both standard and pty modes), so
a `claude` session can resolve user-local tools a minimal service `PATH`
omits. ([#504](#504))

## Fixes

- Unify the hosted-session status badge colours with the bridge map in
the shared Active list: `starting` is azure, `stopping` is orange, and
`crashed` is amber (recoverable, Resume) instead of red — and the
crashed badge now matches its dot.
([#505](#505))
- Serialize `SessionRunner` state persistence so a concurrent
prune-races-upsert window can no longer surface a transient "could not
persist bridge state" warning: the persist path now holds its own lock
(mirroring the hosted manager), keeping each save atomic against
interleaving startup-watch / stop / poll-loop writers.
([#501](#501))
- Harden pty-keeper sidecar parsing so a malformed
`keeper_pid`/`bridge_pid` of `true`/`false` no longer resolves to PID 1
(`bool` is an `int` subclass) and is now treated as absent.
([#500](#500))
- Document the three `logs.retention_*` knobs in `clauster.yml.example`.
([#502](#502))
- Forget now clears a dead background agent even when `claude rm`
soft-fails: clauster drops the orphaned job record itself, gated on the
worker being confirmed dead so a live worker is never force-forgotten.
([#506](#506))
- Logs: the live-tail "reconnecting…" and "disconnected" banners are now
mutually exclusive, and the disconnect banner no longer claims the
bridge may have stopped while it is still running.
([#507](#507))
- Surface the config-file search order in `clauster --help`: the epilog
now lists `$CLAUSTER_CONFIG` → `./clauster.yml` →
`$CLAUSTER_HOME/clauster.yml`, so the resolution order is discoverable
from the CLI without digging through the docs.
([#522](#522))
- A PTY-form bridge that survives a clauster restart is now reattached
as a managed RUNNING instance instead of being orphaned as a STOPPED
card or leaking an uncontrollable keeper process.
([#535](#535))

## Security

- Gate inline scripts with a per-request CSP nonce and drop
`'unsafe-inline'` from `script-src` (`'unsafe-eval'` and `style-src
'unsafe-inline'` remain, tracked as the #442 follow-up).
([#532](#532))
- Store the `/metrics` scrape token as a SHA-256 hash at rest
(`observability.metrics_token_hash`), matching the API token; mint one
with the new `clauster hash-metrics-token` command.
([#503](#503))

## Build System & Dependencies

- Add a `changeset-autodraft` workflow that auto-drafts a
`.changeset/*.md` fragment on trusted, same-repo PRs that touch `src/`
but lack one. ([#530](#530))

Co-authored-by: clauster-ci[bot] <289303168+clauster-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FE-2: optional sort control for the Projects zone

1 participant