fix(dashboard): pin the view-mode switcher and add a v shortcut to cycle Timeline / Grid / List (#2536) - #2547
Merged
Conversation
Rule #1: the requirement lands before any code. §9.14 specifies the two halves of #2536 — the Timeline / Grid / List switcher pinned as the LAST child of the right-anchored header controls cluster (x-position then depends on nothing to its right), and `v` cycling the modes in the switcher's visual order through the `/` hotkey's shared guard ladder. Records the known gaps (guard 5 does not cover pane-internal popovers; WCAG 2.1.4 single-key shortcuts) and the follow-ups (1/2/3 jumps, Shift+V reverse, e.code matching, AT attributes, #1921, #1754). Status 🚧 In Progress; flipped to ✅ in the docs commit that closes the change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s.js (#2536) The mode list lived twice, in DIFFERENT orders: `['grid','timeline','list']` in stores/network.js (whitelist only — order never mattered, not exported) and `['timeline','grid','list']` in the Dashboard's switcher v-for (the visual order). A `v` cycle built on the store copy would have run against the buttons. Consolidate into one zero-import leaf, `utils/viewModes.js` (the #2199 gridStorageKeys shape — Playwright resolves neither the Vite `@` alias nor a paths map, so e2e specs import it by relative path), exporting VIEW_MODES (frozen, visual order), DEFAULT_VIEW_MODE (index 0, the degrade target) and nextViewMode() (unknown wraps to the default). Consumers: the store imports the whitelist and uses DEFAULT_VIEW_MODE at its two degrade sites (init + setViewMode; the identity checks stay literal); the view iterates the import in the v-for; the #1830 stats-overflow sweep imports the same list (identical order → identical sweep). Behaviour-neutral. Guards: tests/unit/viewModes.spec.js (pure module — order, default, frozen, cycle, unknown wrap) and tests/unit/viewModeStructure.spec.js (the literal appears in exactly ONE file under src/ + e2e/ on comment-stripped source; the leaf has zero imports; store + view import it; Dashboard.vue registers exactly one document keydown listener). The last-child assertion lands with the move in the next commit. npm run test:unit: 86 files / 1845 tests (HEAD: 84 / 1835). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#2536) The header's right-hand controls cluster is a right-anchored, non-shrinking flex row (`justify-between` + `flex-shrink-0`), and the Timeline / Grid / List switcher sat in the MIDDLE of it with two conditional siblings to its right — the grid-only Tidy up / Reset pair and the history-loading spinner. In an LTR flex row a child's x is `cluster.right − Σ(width+gap of every sibling to its RIGHT)`, so entering Grid moved the control just clicked (~124px) and every history fetch twitched it (~24px). Move the switcher to be the cluster's LAST element child and the Tidy up / Reset `<template v-if>` to immediately before it (tail: Refresh ▸ [Tidy up ▸ Reset]* ▸ Switcher). Both blocks move VERBATIM — a reorder, not a reservation: no colour class added, removed or rewritten, no new width in any (mode, loading) state, so the #1830 stats-overflow sweep sees the same inputs it always did. Added only: `data-testid="view-mode-switcher"` + `title="Switch view (press v to cycle)"` on the wrapper (a `title`, never an `aria-label` — five e2e specs select the buttons by exact accessible name), `data-testid="history-loading"` on the spinner (its `v-if` untouched; loading-gate count stays 1), `data-testid="refresh-all"` on Refresh. DOM order moves with the visual order (no CSS `order:`), so tab order still matches (principle 23). Guards: - tests/unit/viewModeStructure.spec.js: parses the SFC with vue/compiler-sfc and asserts the switcher is the last ELEMENT child of its parent (a `<template v-if>` counts, so a conditional sibling fails too) and that the grid-controls template is the element immediately before it. A mutation control splices a `<span v-if>` after the switcher in the real source and proves the probe reports it — the guard is not vacuous. This is the REQUIRED CI gate (frontend-build.yml runs test:unit; the e2e suite is advisory). - e2e/dashboard-mode-switcher.spec.js (@smoke): holds `/api/activities/timeline` at the network boundary so the spinner is deterministic, then asserts the switcher's boundingBox is identical (≤1px) with the spinner, without it, in grid (Tidy up / Reset visible while measuring), list, timeline, and again while Refresh re-holds the spinner; plus `nextElementSibling === null`. Negative control run locally: with the grid template moved after the switcher the spec fails in grid mode with x 1115.67 → 989.61. Ratchets unchanged: views/Dashboard.vue raw colours 23/126/2 (== HEAD; the committed baseline is stale, not regenerated), loading gates 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…'s guard ladder (#2536) `v` (and Caps-Lock `V` — `shiftKey` false) advances the view mode in the switcher's visual order, Timeline → Grid → List → Timeline. It is dispatched from INSIDE handleDashboardKeydown: guard 1 (`e.key !== '/'`) becomes a lookup that picks openFilterPill for `/` and cycleViewMode for `v`, so guards 2–5 — Ctrl/Meta/Alt chords, IME composition, editable targets (INPUT / TEXTAREA / SELECT / contentEditable), open modals — stay ONE code path, and guard 0's `e.repeat` means a held key cycles once. `/` still does NOT exclude shiftKey (de-DE `/` is Shift+7); `v` DOES — the letter never needs Shift on any layout, so Shift+V stays reserved (a future reverse cycle) instead of silently cycling forward. Same document listener, same registration slot above the mount await (#2200; mountListenerOrdering.spec unchanged and green; viewModeStructure.spec pins exactly one keydown listener). cycleViewMode calls networkStore.setViewMode(nextViewMode(viewMode)) — the exact path a switcher click takes — so localStorage and the active button cannot disagree with the pane; the `?view=` deep-link's `persist: false` path is untouched. Discoverability is the wrapper's `title="Switch view (press v to cycle)"` (added with the pin in the previous commit), mirroring the filter button's "(press /)". e2e (both @smoke, run locally against a worktree Vite server on :5180 because the :80 stack bind-mounts a different checkout): - `v` cycles all three modes; localStorage follows each press (read in the browser context); a reload after a further `v` lands in the persisted mode. The saved key is cleared once in-page — never via addInitScript, which re-runs on reload(). - Guards: inert with focus in the time-range <select> (targeted by its `24h` option — the first <select> is the Owner filter on multi-owner stacks), under Control/Alt/Meta/Shift chords, inside the filter pill (the typed `v` becomes a live query that legitimately filters trinity-system out, so the pane is asserted, not the row), and with focus on the Create Agent modal's Cancel button (a BUTTON, so guard 5 is what suppresses it). Positive control: a bare `v` then switches. The wrapper's title names the key; no mode button carries an aria-label. npm run test:unit: 86 files / 1847 tests. e2e/dashboard-mode-switcher: 3 passed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Implemented (#2536) - requirements/core-agent.md §9.14: 🚧 In Progress → ✅ Implemented. - architecture/frontend.md (Dashboard Grid view paragraph): VIEW_MODES now lives in the zero-import leaf utils/viewModes.js — one home for the store whitelist, the switcher v-for, the `v` cycle order and the e2e specs; the switcher is deliberately the LAST child of the header controls cluster; `v` cycles through handleDashboardKeydown's shared guard ladder. - feature-flows/dashboard-{grid,timeline,list}-view.md: the toggle is the last header control (pinned across modes and the history spinner), `v` cycles the modes, and the "second home of VIEW_MODES" wording is retired. - feature-flows.md: new top row for 2026-09-06 / #2536. - user-docs (operations/dashboard.md, guides/using-trinity.md): the pages already advertise `/`; staying silent on `v` would be a doc/product mismatch — one bullet and one clause. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
trinity-ability
marked this pull request as ready for review
September 6, 2026 17:36
# Conflicts: # docs/memory/feature-flows.md
vybe
approved these changes
Sep 7, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
Validated via /validate-pr: Fixes #2536 (P2 type-bug), targets dev, 15 files, frontend only. Mode list moved to a zero-import one-home module with a vitest structure guard (last-child + adjacency, mutation control) and a named e2e spec; v rides the existing / keydown guard ladder with one document listener. Requirements §9.14, architecture/frontend.md, three dashboard flows and user docs updated. Security greps clean. Merged current dev to resolve the feature-flows.md index conflict; CI fully green on the merged head.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
x = cluster.right − switcher.width, both invariant. This is a reorder, not a reserve: the cluster's total width per (mode × loading) state is byte-identical todev, so the bug: Dashboard stats bar — right controls overlap host telemetry below ~1170px effective width #1830 stats-overflow sweep sees the same inputs it sees today.vcycles Timeline → Grid → List → Timeline from inside the existing/hotkey handler's guard ladder (repeat / chords / IME / editable targets / open modals shared; one document listener; armed above the mountawait), persisting throughsetViewModeexactly like a click.utils/viewModes.js— the store copy was orderedgrid, timeline, listand a cycle built on it would have run against the buttons.Closes
Fixes #2536
What changed (by commit)
1c12ba40docs(requirements) — new §9.14 indocs/memory/requirements/core-agent.md(Rule Fix: Add missing Docker labels to system agent container #1, first commit): activation + guard order, cycle order = visual order = default, persistence, discoverability, layout invariant, known gaps (guard-5 pane-internal popovers, WCAG 2.1.4), follow-ups.a3e8ca48refactor(dashboard) —src/frontend/src/utils/viewModes.js(zero imports:VIEW_MODES,DEFAULT_VIEW_MODE,nextViewMode);stores/network.jsdrops its local list and uses the shared one;Dashboard.vue'sv-forreadsVIEW_MODES;e2e/dashboard-stats-overflow.spec.jsimports the list (same order, sweep unchanged); newtests/unit/viewModes.spec.js(4 tests) andtests/unit/viewModeStructure.spec.jsone-home / zero-import / one-listener guards.704ac54ffix(dashboard) — switcher moved verbatim to the last child of the controls cluster; Tidy up / Reset moved verbatim to immediately before it;data-testid="view-mode-switcher"/history-loading/refresh-alladded; the site comment records why (and the [BUG] Dashboard navigation and timeline controls overlap at narrow desktop widths #1754 collapse-from-the-left hand-off);viewModeStructure.spec.jsgains the SFC-AST last-child + adjacency guard with a mutation control; newe2e/dashboard-mode-switcher.spec.jsbounding-box test (3 modes × spinner held / absent).132033f6feat(dashboard) — guard 1 ofhandleDashboardKeydownbecomes a dispatch:/→ open filter (Shift NOT excluded — de-DE/is Shift+7),v/Vwithout Shift →cycleViewMode; guards 2–5 stay one code path;title="Switch view (press v to cycle)"on the wrapper (noaria-labelon the mode buttons — their accessible names are contract for five e2e specs); e2e cycle-and-persist + guards tests.3d9ab3f0docs(dashboard) —architecture/frontend.mdclause;dashboard-grid-view.md/dashboard-list-view.md/dashboard-timeline-view.md;feature-flows.mdRecent Updates row; user docs (2 lines); §9.14 → ✅ Implemented.Proof
13899 passed, 32 skipped; integration70 passed, 13 skipped, 2 deselected; image build + import-smoke OK; boot + health OK; venv Python 3.13.5.86 files / 1847 passed; post-rebase neighbourhood (viewModes,viewModeStructure,mountListenerOrdering,loadingGateRatchet):4 files / 24 passed.viewModeStructure2 failed / 6 passed; a second document keydown listener → 1 failed;nextViewModeoff-by-one → 2 failed.views/Dashboard.vueraw-colour23 / 126 / 2==origin/dev(the committed baseline18/77/2is stale and deliberately NOT regenerated in a bug fix); loading gatesDashboard.vue: 1(unchanged);check:tokensOK.e2e/dashboard-mode-switcher.spec.js— 4 passed (3 tests + the authsetupproject), run against a worktree Vite: the dev stack on:80bind-mounts a different checkout, so CI's advisory@smokejob is the first:80-equivalent run./reviewclean, 0 fixes;/validate-prAPPROVE (no Critical, no Warnings; the only note is the three dashboard flow docs' pre-existing bespoke section shape, left alone per Rule Feature/gemini runtime support #2).Decisions for the reviewer
v. Mnemonic for the product's own "view" vocabulary (trinity-dashboard-view,?view=); mirrors the existing/binding's guard ladder (a plain layout-produced key viae.key); zero collisions at HEAD (the only printable-key document handler insrc/frontend/srcis/);Shift+Vdeliberately left free (reserved for a possible reverse cycle). Rejected in the plan:1/2/3direct jumps (optional per the AC, a ≈3-line opt-in on the dispatch map — note AZERTY's top-row digits are Shift-produced),g/t/l(three keys, andgis a vim prefix),m, and Tab chords (design-system principle 23). Override here if you want a different key.v-if, loading-gate count unchanged); any AT attributes (role="group",aria-label="View mode",aria-keyshortcuts="v"on the wrapper,aria-pressedon the buttons) — they change what assistive tech perceives, so they are listed for Design system v2: shared UI primitives, raw-color ratchet, and written style guide #1430 rather than defaulted.Not in this PR / follow-ups (not filed)
e2e/dashboard-stats-overflow.spec.jsgrid@640 is already red ondevfor tag-rich fleets (9 px overlap, span "agents" ⨯ Create button SVG) — [BUG] Dashboard navigation and timeline controls overlap at narrow desktop widths #1754 / bug: Dashboard stats bar — right controls overlap host telemetry below ~1170px effective width #1830 scope.e2e/grid-org-overlay.spec.js:134— the.gv-zonelocator is strict-mode-unsafe once a seconddept-*tag exists./andv; the AT attributes above;1/2/3direct jumps; regenerate the staleraw-color-baseline.jsondeliberately, not inside a bug fix.Dashboard.vuedecomposition (1231 lines); deferred here because extracting the header would collide with [BUG] Dashboard navigation and timeline controls overlap at narrow desktop widths #1754's pending responsive rework. The refactor: decompose oversized Vue views (Settings 3061 lines, MobileAdmin, Agents, AgentDetail, ...) #1030 / refactor: replace bespoke spinners with skeleton placeholders on non-chart surfaces (scanline stays on charts only — design-system p12 as amended 2026-09-06) #1921 deferral comments are for the human to post.e.keymatching is layout-dependent (Cyrillicмon the physical V key will not match — the same as/today); guard 5 does not cover pane-internal popovers (FleetGrid Tiles menu / New-department popover / assign mode, List bulk-tag popovers, NavBar Build Info) — the same exposure a switcher click has.Pre-existing reds reconfirmed on base
A/B-proven identical on
origin/devwith the same tag-rich dev stack:dashboard-stats-overflow.spec.jsgrid@640 (9 px overlap) andgrid-org-overlay.spec.js:134.gv-zonestrict-mode violation with 2 departments.🤖 Generated with Claude Code
CI note — CodeQL
The
CodeQLsummary check flagged one new alert,js/incomplete-multi-character-sanitization, atsrc/frontend/tests/unit/viewModeStructure.spec.js(stripComments). It is a false positive: the regex blanks<!-- -->comments from a Vue SFC template string so the structure guard can regex-scan source text; the result is fed only toRegExp.test/expect().toMatch, never rendered or inserted into a DOM (noinnerHTML/v-htmlsink in the file; vitest runs withenvironment: 'node'). Dismissed as used in tests with this rationale. BothAnalyze (python)andAnalyze (javascript-typescript)jobs are green.