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
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