Skip to content

feat(webui): redesign the model library as a dense, task-first list with row actions #1918

Description

@inureyes

Part of #1910.

Problem / Background

Measured 2026-09-17 against a release build of main 0ef0a1a4 with 212 checkpoints in models/mlx: the Models page is 3,132 px tall at 1440x900 with four rows visible, and 5,936 px at 390x844. Each row is about 100 px because the name cell (webui/src/features/models/screen.tsx:185-206) stacks a ghost Button styled as a pill, a "Selected" label and a source · quantization line. The table has four columns (Model, Task, Lifecycle, Support / Completeness); there is no size column although metadata.disk_bytes is known for every entry, and the Support column repeats "Supported architecture / Complete files" on almost every row. Load, Use in Chat, Unload and Delete exist only inside the inspector (webui/src/features/models/inspector.tsx:77-101), which opens only through the name button (#1901), so the shortest path from a fresh page to chatting is: search, click the name pill, scroll the inspector to its bottom, click Load, click Use in Chat. The #1834 contract says the Models primary path is "Search/filter local library; inspect; Load; Use in Chat; Unload".

The first screen is spent on text nobody acts on: a list of disabled bootstrap actions with raw reasons (screen.tsx:319-326), the paragraph "Backend router_pool; build 0.7.0; state streaming; catalog 212; operations 1; snapshot 3." (connectedDetail at screen.tsx:327), a permanently rendered "Configured roots" disclosure with a copyable example command (screen.tsx:328-356), three page-level buttons in a button-row, then a five-control filter toolbar (screen.tsx:357-409). Below the table, "Library operations" (webui/src/features/models/operations.tsx) prints op_model_load_000001 · mdl_... · Operation state: succeeded and ready · Worker exit observed: No, duplicating the Activity page. The inspector prints the opaque identity.id (mdl_dtlZAgKq...), a ten-row dl including "Worker exit observed: No" for a Ready model and "Next-load profile: Server defaults; see Settings for scope and overrides.", every non-null reason string verbatim ("Family support does not prove this checkpoint was tested.", "No catalog-specific tested-checkpoint evidence is available.", "parameter count is not measured during metadata-only catalog scans", "memory estimate requires backend/provider measurement after load"), the action buttons, "Chat requires a ready provider with an available chat capability. Other tasks remain available through their API.", the removal reason and instructions, a link titled "API task documentation" to docs/llama-server-compat.md, and a capability list rendered as chat · pre_load · Yes. The Source filter offers raw enum values (models_dir, cache); quantization shows "Unknown" for bf16/fp16 checkpoints because quantization_from_config (src/server/webui/catalog_metadata.rs:397-410) reads only quantization and quantization_config.bits and nothing in src/server/webui/ reads torch_dtype; the Task column shows chat, rerank, completion for a reranker.

Current Behavior

ModelsLibrary (screen.tsx:37) builds four DataTableColumn<CatalogEntry> entries (screen.tsx:185-241) and renders the shared DataTable (screen.tsx:412-428) with rowClassName, loading, loadingState and emptyState, no onRowClick and no sortable column; sorting is a separate Sort Select (screen.tsx:399-408) applied by inventory() (webui/src/features/models/policy.ts:82-106). The inspector is the local Inspector primitive (webui/src/design-system/primitives.tsx:142-149, an <aside aria-label="Model details">) in the right column of .models-layout, which collapses to one column below 1100 px (webui/src/features/models/models.css:68-72). StatusBadge (webui/src/design-system/common-adapters.tsx:26-31) already wraps @lablup/ui-common StatusTag with the lifecycle mapping, so the State cell needs no new adapter. Actions flow through execute (screen.tsx:65-81) and load, openAction, confirm (screen.tsx:82-179), which own the stale-revision check, the capacity/eviction confirmation and the busy guard; openAction only acts on selected.

Proposed Solution

Compose the page from #1914 (PageLayout/PageHeader) and the @lablup/ui-common components adopted by #1902, and make the row the unit of work.

  • Rows are one line of about 44 px with columns: Name (the real inference id from fix(webui): catalog display names must keep the checkpoint's real name #1912, rendered as text, no pill), Size (bytes(metadata.disk_bytes) from policy.ts:69, align: 'right', sortValueAccessor on the raw number), Quantization (metadata.quantization ?? metadata.dtype ?? Unknown), Tasks (one Badge per distinct capabilities[].task, in metadata.output_tasks order), State (StatusBadge, mapping unchanged), and a trailing Actions cell. The "Selected" label, the source · quantization line and the Support column leave the row; support and completeness move to the inspector Overview, and an unsupported or incomplete entry shows one warning Badge in the Name cell instead.
  • Actions cell: when canChat is true render Use in Chat (primary tone) and Unload; otherwise render Load, disabled while !canLoad, so a loading row keeps its place and cannot double-submit. Always render an IconButton with accessible name Inspect {display_name} (string models.library.inspect), and for identity.source === 'cache' a danger IconButton Delete gated by canDelete. There is no Menu component in @lablup/ui-common alpha.19 or locally, so icon buttons replace the overflow menu rather than inventing one. Row buttons carry test ids models-row-load, models-row-chat, models-row-unload, models-row-delete; the inspector keeps models-load, models-use-chat, models-unload, models-delete. Every row button calls event.stopPropagation() so the onRowClick handler from fix(webui): make the whole model library row open the inspector #1901 does not also fire. Refactor openAction(kind) to openAction(kind, entry) so row and inspector share one path into load and setConfirmation.
  • Sorting moves onto the table: Name, Size, Quantization and State set sortable: true with sortValueAccessor; the table runs in controlled mode (sortColumnId, sortDirection, onSortChange) so inventory() applies the lifecycle pin first (ready, then loading/draining/unloading, then the rest) and the chosen column within each group. The Sort Select and the models.library.sort* strings are deleted. Search and the Source, Task and Status selects stay; Source and Task options get catalog labels (models_dir becomes "Models directory", cache becomes "Managed cache", tasks use their task labels) instead of raw enum text.
  • Loading state: while state.catalogSequence === null, loadingState is SkeletonRow with count={8}, showActions and loadingLabel from models.library.waiting.
  • Inspector becomes Drawer (width="medium", closeLabel from the catalog) below 1100 px and stays a right pane (Inspector, role="complementary", name "Model details") at or above it, so webui/tests/models.spec.ts:244 keeps passing at wide widths and gets a role="dialog" counterpart at 390 px. Order inside: name, StatusBadge, the four action buttons; Overview dl with source label, architecture, size, quantization or dtype, context (only when runtime.revision === identity.revision), memory (only when memory_estimate_bytes is not null); Capabilities as Badges (task label, warning variant when available is false); then one <details> titled "Details" holding identity.id, inference_id, revision, worker_exit_observed, active_requests, last_error, the NextLoadProfile block, every reason string from metadata.support, unknown_reasons and capabilities[].reason, the tested-checkpoint help, the removal reason and instructions, the disabled bootstrap actions, the configured roots, and the docs/llama-server-compat.md link. Nothing outside that disclosure prints an opaque id, operation id or raw reason string.
  • Remove LibraryOperations and the .models-operations CSS; Activity owns operation history. Keep only what screen.test.tsx:149-181 and 213-258 protect: a download that is in flight, failed or cancelled renders a pseudo-row at the top of the table keyed op:{operation_id} (Name is target.repo_id, State is the download state, ProgressBar in the Size cell, Cancel or Retry in the Actions cell, data-testid="models-operation"); a conflict-failed model_load puts a "Load with eviction" button in that model's Actions cell that opens the existing capacity confirmation; models-pending stays as a role="status" line above the table while pendingReconciliations is non-empty. Succeeded and other terminal operations render nothing here.
  • Header: delete the connectedDetail paragraph and the disabled-actions list from this page. The shell child renders connection.authenticated.detail in the sidebar footer (webui/src/design-system/shell.tsx:104-107); webui/src/app.provider.test.tsx:144,208-213,246, webui/tests/browser-fixtures.ts:175-176 and webui/tests/browser.spec.ts:118 query it by test id and keep passing once it renders there. "Configured roots" becomes the EmptyState action when state.catalog.length === 0 (the existing roots list, help text and copy button inside a Dialog) and a "Roots" item inside the inspector Details; the permanent <details> goes.
  • dtype (server, additive): add dtype: string | null to CatalogMetadata in docs/webui/api.yaml, regenerate docs/webui/generated/ui-api.d.ts and the fixtures under tests/fixtures/webui/, and populate it in catalog_metadata.rs from config.json torch_dtype, null when absent. Nullable and additive, so no schema-version bump. The display-name child edits the same file; land after it and rebase.

Rejected: keeping the actions only in the inspector behind a larger name button, because the contract's primary path must not require opening the inspector; and uncontrolled DataTable sorting, because it would discard the lifecycle pin.

Scope

In scope: webui/src/features/models/screen.tsx, inspector.tsx, operations.tsx (deleted), policy.ts, models.css, strings.ts, tests/fixtures/webui/strings.json (webui/src/main.test.tsx:183-186 asserts the two agree), webui/src/features/models/screen.test.tsx, webui/tests/models.spec.ts, screenshot baselines under webui/tests/screenshots/ plus the SHA256 table in its README, and for the dtype field docs/webui/api.yaml, docs/webui/generated/ui-api.d.ts, tests/fixtures/webui/, src/server/webui/catalog_metadata.rs and docs/webui/catalog.md.

Out of scope: the row-activation handler itself (#1901), the shell, sidebar footer and PageHeader (shell child of #1910), display-name generation (display-name child), string-catalog consolidation and native dialog removal (i18n child), the Activity page (activity child), and any /ui-api/v1 change beyond the nullable dtype field.

Implementation Notes

  • Reuse: DataTable props sortable, sortValueAccessor, sortColumnId, sortDirection, onSortChange, onRowClick, isRowClickable, rowClassName, align (webui/node_modules/@lablup/ui-common/dist/components/DataTable/DataTable.d.ts); StatusBadge, IconButton, ProgressBar, EmptyState from common-adapters.tsx; Badge, Drawer, SkeletonRow through the adapters refactor(webui): adopt every shared @lablup/ui-common component #1902 adds; bytes, canLoad, canUnload, canChat, canDelete, evictionCandidates, inventory from policy.ts; ConfirmAction and AddModel from dialogs.tsx unchanged.
  • Constraints: selecting never loads (models.library.subtitle); the stale-revision, capacity and eviction confirmations in dialogs.tsx stay the only way to unload, delete or evict; 25-row pagination and the filter assertions at screen.test.tsx:199-201 still hold with the new columns; every string goes through t(); no horizontal page scroll at 390 px (models.spec.ts:248), where the table keeps the existing .ds-common-table overflow rule and the Actions cell stays reachable by Tab; axe reports zero violations (models.spec.ts:249); router-real.harness.ts:321 keeps selecting Inspect {name} by role and passes unchanged.
  • Edge cases: disk_bytes null renders "Unknown" and sorts last; a download pseudo-row never collides with a real getRowKey (the op: prefix); a row stays in the table while its model_removal operation is non-terminal and leaves only when the catalog snapshot drops it; single-model mode (state.bootstrap.server.mode === 'single_model') hides Load, Unload and Delete on every row as the inspector does today; focus after Unload from a row lands on that row's Load button; an entry with runtime.revision !== identity.revision shows no context value.
  • Error handling: unchanged paths through execute: failures land in the models-action-error banner, a conflict on load opens the capacity confirmation, a stale server instance sets models.library.stale.

Acceptance Criteria

  • At 1440x900 with the 120-entry fixture from models.spec.ts:230-237, at least 15 table rows are inside the viewport without scrolling.
  • A user reaches Load, Use in Chat and Unload from the row without opening the inspector: a new browser spec drives load, chat navigation and unload through models-row-* buttons only, and fails when the row buttons are removed.
  • The inspector opens from the row (fix(webui): make the whole model library row open the inspector #1901) and from Inspect {name}; models.spec.ts:178,241-244 and router-real.harness.ts:321 pass unchanged.
  • No mdl_ id, op_ id or raw reason string is rendered outside the "Details" disclosure: a unit test renders a ready entry with every reason populated and asserts those strings are absent from the document until the disclosure is opened.
  • The Library operations section, the connectedDetail paragraph and the disabled-actions list are gone from the Models page; the download pseudo-row and capacity recovery keep screen.test.tsx:149-181,213-258 passing after adjustment.
  • A bf16 checkpoint shows bf16 in the Quantization column and the inspector; make verify-webui-contract passes with the new field.
  • Page word count and height at 1440x900 and 390x844 are recorded before and after in the PR body.
  • Integrated into the running bundled binary: load, Use in Chat and unload driven from a row against a real checkpoint, with worker_exit_observed: true after unload.
  • Screenshot baselines regenerated for the variants that visit #models (webui/tests/browser-fixtures.ts:39,41,163) and the README SHA256 table updated.

Verification

pnpm --dir webui run lint
pnpm --dir webui run typecheck
pnpm --dir webui run unit
pnpm --dir webui run browser
pnpm --dir webui run verify-generated
make verify-webui-contract
cargo test --workspace --profile test-fast --features metal,accelerate catalog_metadata

All exit 0. Manual: mlxcel-server --webui --models-dir models/mlx --no-models-autoload, open #models, load qwen3-0.6b-4bit from its row, click Use in Chat, return, click Unload from the row, confirm the lifecycle reaches unloaded; record document.documentElement.scrollHeight and the visible row count at both viewports.

Technical Considerations

Depends on #1901, #1902, and the shell, display-name and i18n children of #1910. The inspector as a Drawer shares the NativeModalContext caveat recorded in #1902; ConfirmAction dialogs are native <dialog> elements that open above a Drawer, so verify focus returns to the drawer after a confirmation closes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions