Skip to content

fix(webui): open the model inspector from anywhere in a library row - #1926

Merged
inureyes merged 5 commits into
mainfrom
fix/issue-1901-webui-row-activation
Sep 19, 2026
Merged

inureyes merged 5 commits into
mainfrom
fix/issue-1901-webui-row-activation

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026

Copy link
Copy Markdown
Member

In the Models library a click anywhere in a row, not only on the name button, now opens the inspector for that entry, so Load, Use in Chat, Unload and Delete are reachable without discovering the name button. Selecting still only calls selectModel and never loads; clicking the row that is already selected does nothing, because selectModel always aborts the live event stream and forces a full snapshot even for the same id.

The behavior is an opt-in seam in the shared DataTable adapter, activateRowPrimary, for #1918 to build on. A body-row click focuses and clicks the row's primary control (the element with ROW_PRIMARY_CLASS, here the "Inspect {name}" button), so pointer and keyboard run the same handler. Clicks on another interactive element in the row, clicks that end a text-selection drag, and rows without an enabled primary control (including the loading and empty rows) are ignored. Rows keep their native row role and gain no tab stop; while the name button has :focus-visible the whole row is outlined.

Why not ui-common onRowClick

The issue suggested alpha.19's onRowClick / isRowClickable. Measured on the Models page, they render every body <tr> as role="button" with tabIndex=0, which fails axe aria-required-children (critical: the rowgroup holds buttons, not rows) and nested-interactive (serious: the name button sits inside the row button), doubles the tab stops per row, and names the row Inspect alpha cache 4bit chat, ..., so the router-real.harness.ts locator new RegExp('Inspect .*' + name) matches two elements and its .click() throws. The adapter's props type omits both props so they cannot come back by accident; docs/webui/ui-common.md records this.

Changes during review

  • Re-selecting the selected row is a no-op (security review: each stray click cost a stream reconnect, a full snapshot and the Activity history).
  • After merging feat(webui): adopt the ui-common theme system and add a glass theme #1923 the selected row paints its selection fill, and the accent "Selected" badge on it failed WCAG AA contrast: 4.49:1 in mlxcel-light and 3.73 to 4.49:1 in every theme under high contrast, because ui-common paints --token-colorPrimary text on a fixed translucent blue fill. The Badge adapter now draws the accent tone as body text inside a --color-focus outline. A new test checks a selected and a hovered row for contrast in all four themes with high contrast off and on; without the CSS change it fails in five of the eight variants.

Test-first evidence

The row-activation tests against the unmodified source (origin/main 31c0459):

  ✓  1 [chromium] › tests/models.spec.ts:164:1 › library journey observes operations before load/chat/unload/cache removal (924ms)
  ✓  2 [chromium] › tests/models.spec.ts:218:3 › Models 1440 light: bounded inventory, keyboard inspection, no search POST, a11y (713ms)
  ✓  3 [chromium] › tests/models.spec.ts:218:3 › Models 390 dark: bounded inventory, keyboard inspection, no search POST, a11y (688ms)
  ✘  4 [chromium] › tests/models.spec.ts:270:3 › Models row activation › clicking a non-name cell opens that entry and reaches Load, Use in Chat and Unload (5.2s)
  ✘  5 [chromium] › tests/models.spec.ts:294:3 › Models row activation › keyboard Tab reaches the next row with a visible row outline, and Space or Enter opens it (315ms)
  ✘  6 [chromium] › tests/models.spec.ts:323:3 › Models row activation › dragging to select text inside a cell does not activate the row (5.5s)
  ✘  7 [chromium] › tests/models.spec.ts:351:3 › Models row activation › rows keep table semantics, each entry keeps one Inspect button, and the page stays axe clean (5.3s)

    Error: expect(locator).toBeVisible() failed
    Locator: getByRole('complementary', { name: 'Model details' })
    Error: element(s) not found

  4 failed
  3 passed (23.0s)

Reverting only screen.tsx on the final branch fails all five row-activation tests (the four above plus the contrast test) while the three name-button tests still pass. Every adapter guard was removed in turn and each removal fails a unit test; removing only the selection guard fails the drag test, which ends with a control click on the same point so it cannot pass by missing the row.

Validation

  • typecheck, lint, unit (51 Vitest files, 484 tests, plus 35 node tests including the i18n drift and theme-selector checks), check:theme-selectors.
  • Playwright Chromium, one spec per run on an isolated port: models 8/8, theme 23/23, browser 28/28, ui-common-components 14/14, ui-common-shell 12/12, settings 3/3, chat 2/2, activity 3/3.
  • Bundle regenerated from the merged source; build_bundle.py --verify, check_webui_contract.py, check_binary_assets.py and check_cross_repo_refs.py pass. browser:all (multi-engine) is left to CI.

Real-binary acceptance

Run at head e7f3db0 against the bundled server (--webui --models-dir models/mlx --no-models-autoload --settings --props --metrics --slots, 224 catalog entries), Chromium at 1440x900, with the library filtered to qwen3-0.6b-4bit (two rows: that models_dir entry and the mlx-community/Qwen3-0.6B-4bit cache entry). All pass:

  • Clicking the Lifecycle cell of qwen3-0.6b-4bit opens the inspector for that entry; the row gets models-selected, its selection fill and the "Selected" badge; no POST is sent. Clicking the other row switches the inspector to it.
  • Clicking the already-selected row five more times sends no bootstrap, catalog or events request (0 / 0 / 0). As a control, switching to a different row sends 1 bootstrap, 5 catalog and 1 events reconnect.
  • Keyboard: from the search field, 5 Tabs reach the first row's name button (:focus-visible); the row shows a 2px solid focus-colored outline and has no role or tabindex. Space opens that row; Tab moves to the next row (outlined) and Enter opens it.
  • From the inspector opened by a row click: Load (lifecycle loading then ready), Use in Chat (enabled, navigates to #chat), then back on Models, Unload through the confirmation dialog (unloading then unloaded). GET /ui-api/v1/catalog?q=qwen3-0.6b-4bit then reports state: unloaded, worker_exit_observed: true. Exactly two POSTs were sent, both to /ui-api/v1/model-actions. The model is left unloaded.
  • Drag-selecting text in a Task cell of an unselected row selects "chat, rerank," and leaves the inspector unchanged; a plain click at the same point then opens that row.
  • At 390x844 a row click opens the inspector with no horizontal page scroll.

Real-router harness

Run by the epic #1910 orchestrator on this head (e7f3db03): cargo test --profile test-fast --features metal,accelerate --lib server::router_server::router_webui_playwright_harness_tests::real_router_browser_harness -- --ignored --exact --nocapture (the make verify-webui-integration-fake target) passed. Its Playwright log shows the spec ran rather than skipped: tests/router-real.harness.ts "secured Rust router drives download, load, chat, Stop/drain, unload, remove and negative security cases" passed in 5.9 s, including the unchanged Inspect {name} locator at router-real.harness.ts:321.

Closes #1901

Part of #1910

The Models library opened its inspector, and with it Load, Use in Chat, Unload and Delete, only from the name button in the first cell. Clicking anywhere else in the row did nothing, so users concluded unload was missing.

The shared DataTable adapter gains an opt-in activateRowPrimary seam. A click in a body row focuses and clicks the row's primary control (marked with ROW_PRIMARY_CLASS), so pointer and keyboard run the same onClick. Clicks on other interactive elements, clicks that end a text-selection drag, and rows without an enabled primary control (including the loading and empty rows) are ignored. Rows keep their native row role and gain no tab stop; adapter CSS adds the pointer cursor, a low-specificity hover fill and a whole-row outline while the primary control has :focus-visible. The Models name button carries the marker, and selecting still never loads.

ui-common alpha.19 onRowClick and isRowClickable are withheld from the adapter type: they render each tr as role="button", which fails axe aria-required-children and nested-interactive on the Models page and makes the router-real harness locator match two elements.

New Playwright row-activation tests fail on the previous source and pass now, alongside adapter and screen unit tests, typecheck, lint, the unit suite, the models and ui-common browser specs, and a regenerated, verified bundle.

Closes #1901

Part of #1910
@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority area:architecture Architecture and code structure changes labels Sep 19, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Open the Models inspector from anywhere in a library row through an opt-in activateRowPrimary seam on the shared DataTable adapter, keeping native row semantics and the name button as the keyboard target.

Findings Addressed

  • No CRITICAL or HIGH findings; no code changes were needed.

Remaining Items

  • Two adapter guards survive mutation: dropping event.currentTarget.contains(row), or widening if (nested && row.contains(nested)) to if (nested), keeps the adapter and screen unit tests green (38/38). The second matters for feat(webui): redesign the model library as a dense, task-first list with row actions #1918: a table inside a tabindex="0" scroll region would silently lose row activation. The PR body claim that each guard removal is caught by a unit test needs softening or a focusable-container test. (MEDIUM), left: auto-fix covered CRITICAL and HIGH only
  • Pre-existing since feat: add the model library WebUI #1891: .models-library .models-selected uses var(--surface-selected, var(--surface-hover)) and neither variable is defined, so the selected row computes to rgba(0, 0, 0, 0) with no hover fill; only the "Selected" badge marks it. (LOW), out of scope here; candidate for feat(webui): redesign the model library as a dense, task-first list with row actions #1918
  • The first click of a word-select double-click in a cell activates the row; only drag selection is guarded. Harmless because activation only selects. (LOW)

Verification

  • All stated requirements implemented (real-binary acceptance still pending with the orchestrator)
  • No placeholder/mock code remaining
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: typecheck, lint, unit (370 Vitest plus node tests), models.spec.ts 7/7 on an isolated port, bundle --verify, webui contract and binary-asset checks. A WebKit and Firefox probe also confirmed pointer activation, the drag-selection guard and a painted row outline on keyboard focus.

@inureyes

Copy link
Copy Markdown
Member Author

Security and Performance Review

No CRITICAL or HIGH findings; no code changes were made.

Checked

  • Row activation only reaches the clicked row's own ds-row-primary element, which on Models is the "Inspect {name}" button calling actions.selectModel(id). Load, Unload, Delete and the confirmation dialogs render outside the delegated wrapper. A probe on a 25-row page sent zero POSTs across 85 row clicks.
  • CSP: no inline script, inline style or style prop is added, and index.html changes only the two hashed asset names. build_bundle.py --verify passes, so the committed bundle is exactly what the source builds.
  • Handler cost on 25 rows: under 0.1 ms per click, the same as clicking the name button directly.

Remaining items

  • MEDIUM: clicking the already-selected row is not free. selectModel always runs selectionChanged(), which aborts the live /events stream and forces a full snapshot, and the reducer clears runtimeHistory even when the id is unchanged. With the stream held open, 2.5 s idle sent no bootstrap, catalog or events requests; 5 clicks on the selected row sent 5 of each. The name button already behaved this way, but the whole row is now the target, so stray clicks reset Activity history and reconnect the stream. Suggested fix: skip selectModel when the id equals the current selection. Left because auto-fix covered CRITICAL and HIGH only.
  • LOW: the ROW_INTERACTIVE comment says it mirrors the package guard, but it uses a[href] where alpha.19 uses a. Harmless on Models, which has no anchors.

Review follow-ups to PR #1926 (issue #1901, epic #1910).

Re-selecting the already-open row now short-circuits before calling actions.selectModel, since selectModel unconditionally aborts the live /events stream, forces a full snapshot refetch and clears runtimeHistory even when the id is unchanged; a stray click on an already-selected row no longer pays that cost. screen.test.tsx and load-profile-integration.test.tsx are updated for the new no-op semantics, and the row-activation describe block gains a case that proves a same-row click and its name button both stay silent while a different row still selects exactly once.

common-adapters.test.tsx gains two DataTable whole-row activation tests that were previously unexercised: a focusable ancestor outside the row (a tabIndex=0 scroll region, as #1918 may add) still lets a plain-cell click through, and a click that bubbles from a React portal rendered outside the delegation wrapper is ignored. Both were confirmed to fail against a temporarily weakened guard and pass again once restored.

common-adapters.tsx renames the module-level handler activateRowPrimary to delegateRowClick and drops the prop-destructuring alias, and corrects the ROW_INTERACTIVE comment: it mirrors the common table's nested-interactive guard except that an anchor only counts as interactive with an href.
The DataTable<T> adoption-matrix row described whole-row activation but not the follow-up in 2c2efd6: the Models name button's onClick skips actions.selectModel when its entry is already selected, so re-activating the selected row (by the row or the name button) no longer restarts the live event stream and snapshot refetch.

Validation: pnpm --dir webui run typecheck, pnpm --dir webui run lint, pnpm --dir webui run unit (373 passed) and pnpm exec playwright test --config playwright-report/local-4901.config.ts tests/models.spec.ts (7 passed) all pass; docs-only change needs no bundle rebuild.

Refs #1901
Brings in the theme system from #1923. The only conflicts were in the generated bundle under src/webui/assets/, which was regenerated from the merged source with scripts/webui/build_bundle.py rather than hand-merged.
With #1923 the selected Models row now paints its --color-selection fill, and the accent Badge on it failed WCAG AA color contrast: ui-common alpha.19 draws --token-colorPrimary text on a fixed translucent blue fill, which measured 4.49:1 on the selected row in mlxcel-light and 3.73 to 4.49:1 in all four themes under high contrast, where the selection fill is 30 percent focus color. Row activation makes a selected row routine, and the new row-activation axe test caught it after the merge.

The Badge adapter CSS now draws the accent tone as body text inside a --color-focus outline, which clears 4.5:1 on every surface it sits on. A new models.spec.ts test selects a row, hovers another, and runs the axe color-contrast rule on the table in mlxcel and glass, light and dark, with high contrast off and on. Without the CSS change it fails in five of those eight variants (mlxcel-light, and every theme with high contrast on).
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
@inureyes
inureyes merged commit c1521d6 into main Sep 19, 2026
18 checks passed
@inureyes
inureyes deleted the fix/issue-1901-webui-row-activation branch September 19, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Architecture and code structure changes priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(webui): make the whole model library row open the inspector

1 participant