Repository navigation
feat(webui): redesign the model library as a dense, task-first list with row actions #1918
Description
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)frompolicy.ts:69,align: 'right',sortValueAccessoron the raw number), Quantization (metadata.quantization ?? metadata.dtype ?? Unknown), Tasks (oneBadgeper distinctcapabilities[].task, inmetadata.output_tasksorder), State (StatusBadge, mapping unchanged), and a trailing Actions cell. The "Selected" label, thesource · quantizationline and the Support column leave the row; support and completeness move to the inspector Overview, and an unsupported or incomplete entry shows onewarningBadgein the Name cell instead. - Actions cell: when
canChatis 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 anIconButtonwith accessible nameInspect {display_name}(stringmodels.library.inspect), and foridentity.source === 'cache'a dangerIconButtonDelete gated bycanDelete. There is no Menu component in@lablup/ui-commonalpha.19 or locally, so icon buttons replace the overflow menu rather than inventing one. Row buttons carry test idsmodels-row-load,models-row-chat,models-row-unload,models-row-delete; the inspector keepsmodels-load,models-use-chat,models-unload,models-delete. Every row button callsevent.stopPropagation()so theonRowClickhandler from fix(webui): make the whole model library row open the inspector #1901 does not also fire. RefactoropenAction(kind)toopenAction(kind, entry)so row and inspector share one path intoloadandsetConfirmation. - Sorting moves onto the table: Name, Size, Quantization and State set
sortable: truewithsortValueAccessor; the table runs in controlled mode (sortColumnId,sortDirection,onSortChange) soinventory()applies the lifecycle pin first (ready, then loading/draining/unloading, then the rest) and the chosen column within each group. The SortSelectand themodels.library.sort*strings are deleted. Search and the Source, Task and Status selects stay; Source and Task options get catalog labels (models_dirbecomes "Models directory",cachebecomes "Managed cache", tasks use their task labels) instead of raw enum text. - Loading state: while
state.catalogSequence === null,loadingStateisSkeletonRowwithcount={8},showActionsandloadingLabelfrommodels.library.waiting. - Inspector becomes
Drawer(width="medium",closeLabelfrom the catalog) below 1100 px and stays a right pane (Inspector,role="complementary", name "Model details") at or above it, sowebui/tests/models.spec.ts:244keeps passing at wide widths and gets arole="dialog"counterpart at 390 px. Order inside: name,StatusBadge, the four action buttons; Overviewdlwith source label, architecture, size, quantization or dtype, context (only whenruntime.revision === identity.revision), memory (only whenmemory_estimate_bytesis not null); Capabilities asBadges (task label,warningvariant whenavailableis false); then one<details>titled "Details" holdingidentity.id,inference_id, revision,worker_exit_observed,active_requests,last_error, theNextLoadProfileblock, every reason string frommetadata.support,unknown_reasonsandcapabilities[].reason, the tested-checkpoint help, the removal reason and instructions, the disabled bootstrap actions, the configured roots, and thedocs/llama-server-compat.mdlink. Nothing outside that disclosure prints an opaque id, operation id or raw reason string. - Remove
LibraryOperationsand the.models-operationsCSS; Activity owns operation history. Keep only whatscreen.test.tsx:149-181and213-258protect: a download that is in flight, failed or cancelled renders a pseudo-row at the top of the table keyedop:{operation_id}(Name istarget.repo_id, State is the download state,ProgressBarin the Size cell, Cancel or Retry in the Actions cell,data-testid="models-operation"); a conflict-failedmodel_loadputs a "Load with eviction" button in that model's Actions cell that opens the existing capacity confirmation;models-pendingstays as arole="status"line above the table whilependingReconciliationsis non-empty. Succeeded and other terminal operations render nothing here. - Header: delete the
connectedDetailparagraph and the disabled-actions list from this page. The shell child rendersconnection.authenticated.detailin 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-176andwebui/tests/browser.spec.ts:118query it by test id and keep passing once it renders there. "Configured roots" becomes theEmptyStateaction whenstate.catalog.length === 0(the existing roots list, help text and copy button inside aDialog) and a "Roots" item inside the inspector Details; the permanent<details>goes. - dtype (server, additive): add
dtype: string | nulltoCatalogMetadataindocs/webui/api.yaml, regeneratedocs/webui/generated/ui-api.d.tsand the fixtures undertests/fixtures/webui/, and populate it incatalog_metadata.rsfromconfig.jsontorch_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:
DataTablepropssortable,sortValueAccessor,sortColumnId,sortDirection,onSortChange,onRowClick,isRowClickable,rowClassName,align(webui/node_modules/@lablup/ui-common/dist/components/DataTable/DataTable.d.ts);StatusBadge,IconButton,ProgressBar,EmptyStatefromcommon-adapters.tsx;Badge,Drawer,SkeletonRowthrough the adapters refactor(webui): adopt every shared @lablup/ui-common component #1902 adds;bytes,canLoad,canUnload,canChat,canDelete,evictionCandidates,inventoryfrompolicy.ts;ConfirmActionandAddModelfromdialogs.tsxunchanged. - Constraints: selecting never loads (
models.library.subtitle); the stale-revision, capacity and eviction confirmations indialogs.tsxstay the only way to unload, delete or evict; 25-row pagination and the filter assertions atscreen.test.tsx:199-201still hold with the new columns; every string goes throught(); no horizontal page scroll at 390 px (models.spec.ts:248), where the table keeps the existing.ds-common-tableoverflow rule and the Actions cell stays reachable by Tab; axe reports zero violations (models.spec.ts:249);router-real.harness.ts:321keeps selectingInspect {name}by role and passes unchanged. - Edge cases:
disk_bytesnull renders "Unknown" and sorts last; a download pseudo-row never collides with a realgetRowKey(theop:prefix); a row stays in the table while itsmodel_removaloperation 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 withruntime.revision !== identity.revisionshows no context value. - Error handling: unchanged paths through
execute: failures land in themodels-action-errorbanner, aconflicton load opens the capacity confirmation, a stale server instance setsmodels.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-244androuter-real.harness.ts:321pass 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
connectedDetailparagraph and the disabled-actions list are gone from the Models page; the download pseudo-row and capacity recovery keepscreen.test.tsx:149-181,213-258passing after adjustment. - A bf16 checkpoint shows
bf16in the Quantization column and the inspector;make verify-webui-contractpasses 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: trueafter 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_metadataAll 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
refactor(webui): adopt every shared @lablup/ui-common component (#1924)
fix(webui): make row re-selection a no-op and harden row delegation
fix(webui): open the model inspector from anywhere in a library row (#…
feat(webui): show loaded models and compose routes on the app shell (#…
fix(webui): close review gaps in library rows and the inspector drawer
chore(webui): regenerate the embedded bundle for the review fixes
fix(webui): keep Tab inside the inspector drawer and tidy review left…
chore(webui): regenerate the embedded bundle for the drawer Tab fix
fix(webui): report only recognized weight dtypes and trim library re-…
chore(webui): regenerate the embedded bundle for the dtype and render…
test(webui): cover the Quantization dtype fallback and task ordering
docs(webui): note float64 in the declared dtype normalization list
Merge origin/main into feature/issue-1918-model-library-redesign
fix(webui): fit fifteen library rows at 1440x900 on every platform
Merge origin/main into feature/issue-1918-model-library-redesign
fix(webui): harden the models drawer and row focus after review
fix(webui): hide a zero context for an unloaded model in the inspector
feat(webui): redesign the model library as a dense list with row acti…