Skip to content

feat(webui): provider-grouped model catalogue and workspace picker - #26

Merged
fengzhi09 merged 3 commits into
mainfrom
feat/workspace-model-selection
Sep 25, 2026
Merged

fengzhi09 merged 3 commits into
mainfrom
feat/workspace-model-selection

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

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 — WorkspacePanel was a placeholder ("In development" sections, inert buttons); the complete backend endpoints were never called from the webapp. Adds WorkspacePickerModal (antd Modal + Tabs): Recents (server-side recents, debounced search, "no workspace" tmpdir) and Browse (path input, parent nav, glob filter reusing lib/workspace-filter.ts, mkdir, opportunistic native picker). All writes via POST /api/workspace.

Model catalogue — handleGetModels only 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 / cwd models.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 via session/set_config_option on the in-scope ACP client (applyRecordedModel).

Fixes found during acceptance

  • BrowseResult declared path but the server sends dir (pre-existing wrong type on main, masked in FilesPanel by a ?? path fallback) — confirm button permanently disabled, mkdir silently broken. Fixed + wire-shape regression tests on both sides.
  • Browse children now carry isDir so directory rows show folder icons and row-click navigation works (pinned in wire test).
  • current returns null when 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 typecheck 0 errors · pnpm test:webui 1358 pass / 0 fail / 2 skipped · pnpm test:webapp 192/192 · pnpm build ✓ · pnpm check:source 4558 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 verify deferred to CI.

feat-workspace-model-selection agent 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
@fengzhi09
fengzhi09 merged commit 7e27de1 into main Sep 25, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/workspace-model-selection branch September 25, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant