Skip to content

feat(webui-react): four-column workspace UI, tabbed right sidebar, session isolation [draft] - #22

Draft
fengzhi09 wants to merge 15 commits into
mainfrom
feat/webui-react-modular
Draft

fengzhi09 wants to merge 15 commits into
mainfrom
feat/webui-react-modular

Conversation

@fengzhi09

@fengzhi09 fengzhi09 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

DRAFT / WIP — do not merge. 并行多会话生成与主/子 agent 隔离仍在进行中。

Summary

  • Four-column workspace UI: resizable left nav, chat area with session title bar, right sidebar with tab pages (files / preview / built-in browser / git / details)
  • Session isolation (run mirror): engine output is keyed by the owning session so switching sessions mid-run no longer cross-contaminates chats; finalize flushes buffered lines back to the owning session; per-view running indicator
  • Stop reliability: child registry keyed per session (cid|mvs / cid|uuid / latest) with fallback lookup chain
  • Session create/switch fixes: stale-closure chat leak that made new sessions show another session's messages
  • Send/cmd guards: 409 when a session (or any session, engine is single-instance) is still generating
  • Workspace picker: native-style directory browser (path input, glob filter, size/mtime/mode table, new folder), single-confirm inline delete (4 clicks → 2)
  • Right sidebar extras: built-in browser panel (sandboxed iframe over /api/fs/raw), git panel (status/branches/diff/checkout via git CLI), file tree context menu (copy path, open in system/file manager, open in browser, preview)
  • Per-message timestamps (YY/MM/dd HH:mm:ss, '--' for history), assistant action row, run status row
  • New endpoints (containment-gated): GET /api/fs/raw, GET+POST /api/fs/file+open, GET/POST /api/git/status|branches|diff|checkout

Verification

  • typecheck 0 errors; vitest 134/134 (16 files) incl. new files-panels contract tests
  • pnpm check:source passed (inventory 4678 paths); build green; deployed to the dev box and smoke-tested E2E (new-session isolation, mid-run switching, stop, concurrent-view checks)

Known follow-ups (why this is a draft)

  • Concurrent generation in multiple sessions needs explicit chat-target routing inside the ACP stream (currently guarded by a global 409 on send)
  • Sub-agent output isolation semantics TBD

McodeAcpClient.alive was never defined, so getMcodeAcpClient() never reused

the cached client: every 30s poll constructed a new one and spawned another

cli.js acp chain while the previous singleton was overwritten and never

stopped. Define alive and stop the old instance before dropping the

reference.
…sed seams

Rewrites the vanilla single-page web UI as @mavis/webui-react (React 18 +

Ant Design 5 + TypeScript strict + Vite), 1:1 against packages/webui/public.

Layering (enforced by scripts/check-layering.mjs, 0 violations):

  contracts/  wire protocol + domain types + port interfaces (the only seam)

  core/       port implementations, zero UI dependencies

  ui/         dumb components, props in / events out, co-located CSS

  features/   the single layer that sees both core and ui

Session isolation: every SessionId gets its own SessionSlice (messages,

streaming buffer, context, per-session provider/model/thinking selection),

so switching sessions never bleeds state into another.

New capability: a three-stage ModelPicker (provider -> model -> thinking

effort off/low/medium/high/max), persisted per session through

ModelServicePort and hot-swappable via replacePort().

Hot-swappable: all ports are injected through createRegistry(overrides) /

replacePort(); the antd NotifierPort replaces the console default at runtime.

Verified: tsc 0 errors, 99/99 vitest cases, vite build green,

check:source and check:tsconfig pass.
The webui-react exemption insertion split the existing packages/webui

comment across two lines. Restore one coherent comment per exemption.
Three defects found by actually running the bundle:

1. Infinite render loop (React MiniMax-AI#185, crashed on load). app-controller

   snapshot() built a fresh object per call, but useSyncExternalStore

   requires getSnapshot to return the same reference while state is

   unchanged. Now memoized and invalidated on notify(). Locked by

   test/snapshot-stability.test.ts.

2. /react/ was 404. Two halves: serveStatic only accepted files (a

   directory fell through) and the static route matched only paths

   containing a dot, so /react/ never reached it. Directories now fall

   back to index.html and the matcher also accepts trailing slashes.

   Locked by packages/webui/test/router-static-dir.test.js (5 cases).

3. GET /api/usage 404: the router registers POST only. usage-service

   now posts.

Also lands the fix round: App.tsx split into six feature containers,

all modals/SlashOverlay/WorkspacePicker wired, per-session input drafts,

search filtering, slash-command routing, delete confirmation, vendor

chunk splitting (main chunk 492 kB -> 106 kB), and hot-swap ports holder.

Verified: webui 1397 pass / 0 fail, webui-react 112/112, tsc 0 errors,

check-layering 81 files 0 violations, check:source 4648, vite build green.
…inal

Per decision: the original vanilla SPA is archived, running default is the

new version.

- serveIndex() now prefers packages/webui/public/react/index.html and falls

  back to the vanilla shell only when the build output is missing (fresh

  clone before build still opens). serveLegacyIndex() serves the archived

  vanilla shell.

- /legacy, /legacy/, /legacy/index.html route to the archive. The vanilla

  files are NOT moved or renamed: public/index.html + app/ + styles/ stay

  where they are (moving them would create source-sync conflicts per

  AGENTS.md) and their root-absolute asset paths keep working under /legacy/.

- token-gate assertions updated: isIndex now means "an app shell" (React or

  archived vanilla) because those tests are about the gate, not the shell.

  Two new cases lock the default/archive split.

Verified in a real browser against the running server (not just unit tests):

  /         -> rootKids=1 topbar=true react=true   (React shell mounted)

  /legacy/  -> vanilla=true textLen=419            (archived original)

Vision readback of the screenshots confirms no \uXXXX garbled escapes and

no layout collapse; every vanilla string renders (只读/READ ONLY, 新建会话,

会话列表, 还没有消息..., 输入消息...). This also confirms the earlier JSX

unicode-escape fix at the pixel level.

Verified: webui 1399 pass / 0 fail (1401 total), webui-react 122/122,

check:source 4655, tsc 0 errors, check-layering 81 files 0 violations.
…tory

pnpm verify --profile full failed at check:source on every platform:

  Error: Source inventory changed; review before updating.

  Missing: .verify-shots/*.png

Root cause: I created .verify-shots/ INSIDE the repository to stage browser

screenshots for visual verification, then ran source-inventory.mjs --write,

which recorded those four PNGs into release/public-source.json. Adding the

directory to .gitignore kept the PNGs out of the commit but NOT out of the

inventory: source-inventory walks the working tree with readdirSync and does

not consult .gitignore. CI checks out a tree without the PNGs, so the

inventory pointed at files that do not exist.

This is the hazard AGENTS.md calls out explicitly ("the inventory scans the

working tree, including untracked files outside its explicit exclusions") and

that the repo-contract agent warned about. Verification artifacts belong

outside the repository.

- Remove the four stale inventory entries (4655 -> 4651 paths). The PNGs are

  moved to /tmp, out of the tree.

- Revert the .gitignore line I added: it institutionalises putting scratch

  dirs in-repo and, worse, gives false confidence that they are excluded.

- No new inventory exclusion for .verify-shots: an ad-hoc directory name is not

  a principled skip rule and would mask real files.

Verified locally with the exact CI command before pushing:

  pnpm verify --profile full -> check:source PASS (4651 files),

  check:tsconfig PASS (126 exports), export source preview PASS.
CI red on ubuntu/macos (windows was already green after the inventory fix):

  ✖ 根路径默认是 React 新版外壳

  ✖ 原版归档在 /legacy/,两个入口都能打开

  ✖ serveStatic: 目录入口回落到 index.html

  ✖ serveStatic: 目录入口(无尾斜杠)同样回落

  ✖ serveStatic: 具体文件不受影响

Root cause: the five tests I added assert against packages/webui/public/react/,

which is the vite build output and is gitignored. CI runs test:webui straight

after pnpm install --frozen-lockfile without building webui-react first, so the

artifact simply does not exist there. Green locally only because I had built it.

That is a hidden test->build-artifact coupling, and it is my bug.

Decouple rather than make CI build first (which would only mask it and slow the

gate down):

- serveStatic / serveIndex / serveLegacyIndex take an optional root defaulting

  to PUBLIC_DIR. Same seam discipline as the ports in webui-react.

- router-static-dir.test.js now builds a throwaway fixture tree in os.tmpdir()

  and passes it in, so the routing and fallback LOGIC is asserted deterministically

  with or without a build. 8 cases, covering both branches of serveIndex.

- The two router-auth-gate assertions now state the real contract instead of

  pinning one branch: artifact present -> React shell, absent -> vanilla shell,

  and in both cases the root must be a reachable app shell. /legacy/ stays vanilla.

Also fixes a duplicate `join` import that broke the whole check file at load.

Verified: webui 1402 pass / 0 fail (1404 total), webui-react 122/122,

check-layering 81 files 0 violations, check:source 4651.

Known remaining flake (pre-existing, out of scope): upload-limits can fail with

`write EPIPE` when it races the socket; it passed locally this run and failed in

the previous CI run. packages/tui test:capabilities also fails on linux-arm64 only

(Unsupported MCode update host: linux-arm64) and is untouched by this PR.
…space browse

Four functional defects reported against the running UI. All verified end to

end in a real browser against the served bundle (Playwright, 11/11).

1. Switching sessions showed stale/blank chat. There was no wire-state

   ingestion at all: app-controller never opened /api/stream, so messages

   never arrived and every session rendered an empty slice. Adds the full

   chain -- /api/stream connection, GET /api/state baseline, state.snapshot

   deltas -- plus session-service.chatLinesToMessages (mirrors vanilla

   render.js#parseChatLines prefix grammar: > / bullet / triangle / circle /

   check / Plan: / Ask:..., with Plan and Ask flattened to text so hydrating

   a history cannot trigger a modal) and hydrateFromWireState. Hydration writes

   into the slice named by state.sessionId and never touches another session,

   so isolation holds. switchTo now also hydrates from the {session:{id,chat,

   title}} response so content is visible the moment you switch.

   This also closes the needs_authorization gap: those control frames now reach

   the client, so gated deletes surface AuthModal instead of silently timing

   out after five minutes and declining.

2. Config-panel toggles did nothing. ToggleSwitch rendered the real input at

   0x0 and transparent, with the visible slider on a sibling span carrying no

   events, so clicks landed on dead pixels. Root is now a label with the input

   stretched over the hit area.

3. Workspace directory picker could not descend. /api/workspace/browse child

   entries omitted isDir, so every entry looked like a file. Server now sends

   isDir explicitly and the core adapter treats a missing flag as a directory.

4. New session failed with "missing id". The server answers {session:{id}}

   while create() read res.id; both shapes are accepted now.

Also: toSummary falls back to createdAt for shadow rows of state.sessions.

Verified: tsc 0 errors, vitest 126/126 (4 new isolation regressions appended

to test/app-controller.test.ts), check-layering 81 files 0 violations,

vite build green, and Playwright 11/11 against the served bundle -- including

switch A -> B -> A round-tripping byte-identical content (412716 chars), which

is the session-isolation property this rewrite exists for.
POST /api/usage is fire-and-forget (responds {ok:true} immediately and lands
the query result later via a state push), so quota() parsed empty values from
its body. Read the authoritative usage snapshot from GET /api/state instead.
…atalog

- applyWireState now merges the flattened settings fields carried by

  /api/state and state.snapshot pushes, so settings toggles stay in sync

  across tabs and external changes (POST /api/settings broadcasts state).

- The slash command panel now lists the server availableCommands

  catalog (local + mcode) instead of a hardcoded approximation.

- Surface auth-declined/network errors for delete/rename/reset-token as

  toasts instead of unhandled promise rejections.

Verified: Playwright E2E 32/32 against the served bundle (LAN/readOnly/

Token toggles, token reset deny path, appearance/language, usage popover,

workspace browse descent + pick, slash panel + command echo, rename, delete

with auth approve), tsc 0, vitest 126/126, check-layering 0.
…on mode

The plan/plan-mode answers never had a working channel. The vanilla UI
posts POST /api/answer, but the server treated it as a legacy no-op, so
approving or rejecting a plan silently did nothing; the React port left
its modals unwired for the same reason.

Server: /api/answer is now the real state channel. type=plan clears
cs.plan and cs.enterPlanMode; type=planmode sets cs.planMode and clears
cs.enterPlanMode; type=permission stays an ack because mcode fixes the
permission mode at launch. Each applied answer pushes fresh state to the
client, so the modal closes from the authoritative snapshot. The
follow-up prompt for agree/add stays the client's job via /api/send --
the client localizes the answer text; the server never invents copy.

React: new InteractServicePort (answerPlan / answerPlanMode /
permissionModes / setPermissionMode) with an HTTP-backed default, wired
into the registry and AppActions. Wire state now flows into the model:
SessionSlice gains plan (from plan_update), hydration maps state.plan and
state.goal into the slice, and AppSnapshot exposes enterPlanMode and
permissionLabel. PlanModal is driven by the live wire plan (falls back to
the pending chat block for history) and PlanModeModal by
state.enterPlanMode.active; both close from the server snapshot after
answering. Plan agree/add sends a localized follow-up message so the
decision actually reaches the model.

Permission mode: gap #9 closed. The picker gains the missing Read tier
(matching the server's 4-mode catalog), seeds from state.permissions, and
syncs changes through POST /api/permissions with an error toast on
failure.

Verified: tsc 0 errors, vitest 127/127 (new interact delegation
regression), check-layering 82 files 0 violations, source inventory
regenerated for core/services/interact-service.ts (4652 files).
… before thinking

The picker exposed a flat provider list then a per-provider model list,
which made users hunt through two levels to reach the actual model. The
catalog is now hierarchical the way the backend can serve it:

Server: GET /api/models accepts an optional models.json (env
MCODE_WEBUI_MODELS_CONFIG, default <cwd>/models.json) whose providers[]
entries each carry a labeled model list, and returns them as groups[]
alongside the legacy flat models field. mcode built-in models merge into
the current provider group, de-duplicated by full id (config wins). The
config file is re-read per request, so editing it needs no restart.

React: ModelPicker drops its provider stage -- stage one is now the
grouped model list (provider group headers, current model checked by
full id or bare name), stage two stays the five thinking efforts,
matching the requested pick-model-first order. ModelService parses
groups[] and falls back to deriving groups from the flat catalog for
older backends; AppSnapshot carries modelGroups.

Verified: tsc 0 errors, vitest 125/125 (picker tests rewritten for the
two-stage contract), node --check on the route.
…roll

Three defects from the running UI:

[completed] spam in the chat. The server writes each tool call as a
-> toolName block followed by indented lines: a [completed]/[failed]/
[in_progress] status line, output, @ local paths, ! errors. Vanilla
parses those into a tool block with status; the React grammar flattened
the whole thing into plain assistant text, so every tool call printed a
raw "[completed]" line. chatLinesToMessages now consumes the indented
continuation into a native tool-call block (status mapped to
running/done/error, first output lines + path/error counts as summary),
matching render.js parseChatLines.

Thinking chain blew up the conversation when expanded. The block is now
three-state: folded by default, first open shows a fixed 5-line capped
preview (scrollable), and explicit expand-full/collapse buttons move
between preview and full text.

Autoscroll stalled mid-stream. The follow effect set scrollTop once per
message change, but streaming chunks keep growing scrollHeight after the
first layout (markdown/highlight), so the view stopped halfway and read
as "not following". It now scrolls pre-paint and again on the next
animation frame while stick-to-bottom is engaged.

Verified: tsc 0 errors, vitest 125/125 (thinking block test rewritten
for the three-state contract).
Clicking "select directory" with no active workspace asked the server to
browse without a path. The no-path branch returned dir="/" with an empty
children array (vanilla renders the roots array as top-level nodes; the
React picker renders children only), so the picker opened an empty list
and, with cwd empty, the select action could not proceed either -- a
dead end the user reported as a broken directory dialog.

When no path is given, list the first existing allowed root instead and
prepend the remaining roots as synthetic directory entries; roots is
still returned for the vanilla rendering. Server-side now serves 86
usable entries for /home/acer09; verified in the browser that the picker
opens a populated list and descends.
…ssion isolation

Client (packages/webui-react):
- Four-column layout: resizable left nav sidebar, chat area with session
  title bar, tabbed right sidebar (files/preview/browser/git/details)
- Global icon system + reusable primitives (IconButton, ResizeHandle,
  AlertsBell) extracted from duplicated inline markup
- Per-message timestamps (YY/MM/dd HH:mm:ss, '--' for history), assistant
  message action row, run status row (elapsed + token/s)
- File tree context menu (copy path, open in system/file manager, built-in
  browser, preview); markdown renderer shared with doc preview
- Workspace picker: native-style browser (path input, glob filter, table
  with size/mtime/mode, new folder), single-confirm inline delete
- Session switch no longer locked while generating: per-session run mirror
  keeps engine output isolated; snapshots pin the viewing session's chat
- New-session stale-chat leak fixed (single-source chat backing store)

Server (packages/webui):
- GET /api/fs/raw, GET+POST /api/fs/file+open (containment-gated)
- git status/branches/diff/checkout via git CLI (lib/git.js)
- Per-session child registry for reliable per-session stop

Known follow-ups: concurrent multi-session generation needs explicit chat
target routing in the ACP stream; sub-agent output isolation semantics TBD
@fengzhi09
fengzhi09 marked this pull request as draft September 25, 2026 09:54
@fengzhi09 fengzhi09 changed the title feat(webui-react): modular React + Ant Design Web UI with port-based seams feat(webui-react): four-column workspace UI, tabbed right sidebar, session isolation [draft] Sep 25, 2026
fengzhi09 added a commit that referenced this pull request Sep 25, 2026
* feat(webui): provider-grouped model catalogue and workspace picker

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)

* fix(webui): workspace picker wire mismatch and pre-session model apply

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)

* fix(webui): workspace picker rows carry isDir so click navigates

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

---------

Co-authored-by: feat-workspace-model-selection agent <agent@mcode-webui.local>
fengzhi09 added a commit that referenced this pull request Sep 26, 2026
#32)

Port PR #22's run-mirror semantics to the current architecture so a
mid-run session switch never leaks live engine lines into another
session's view or persisted record (ticket session-isolation/02).

An earlier partial implementation on this branch introduced the
per-(cid, engine sid) runChat buffer family in state-bus.js and routed
all six stream-write sites in streamAcpPrompt through r.chatArray().
This commit completes the port after review; the buffer family and the
always-buffer write routing were kept, the following gaps were fixed:

- Live view: with every write landing in the buffer, the wire snapshots
  still shipped cs.chat only, so the owning session's own view never
  saw its turn stream. state-bus now exposes runChatViewChat /
  snapshotViewFields and every snapshot builder (three SSE push sites,
  /api/state, the SSE first frame, the switch response) re-attaches the
  buffer for the owning view and scopes the run indicator to the viewed
  session: a foreign turn renders as idle in the switched-to view, and
  the owning view keeps its running claim even after a switch's
  resetContext healed it (projected from the run registry, no cs
  mutation).
- Finalize clobbers: streamAcpPrompt's finalize re-pointed
  cs.mcodeSessionId and wrote the engine binding + title through
  whichever record cs pointed at after a mid-run switch, redirecting
  the switched-to session onto the run's engine conversation. The
  turn's owning webui record id is now captured at send time and passed
  through runMcodeAcp; all cs mutations are gated on still viewing the
  owning session, and the record-side binding/title writes address the
  owning record by id.
- Draft promotion: bindDraftToMcodeSid promoted records through cs and
  would rename or merge the switched-to session's record when the
  engine session id arrived after a switch. A pre-bind switch now uses
  the new bindRecordToMcodeSid helper (promoteDraftToMcodeSid targeted
  at a record id, no cs access).
- Finalize drain: handleSend drains the buffer after the run settles —
  appending to cs.chat when the user still views the owning session
  (the ● rewrite then lands on the drained line) or writing the lines
  to the owning session's persisted record via the new
  appendChatToSession helper when they switched away; the viewed
  session's chat and record stay clean either way. Failed and empty
  turns flush the same way.
- createRunChat now replaces the cid's buffer map: beginRun allows one
  live turn per cid, so a session/load fallback that re-keys the buffer
  cannot leave a stale entry behind.

Tests: new routes/chat-run-mirror.check.mjs drives the real chat.js →
runMcodeAcp → sessions.js → state-bus chain plus the real switch route
against a fake ACP transport and pins mid-run switch isolation, view
merging, record attribution, and first-turn draft promotion under a
pre-bind switch. stream-cumulative-render.test.js — whose three
failures were caused by the new buffer routing — now reads the routed
lines through runChatLinesFor. helpers/_setup.js mirrors the three new
sessions.js exports. Regenerated release/public-source.json for the
new test file.

Verified live on an isolated instance (server 18098 + web 18099,
MCODE_WEBUI_DATA_DIR in a temp dir, fake slow multi-tool engine):
session A streaming → switch to B mid-run shows no A lines and an idle
indicator; switching back shows the record plus the live buffered lines
with the running indicator; after completion the full turn persisted to
A's record (B stayed at zero lines).

Gates: pnpm typecheck (0 errors), webui suites (190 relevant tests ×3
runs green; full suite 1258 pass with only lib/mcode-acp-note.test.js
hanging at teardown — reproduced on clean HEAD, environmental), pnpm
test:webapp (213 pass), pnpm build.
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