Conversation
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.
This was referenced Sep 24, 2026
…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
marked this pull request as draft
September 25, 2026 09:54
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.
Summary
Verification
Known follow-ups (why this is a draft)