feat(webui): provider-grouped model catalogue and workspace picker - #26
Merged
Merged
Conversation
added 3 commits
September 25, 2026 22:23
Two basic features in the Next.js webapp were broken: the workspace panel was a placeholder (no way to switch workspace), and the model selector was unusable before any session was attached (handleGetModels only read the engine session's configOptions). Port PR #22's approach on the current Next.js/antd stack. Workspace picker - WorkspacePanel now shows the active directory and a 'Switch workspace' button, backed by a new WorkspacePickerModal (antd Modal + Tabs). - Recents tab: lists server-side /api/workspace/recent items, debounced search, and a 'no workspace' button that uses tmpdir. - Browse tab: in-product directory navigator. Path input, parent navigation, allowed-roots view, directory listing (dirs-first + alphabetical), glob filter, create-new-folder via /api/fs/mkdir, and an opportunistic 'Open native picker' button on platforms with a native dialog (zenity/kdialog/osascript/PowerShell). - All writes go through the existing POST /api/workspace endpoint; the picked dir is then visible in the state snapshot via SSE. Model catalogue (server merge + UI grouping) - server/lib/models.js gains getBuiltinModelsFromMcode(), which harvests MiniMax-M* ids from mcode's own dist/cli.js bundle (and sibling chunks/*.js) so the webui catalogue tracks mcode's TUI without a coordinated webui release. The existing context-limit table is kept. - server/routes/model.js#handleGetModels now merges three sources, with the engine session's configOptions authoritative when present: 1. cs.configOptions['model'] (engine-encoded ids, round-trip via /api/set-model); 2. MCODE_WEBUI_MODELS_CONFIG / cwd/models.json providers file; 3. getBuiltinModelsFromMcode() folded into the current-provider group (default: minimax_api). When none of those exist, current falls back to cs.model.name (the recorded pre-session choice) and finally DEFAULT_MODEL. The response keeps for the UI to render per-provider sections, plus the flat list for backwards compat. - webapp/lib/api.ts: ModelsPayload now carries ModelEntry (id/name/ label/provider/source/contextLimit) and ModelGroup. New typed helpers for workspaceTree, pickWorkspaceNative, recentWorkspaces(search,limit). - composer.tsx ModelSelect renders per-provider sections with a thin divider; entries without a provider fall under an 'Other' heading. - composer currentModelLabel resolves through catalogue.label, falling back to the engine's value when no catalogue entry matches. Tests - server: handleGetModels — new 'catalogue merge' describe block covers providers-config+builtin merging, builtin-only, engine-authoritative, config-id winning collisions, and the empty-catalogue fallback. The existing 'no_session_config' test now asserts current reflects the recorded pre-session choice (the documented new contract). - server: getBuiltinModelsFromMcode — returns either a MiniMax-M* array (when dist/cli.js is built) or [], and the cache is reference-stable across calls. - webapp: composer-models.test.ts pins the order-preserving grouping the ModelSelect panel relies on (catalogue order within provider, first-seen order across providers, single 'Other' bucket for provider-less entries). - test/helpers/_setup.js: dispatch-through wrapper for getBuiltinModelsFromMcode so the catalogue-merge tests can flip the builtin list between tests without a second mock.module registration. Gates - pnpm typecheck — 0 errors - pnpm test:webui — 1339 tests, 1337 pass, 2 skipped (Windows-only), 0 fail - pnpm test:webapp — 190 tests, 190 pass, 0 fail - pnpm build — passes - pnpm check:source — passes (4557 files reviewed)
Acceptance pass 2 fixes. Three of the four issues the reviewer flagged
shipped because the wire shape and the type drifted apart without a
regression pin; the fourth is an active-child lifecycle question I
document as out-of-scope rather than attempt.
1. Browse-tab wire mismatch (BLOCKING)
The server's browse response has always carried the current directory
as 'dir' (server/lib/workspace.js#browseWorkspace). The webapp's
BrowseResult type wrongly declared 'path', and WorkspaceBrowseTab
read listing.path on three call sites — confirm stayed disabled
and mkdir was a silent no-op. Fix: rename the type field to 'dir',
switch all picker reads to listing.dir, leave FilesPanel's own
?-path fallback alone (it's reading the user-typed local state, not
the server's wire field). New regression tests pin both ends:
- test/routes/workspace.check.mjs: server response shape (dir +
parent + children, no top-level 'path')
- webapp/test/workspace-picker-wire.test.ts: api.ts declares 'dir',
WorkspaceBrowseTab reads listing.dir.
2. Pre-session model pick never applied at session creation (REQUIRED)
handleSetModel records cs.model.name, but a fresh runMcodeAcp
creates an engine session without forwarding the recorded id —
the engine booted its own default while the chip claimed something
else (engine ran glm-5.3 while the chip showed M2.5). Fix:
applyRecordedModel(client, sid, cs, cid) is called from
runMcodeAcp right after session/new returns. It resolves the
recorded id against the engine's model option (engine-encoded
value as-is, bare name match for the builtin-catalogue form,
null on ambiguous/unknown), then pushes session/set_config_option
directly through the in-scope client — the cid's active-child
registry isn't wired yet at that point, so going through
setConfigOption in mcode-rpc.js would miss every time. The
local configOptions snapshot is updated synchronously and a
pushStateFor mirrors it on the SSE channel.
Tests in test/lib/mcode-acp-note.test.js cover the resolution
helpers (lastSegment, findModelOption, matchesModelId,
resolveModelId) and the apply integration with a fake client:
skips on no pick / engine already on recorded / unknown id,
applies on builtin-catalogue form.
3. current fallback cleanup
Old: handleGetModels reported DEFAULT_MODEL when nothing was
recorded, inventing an active model the engine never confirmed.
The chip then rendered a guessed model id. Fix: return null
instead; composer.tsx currentModelLabel renders the generic
'Model' label for null. Test updated accordingly.
4. Trailing newlines on routes/model.js and lib/models.js.
Mid-session set-model sync (VERDICT: pre-existing artifact, not in scope)
/api/set-model returns mcodeSynced:false 'mcode acp client
unavailable' between turns. Root cause is the active-child
lifecycle: runMcodeAcp registers a per-turn McodeAcpClient as
cid's active child, clears it in finally. Between turns no child
is registered; clientForCid(cid, requireLive:true) returns null;
mcode-rpc.js reports no_client. The singleton fallback would
target a different acp subprocess whose sessions map does not
contain the active session, so the engine's requireAttachedSession
refuses the call anyway (see mcode-rpc.js:55-69 comment block).
Re-using the same subprocess across turns is the fix but a
larger architectural change than this ticket's scope; the chip
keeps the user's choice in cs.model.name so the next turn starts
on the right model. Documented here for the record; acceptance
agent should know this is the persistent 'between turns' limit,
not a regression introduced by this branch.
Gates
- pnpm typecheck — 0 errors
- pnpm webapp:typecheck — 0 errors
- pnpm test:webapp — 192 / 192 / 0
- pnpm test:webui — 1361 tests, 1358 pass, 0 fail, 2 skipped,
1 cancelled (mcode-acp-note.test.js file-level singleton cleanup
race pre-existing on main; with --test-force-exit all 85 tests
in the affected files pass)
- pnpm build — passes (6253 source files)
- pnpm check:source — passes (4558 files)
Acceptance round 3 (post-merge concern): /api/workspace/browse
children only carried {name, path}, so the WorkspaceBrowseTab row
click — gated on 'entry.isDir && setPath(...)' — never fired, and
row-click navigation was dead. The user could still navigate via the
path input, up, home, and allowed-roots buttons, but every directory
row also rendered with the file icon (FilesPanel-style) rather than
the folder one.
Verified: browseWorkspace in server/lib/workspace.js only emits
directory entries (the inner 'if (ent.isDirectory())' filter at the
push site). Every child is therefore a directory; setting isDir: true
is a constant, not a per-entry computation. Setting it explicitly is
cheaper than reshaping the BrowseEntry type to make the field
optional, and it pins the wire ↔ type agreement.
Fix:
- server/lib/workspace.js#browseWorkspace: emit 'isDir: true' on each
child entry, with a comment that explains why the constant is set
rather than derived.
- BrowseResult.children[] in webapp/lib/api.ts already declares
BrowseEntry with 'isDir: boolean' — no type change needed.
- test/routes/workspace.check.mjs: the existing 'browse wire shape'
test now also asserts 'body.children[0].isDir === true', with a
comment explaining why the picker row click depends on it.
Out of scope: panel rendering (folder vs file glyph), which already
uses the now-correct isDir flag.
Gates
- pnpm typecheck — 0 errors
- pnpm webapp:typecheck — 0 errors
- pnpm test:webapp — 192 / 192 / 0 fail
- test/routes/workspace.check.mjs — 11 pass / 1 skipped (Windows-only) /
0 fail
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.
What
Ports the two broken basic features (workspace directory selection, model & provider selection) following PR #22's approach, reimplemented in the Next.js/antd stack.
Workspace picker —
WorkspacePanelwas a placeholder ("In development" sections, inert buttons); the complete backend endpoints were never called from the webapp. AddsWorkspacePickerModal(antd Modal + Tabs): Recents (server-side recents, debounced search, "no workspace" tmpdir) and Browse (path input, parent nav, glob filter reusinglib/workspace-filter.ts, mkdir, opportunistic native picker). All writes viaPOST /api/workspace.Model catalogue —
handleGetModelsonly read the engine session's ACP config option, returning an empty list (no_session_config) before any session existed. Now merges three sources, grouped by provider: engine session config (authoritative when present, engine-encoded ids round-trip through/api/set-model) → providers config (MCODE_WEBUI_MODELS_CONFIG/ cwdmodels.json) → builtin catalogue extracted from mcode's own cli.js bundle (never hardcoded). Pre-session picks are recorded (cs.model.name) and applied at session creation viasession/set_config_optionon the in-scope ACP client (applyRecordedModel).Fixes found during acceptance
BrowseResultdeclaredpathbut the server sendsdir(pre-existing wrong type on main, masked in FilesPanel by a?? pathfallback) — confirm button permanently disabled, mkdir silently broken. Fixed + wire-shape regression tests on both sides.isDirso directory rows show folder icons and row-click navigation works (pinned in wire test).currentreturnsnullwhen nothing is recorded (no invented active model); composer renders a neutral label.Known limitation (follow-up ticket)
Mid-session model switching records the choice for the next turn but cannot sync to the live engine: the per-turn ACP subprocess is reaped between turns and the singleton fallback never attached the session. Fix requires a persistent per-cid subprocess — architectural change, out of scope here. Verified as pre-existing on main.
Verification
Gates (fresh, acceptance agent re-ran independently):
pnpm typecheck0 errors ·pnpm test:webui1358 pass / 0 fail / 2 skipped ·pnpm test:webapp192/192 ·pnpm build✓ ·pnpm check:source4558 files ✓Live browser verification (isolated instance, 3 acceptance rounds): full browse → mkdir → switch flow; recents switch; no-session grouped catalogue incl. bundle-extracted M2.5/M2.1/M2; pre-session pick applied to the engine (
m:minimax:MiniMax-M3:v:, chip agrees); unrepresentable picks skip with a logged reason; regression on chat/sessions/panels clean.Full
pnpm verifydeferred to CI.