From 6627cfc2f02d90cdfed0a66a9d45f7207922d81a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 2 Sep 2026 18:37:55 -0400 Subject: [PATCH] feat(rules): a usage badge on the rule list, and the badge becomes canon instead of a second copy (#3319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 333 step 5, and rule 27 — the counter had a tuning point from step 4 and no operator-facing one until now. The task said to reuse the snippet badge's classes rather than mint a parallel set, citing the eight duplicated CSS families the ledger already carries (#3207). `.usage-tag` lived in SnippetListView's SCOPED block, so "reuse" was not available: copying it into the rule pane would have been the ninth family, and importing it is not a thing a scoped block permits. So it was promoted rather than copied. Three pieces, each of which existed once and now exists once: - `components.css` gains `.usage-tag` / `.usage-dead`, geometry and colour only, with the scoped original deleted rather than left behind. - `UsageBadge.vue` holds the logic the two lists would otherwise duplicate — the >=3 dead-weight threshold, the empty-string-renders-nothing rule, the tooltip. - `types/usage.ts` holds `RecordUsage`, one client type over two tables. `SnippetUsage` becomes an alias, so no existing consumer changes. THE ADVICE IS A PROP, and that is the substance rather than the plumbing. The counts read identically for every kind; the remedy does not. A snippet offered and never opened should probably be rewritten or deleted — one action. A rule in the same position has TWO possible causes and the operator has to pick: its trigger may fire on the wrong work, in which case `when_to_apply` wants rewording, or it may genuinely not be wanted. Baking "delete it" into the component would give the wrong nudge half the time on the surface where being wrong is most expensive, since a deleted rule stops binding behaviour. The route zero-fills every row through `usage_for_rules`, one aggregate per page — per-row would be N+1 by construction. That matters more here than for snippets: every rule on every existing install predates `rule_usage_events`, so the zero-filled shape IS the common case for a while, and a route that attached the key only where it found events would leave the badge reading undefined on almost every row. `usage_for_rules` had no test at all — step 1 covered the write path and the zero shape and left the aggregate uncovered, which only became load-bearing when a list started rendering it. It now has an integration test over real Postgres, including that a rule with no events comes back zero-filled rather than absent. Recorded as snippet #3460, per the design system's own instruction that the component layer lives as snippets rather than as prose. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN --- frontend/src/api/rulebooks.ts | 9 +++ frontend/src/api/snippets.ts | 16 ++--- frontend/src/assets/components.css | 26 +++++++ frontend/src/components/UsageBadge.vue | 62 +++++++++++++++++ .../src/components/rules/RuleListPane.vue | 12 ++++ frontend/src/types/usage.ts | 23 +++++++ frontend/src/views/SnippetListView.vue | 67 +++---------------- src/scribe/routes/rulebooks.py | 19 +++++- tests/test_routes_rulebooks.py | 24 +++++++ tests/test_services_rule_usage.py | 62 +++++++++++++++++ 10 files changed, 250 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/UsageBadge.vue create mode 100644 frontend/src/types/usage.ts diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 9665f42..1a8c175 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -1,3 +1,5 @@ +import type { RecordUsage } from "@/types/usage"; + import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; /** How a rule reaches a session (milestone 307). */ @@ -96,6 +98,13 @@ export interface RuleHeader { * A date (YYYY-MM-DD), or the literal "never". */ last_verified?: string; + /** + * Surfaced-vs-opened counts from `rule_usage_events` (milestone 333). + * Zero-filled by the list route, so a rule predating the table reads as + * "never surfaced" rather than as a missing field — which for a while is + * every rule on every install. + */ + usage?: RecordUsage; } export interface ApplicableRules { diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 25bd2d8..0931e14 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -1,3 +1,5 @@ +import type { RecordUsage } from "@/types/usage"; + import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; /** One canonical location of a reusable thing. A snippet that unifies several @@ -50,15 +52,11 @@ export interface Snippet { owner?: string | null; } -/** How often a record was put in front of an agent versus actually opened. - * A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a - * slot in every future auto-inject menu while never being used. */ -export interface SnippetUsage { - surfaced_count: number; - pull_count: number; - last_surfaced_at: string | null; - last_pulled_at: string | null; -} +/** Kept as a name because every consumer here says "snippet usage" — but it IS + * the shared shape, since rules answer the same question off their own table + * (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four + * fields here is how the two drift. */ +export type SnippetUsage = RecordUsage; /** Result of the last drift check — does the recorded location and code still * match source? The check runs agent-side (Scribe has no checkout); this is the diff --git a/frontend/src/assets/components.css b/frontend/src/assets/components.css index 0d76513..c886ef0 100644 --- a/frontend/src/assets/components.css +++ b/frontend/src/assets/components.css @@ -351,3 +351,29 @@ .required { color: var(--fs-error); } .field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); } + +/* --- usage badge ---------------------------------------------------------- + "surfaced N×, opened M×" on a list row, for any record kind the retrieval + surfaces can choose: snippets and notes from note_usage_events, rules from + rule_usage_events. Promoted here from SnippetListView's scoped block when + the rule list needed the same chip (milestone 333 step 5) — a second scoped + copy is how the ninth duplicated CSS family starts (#3207). + + Geometry and colour only. A view keeps its own spacing as a scoped + remainder, the way it does for every other recipe in this file. */ +.usage-tag { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); + color: var(--fs-text-tertiary-fg); +} + +/* Dead weight is a nudge, not an error — it warns in the warning colour rather + than the danger one, because the record isn't broken, just unearned. */ +.usage-tag.usage-dead { + background: color-mix(in srgb, var(--fs-warning) 18%, transparent); + color: var(--fs-warning-fg); +} diff --git a/frontend/src/components/UsageBadge.vue b/frontend/src/components/UsageBadge.vue new file mode 100644 index 0000000..47fabbb --- /dev/null +++ b/frontend/src/components/UsageBadge.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue index 6c2a662..32d08f7 100644 --- a/frontend/src/components/rules/RuleListPane.vue +++ b/frontend/src/components/rules/RuleListPane.vue @@ -1,5 +1,16 @@