Feat/webui nextjs framework - #18
Conversation
ad2c268 to
ab09dcc
Compare
modacker
left a comment
There was a problem hiding this comment.
Summary
This is a net-positive cleanup, not a "屎山". The Hono migration uses createResponseCapture to reuse (req, res, ctx) handlers without rewriting them, the 5-gate chain is shared between Hono and the legacy router, and the cancel-as-notification fix is correct end-to-end. Three concrete findings below are worth fixing before merge.
Findings (actionable)
1 · ownsRequest rebuilds the entire Hono app on every request — performance regression
packages/webui/server/bootstrap.js:101 calls ownsRequest(method, pathname) without a third argument, and app.js:154-158 falls back to createHonoApp():
export function ownsRequest(method, pathname, app = null) {
const a = app ?? createHonoApp(); // re-registers ~47 routes per request
...
}createHonoListener() (line 501-503) already built a Hono app and closed it over. A Hono-owned request therefore triggers two full Hono constructions: one for the dispatch decision, one for the actual serve. The sidebar's ?refresh=1 and per-state-push polling make this non-trivial.
Fix: hoist the app to module scope (const app = createHonoApp()) and pass it into both createHonoListener and ownsRequest. Or have createHonoListener return { app, listener }.
2 · POST /api/protocol/cancel returns fallback: "hard_kill" but never hard-kills
packages/webui/server/routes/protocol.js:130-141:
const r = await cancelSession(sessionId);
if (!r.ok) {
return respond(res, 200, {
ok: true,
cancelled: false,
warning: r.error,
code: r.code,
fallback: "hard_kill", // nothing actually kills here
});
}Only the notification was attempted. The actual gentle-then-SIGKILL cascade with the 2s grace window lives in chat.js#handleStop at /api/stop (packages/webui/server/routes/chat.js:243-289). A client UI that calls /api/protocol/cancel for "stop" reads fallback: "hard_kill" and may stop polling while the child keeps running.
Fix: rename the field to something honest (note: "notification_failed_run_api_stop_for_kill"), or chain this endpoint into the same SIGKILL path.
3 · No integration test against a real engine for the ACP changes
The headline behaviour fixes — session/cancel as notification instead of request, set_mode/set_config_option wire-field renames (modeId/configId not mode/key), the fork/resume/activate routes — are all verified against mocks. If packages/tui/src/acp/agent.ts ever registers session/cancel as a request again, this PR's tests won't catch it.
The PR description itself flags: "No live-service acceptance. Every check above is offline. packages/webui/test/** mocks the ACP client, so the ACP changes are covered at the protocol boundary, not against a running engine."
Fix: a smoke harness spawning mcode acp --mock-stub (or whatever test stub exists) and asserting the wire shape end-to-end.
Known / acknowledged gap (not blocking)
4 · mcode-session-delete.js hardcodes 32 local_runtime_* tables
packages/webui/server/lib/mcode-session-delete.js:24-62. The PR description flags this explicitly as "schema knowledge the webui does not own and cannot keep correct." The fix belongs on the engine side as mcode/session/delete; out of scope here.
Two consequences worth tracking:
- When mcode adds a new
local_runtime_*table, the per-table try/catch classifies it as "absent" (no PRAGMA-confirmedtable_info), so new message/state types degrade silently. _isConfirmedUnsupportedSchema(line 102-117) fails-closed after mcode ships a keyless table, but the silent orphan window between mcode release and webui release is unavoidable.
Suggest a CI sanity test that counts local_runtime_* tables in ~/.minimax/v2/sqlite and asserts the list covers them.
Observations (lower-impact)
-
chat.js:254dynamicawait import("../lib/mcode-rpc.js")is misleading —mcode-rpc.jsis already transitively loaded at chat.js boot viamcode-acp.js:17(import { mcodePermissionToWebui } from "./mcode-rpc.js"). Static-import for consistency, or add a one-line comment. -
router.js:128-147keeps/api/healthand/api/settingsonly for the OY-3 gate tests — both routes are owned by Hono (app.js:78,app.js:119) in production. Legacy copies are dead code. Gate behindNODE_ENV !== 'production'or remove and target the Hono layer (it runs the same gates). -
matchHonoPathinapp.js:177-198is hand-rolled: theLimited to the patterns we actually usecaveat is fine for:name-only, but a Hono router upgrade with*wildcards or optional segments would silently mis-route. Either pin a Hono router version or extend the helper. -
acp.mjsprompt() removedresult.events(OOM hardening, line 228). A futuresession_updatekind that needs events for finalize would silently lose them; the comment doesn't flag it as a deliberate gap. -
state-bus.jsat 798 lines with 12+settings.jsimports — PR claims "split by responsibility" but state-bus owns per-cid state, SSE channel, alerts re-export, settings aggregation,getCidFromReq. AgetRuntimeSettings()facade would let state-bus drop those imports.
What's right
createResponseCapture+invokeHandlercorrectly avoidPromise<Promise<Response>>; the comment is accurate.gates.jsfactoring with Origin/CSRF (gate 1b) without the local-request exemption is the actual CSRF fix.scripts/check-webui-bundle.mjsis small but high-leverage — bare-specifier ×cliExternalModulesdrift is exactly howhonoshipped missing. 3× size floor + Hono marker + comment-aware regex is the right trio.- Cancel-as-notification (
mcode-rpc.js:97-99) and thechat.js:243-289cascade ordering (notify → kill → 2 s grace) are correct. TheresetThinkingClaimescape hatch forwasRunning=falsezombie-claim case is the right defensive coding.
From @stevenjj33's `fix/webui-windows-validation`, which validated #18 on Windows and fixed what that surfaced: - better-sqlite3 resolution probes the Windows installer layout (the release directory named by the launcher's sibling `current` file, with the same charset guard the launcher enforces) and the pnpm workspace root. - `test/lib/config.test.js`, `test/routes/fs-containment.test.js` and `test/integration/router-boot.test.js` made cross-platform. - `/api/health` and `/api/settings` report the engine's own version. Three conflicts, resolved: - `server/lib/settings.js` and `server/routes/health.js` — their version commit fixes the same two `"0.1.2"` constants this branch already fixed ("report the engine's own version, not a pinned constant"), reading the same `agentInfo` from the same ACP handshake. Kept this branch's implementation; the only net change theirs made to these files was a duplicate import of `getMcodeServerInfo`, which is dropped. Both files are byte-identical to the pre-merge branch. - `test/integration/router-boot.test.js` — kept BOTH environment additions: theirs points `MCODE_CMD` at a nonexistent path so the suite never spawns the engine, this branch's points the usage history at the temp dir so the forecast assertion cannot read the operator's own. They isolate different things and both are wanted. Verified after the merge: the three merged suites 53/53 pass; the full webui suite is 1273 tests, 1271 pass, 2 skipped, 0 fail.
0524196 to
294b2b2
Compare
The webui server runs from source, so shipping it shipped a module graph the release archive could not resolve: `@mavis/*` are private with no build output, and a bare specifier that crept in without being listed in `cliExternalModules` produced a runtime that failed on first import, which is how `hono` previously went missing. scripts/build.mjs now bundles `server/bootstrap.js` into `dist/webui/server.js`, sharing the workspace-source plugin with the CLI build, and scripts/check-webui-bundle.mjs is a gate so the bundle, the externals list and the release manifest cannot drift apart.
…d output `@mavis/shared` is imported for the data-directory contract, so it is a declared dependency rather than an undeclared one, and the lockfile is refreshed so `--frozen-lockfile` installs. .gitignore now explains each build artifact it excludes: the source inventory scans the working tree, so an un-ignored artifact would be published as source.
The vanilla-JS frontend is replaced by a Next.js static export that reproduces the desktop client's layout and design tokens, so markup and class strings can be checked against the client rather than invented.
…acy UI `public/app/**` and `public/styles/**` became unreachable once the Next export was the served frontend, and `static.js` carried a `public/` fallback for them. Both are gone; the server serves a single root.
Every non-SSE `/api` route is served by the Hono app. `OWNED_ROUTES` is the ledger the check in test/server/app-hono.test.js asserts against, and the legacy dispatcher table is empty.
The four-layer better-sqlite3 resolution chain, the session-delete SQL, the streaming chat-line writer and the context percentage each moved into their own module. `layout.js` is the single place the bundle reads `import.meta.url`.
`checks/` is folded into `test/`, and files are named for the module under test (`test/lib/<module>.check.mjs`, `test/routes/<route>.check.mjs`) instead of a shared prefix. The paths are declared in the webui's test script rather than hard-coded.
…tion Documentation and comments described the previous architecture: module inventories named deleted files, API.md documented fallbacks the router does not implement, CAPABILITIES.md pointed at deleted frontend symbols, and comments narrated how a file had evolved instead of stating what a reader cannot recover from the code. Each claim is restated against the code. The comments keep the external contracts (paths, environment variables, protocol methods, ordering invariants) and drop the rest, and the 41 translation keys no component reads are gone from both dictionaries.
Regenerated with `node scripts/source-inventory.mjs --write` after the paths above changed.
Reaching `session/cancel`, `set_mode`, `set_config_option` and `activate` made several statements wrong in the same breath: /api/stop's header said cancel was unsupported so every stop was a SIGKILL, /api/protocol/capabilities advertised "no graceful cancel in mcode 0.1.5", and the 501 branches were explained as "mcode 0.1.5 does not support this". None of those hold now. The remaining `mcode 0.1.x` labels go too: they narrated which engine release a fallback was written against rather than what the fallback is for. Two of them were inside log template strings and the capabilities payload, so those two strings change with the wording. `node scripts/source-inventory.mjs` is unaffected (content-only edits).
The card carried six hardcoded values — a CDN avatar, the display name, the plan tier, the workspace name, the user id and the token-plan flag. Only one of them had a real source and the card ignored it: the server already publishes the plan tier from the quota API as `usage.plan`. Everything else was fabricated, which is worse than an empty field because a plausible id and plan are indistinguishable from real ones. `plan`, `workspaceName` and `hasTokenPlan` now come from the server snapshot: the quota API's tier, the basename of the workspace the session actually runs in, and whether a Token Plan key is configured. The user id and the avatar have no source, so they render their empty state — the id row is hidden and the avatar falls back to the workspace initial — until the account API is wired. Also removes the "switch to classic" entry. It set `window.location.href` to /mavis, a surface this branch deletes, and its own comment said so: "webui has no /mavis surface yet … this stays visible so the 1:1 menu shape is preserved". A menu row that cannot work is not parity. The icon and both i18n keys go with it.
…ng it The card had no real source for a display name or a plan tier, so it hardcoded both. The engine already had them: `getAccountStatus()` merges an `accountIdentityGetter` (lifecycle.ts) that reads the shared OAuth auth context, and `/status` already calls it — `formatStatus` simply never printed `identity`. So this is a missing output, not a missing capability, and no second authorization is involved: the credential is a per-user file in the data directory both processes already resolve the same way. Adds the `mcode/account/status` ACP extension method, following the existing `mcode/session/queue/*` pattern, and `GET /api/account` to carry it to the client. The payload is an allow-list projection, not a spread of `TuiAccountStatus`: a field added to that type later must not reach a browser by default. Nothing in it reads a credential — no access/refresh token, no subscription key, no provider API key; `managedTokenPresent` is a boolean, not the token it names. `identity.email` is omitted deliberately: the UI needs a display name, and carrying an unused PII field over the wire is a leak waiting for a logging accident. Verified against a running engine: the response contains field names only, no secret values. The route is on-demand rather than part of the state snapshot. The snapshot is broadcast to every SSE subscriber, including over the LAN when `lanBind` is on, so account data does not belong in it; the response is never logged. A failure is a soft one, so the card renders its empty state instead of a substitute value. NOTE: `packages/tui` is vendored from upstream (`docs/source-sync.md`), so this hunk will be a sync candidate. It is additive — one method registration, one capability-list entry — with no change to existing methods or responses.
It stacked the workspace name over the plan tier, which reads as one identity when they are two unrelated things — the workspace is the directory the session runs in, the tier comes from the account. The id row above it had no source and was already hidden, and its Upgrade / Manage button is permanently disabled with an "unsupported" tooltip, so the card's only visible content was the confusing pair. The account name and plan already show on the footer row that opens the menu, where they are read from `/api/account`. Its four translation keys go with it, from both dictionaries, along with the `realUserId` / `copyUserId` / `hasSubscription` values that only the card read.
The usage popover got its 5h / weekly figures by calling MiniMax's quota
endpoint with a Subscription Key the operator pasted into the web
settings. Two problems: that key sat in plain text in settings.json, and
lib/usage.js's own header already said the credential lives with mcode —
so the web server was keeping a second copy of a secret in order to
repeat a call the engine makes anyway.
Quota now comes from the engine over ACP. `mcode/account/status`
(extensions.ts) already projects the plan tier and each window's
remaining percentage, so POST /api/usage maps that projection instead of
calling out. handleUsage also answered `{ok:true}` *before* the figures
arrived, and the popover reads the response body — so a successful fetch
still rendered "unavailable". The body is now the snapshot itself.
What goes away with the key: the `quotaEnabled` / `tokenPlanApiKey`
settings, MCODE_WEBUI_TOKEN_PLAN_KEY and its _FILE variant, and the
snapshot fields that published them. The settings UI that fed them was
already gone. buildPersistBody() is an explicit whitelist, so the first
start after this change rewrites settings.json without the retired
fields — no plaintext credential left behind for a feature that no
longer reads it.
Two smaller fixes in the same path: the old parser zeroed the session*
counters on every /api/usage call, but those belong to the chat flow
(mcode-acp.js accumulates them per turn), so applying a quota reading no
longer touches them; and the weekly reset time, which the engine does
report, is now recorded for the forecast instead of hardcoded null.
Verified: packages/webui 1256 tests / 0 fail, webapp typecheck clean,
docs-alignment clean. GET /api/account and POST /api/usage were both
checked against a live engine in a later commit's run.
Three points from the pull request review.
`ownsRequest(method, pathname)` built a fresh Hono app — 47 routes — for
every request just to decide whether Hono owns the path, while
`createHonoListener()` had already built one. The sidebar's polling paths
paid for two router tables per request. `createHonoListener()` now returns
`{ app, listener }` and the bootstrap threads that app into `ownsRequest`,
so the decision and the serve walk the same table. The app is deliberately
not hoisted to module scope: building it per call is what keeps the route
tests isolated from one another.
`POST /api/protocol/cancel` answered an undeliverable notification with
`fallback: "hard_kill"`, and nothing on that path kills anything — the
gentle-then-SIGKILL cascade with its two-second grace window lives behind
`POST /api/stop`. A client that trusted the field could stop polling while
the child kept running. The payload now names the endpoint that does carry
the cascade.
`routes/chat.js` reached for `mcode-rpc.js` through a dynamic import it
had no reason to pay for: `chat.js` imports `mcode-acp.js`, which imports
`mcode-rpc.js`, so the module is already in the cache.
Two defects behind one report of raw protocol text showing up as chat. A `tool_call_update` whose `tool_call` never arrived — webui attached mid-stream, or the update was the first frame seen for that tool — had no row to insert its body after, so `mcode-acp.js` appended ` [status]`, ` @ path` and output lines at the end of the transcript with no `→ name` header above them. `decodeTranscript` cannot attribute an indented line to a tool without that header, so its fall-through branch collected the whole run into a `chat.system` row: the transcript showed a block labelled 系统 whose body was `[in_progress]`, `[completed]` and `@ /path/...` lines verbatim. The update now writes its own header — the tool name is on the event — and registers it, so later updates for the same tool land under it. `decodeTranscript` additionally refuses to fabricate a system row out of a line that opens with a protocol glyph, which also covers a server that has not been updated; plain indented continuation prose is untouched. `config_option_update` claimed in its comment that it learns about a model change made in another client, while only propagating `permissionMode`. A model switched in the TUI therefore left the web UI naming the old one, and `cs.model.name` kept being sent as the `model` for the next prompt. It now reads the `model` option's `currentValue` — the same field `routes/model.js#handleGetModels` derives its `current` from, so the two cannot disagree about which field holds the encoded id — and leaves `cs.model` alone when the option carries nothing usable. Both handlers moved out of the streaming callback into exported functions, so they are testable without a live engine.
…n the column The Local/Cloud segmented control could only ever be half real: mcode exposes no cloud sessions, over ACP or otherwise, so 云端 was rendered disabled as a placeholder. Removed, with its three i18n keys. Above it sat a folder glyph and a workspace name whose only effect was to open the workspace panel — the room the toolbar's 工作区 button already opens. It never switched anything: `webapp/lib/api.ts#setWorkspace` has no caller at all. Removed rather than left as a second door to one room. The AI-content disclaimer was a sibling of the row that holds the transcript *and* the drawer, so its `text-center` centred it across the drawer too and it read as sitting under the drawer. It is a child of the conversation column now, which is also what keeps it out of the transcript's scroll area. The drawer no longer opens on 工作区 by itself. It starts closed: this workspace panel is mostly placeholders, so opening it in a session with no history put empty sections and inert buttons in front of the user before they asked for anything. The toolbar and the sidebar's nav rows still open a panel on demand. Contact us / Learn more: every row pointed at `example.com` or `support@example.com` — links that look live and go nowhere. They are disabled rows now, in the same shape as 飞书 and 签到, until there is a real target to open. The submenu itself was unusable for two further reasons: the panel is a sibling of its trigger, so the trigger's `mouseleave` closed it in the same tick the pointer set off toward the panel; and `right-[calc(100%-8px)]` resolved to a position 8px from the trigger's *left* edge, which laid the panel out past the left edge of the window. One hover container now owns the row and its panel, and the flyout opens away from the sidebar. CAPABILITIES.md loses two claims that described the removed chip; the workspace switcher is now marked as not wired in the Next frontend.
`state.model.name` is the engine's encoded selection — the `value` of its `select` config option — while a catalogue entry carries a separate display `name`. The chip printed the value, so it read `deepseek-v4.1-flash` while the dropdown a few pixels away listed `DeepSeek V4.1 Flash`. The chip now resolves the value through the catalogue and falls back to the stripped value only when the engine lists no entry for it. Enter also submitted while an IME was still composing. A candidate window confirms on the same key, so a Chinese message could be sent half-composed. Enter during composition is left to the IME now. A send that hangs was invisible: `sending` stays set until the promise settles, the send button is replaced by the stop button while a turn runs, and Enter then returns early without a word — so the text simply stayed in the box and the composer looked dead. The two send endpoints now carry a deadline (they answer with an ack before the engine runs, so a reply past 30s means the request is not arriving), and the hint under the box says 正在发送 while a send is in flight. The usage popover also called `GET /api/usage`, which no route registers — only POST is — so it 404'd and the popover showed its failure line no matter what the engine reported. Both endpoints that share `/api/usage` are POST because the route fetches from the engine and appends to the forecast history; `getQuota()` now uses POST. MSG2
The sidebar's per-project pill read roots + children, and every child row is a `session_kind='task'` sub-agent. Measured against the engine's own database that made one project's pill read 578 for 321 conversations — close to double — and the tree renders every group collapsed by default, so the number sat next to a handful of visible rows. The pill now counts the sessions a user started. Sub-agent rows keep rendering under their parent; only the count changes.
`/api/health` and `/api/settings` answered `mcodeVersion: "0.1.2"` — the version webui was written against, not the one installed, which is 0.5.2 here. `/api/protocol/capabilities` already reads the `agentInfo` from the engine's ACP `initialize` reply; these two read the same field now and say `unknown` until a client attaches.
`packages/webui/server/routes/account.js` was added when the account card started reading the engine, and the inventory was not regenerated with it, so `pnpm check:source` had been failing since. The file is a plain route handler with no credential handling; reviewed and recorded.
…te directory `router-boot.test.js` spawns a server and asserts `no_history` on a "fresh server", but never redirected the usage-history path — so the assertion depended on whether the machine running it had ever fetched a quota. It does now. `router-readonly.test.js` imports the real settings module, so its `setReadOnly(true)` rewrote the operator's `~/.mcode-webui/settings.json`, and each audit event it appended went to the `~/.mcode-webui/events.ndjson` a running dev server is also writing. That read-modify-rename is not safe against two writers, which is one of the ways this file went red intermittently. Both paths now point into a per-run temporary directory.
The engine reports two quota windows — one rolling over 5 hours and one weekly — and `POST /api/usage` has returned both (`remaining` / `resetAt` and `weeklyRemaining` / `weeklyResetAt`) since it started reading the engine over ACP. The popover drew a single row labelled "Quota" from the 5-hour pair, so the weekly figure was fetched and then dropped: the information was in the payload with nothing on screen for it. One row per window now, each with its own gauge and reset time. The percentage is labelled 已用 / Used, because a bare "7%" next to "Quota" read as 7% left rather than 7% consumed. A window the engine reports no figure for is dropped instead of being drawn as 0%.
The usage popover fetched only when it was hovered, so the number was as old as the last visit and the only way to get a current one was the refresh button. `startQuotaPolling` in the store now reads the quota on load and every two minutes for as long as the page is open, so the popover has a figure before it is ever opened. A hidden tab does not poll; it catches up on `visibilitychange`. Polling is a read, not a measurement. Every `POST /api/usage` used to append a sample to the forecast history, and one sample every two minutes would grow that file without bound for a forecast that only reads within the weekly window. `record` now decides — in `lib/usage.js`, defaulting to true so a caller that says nothing keeps the old behaviour — the poll sends `record: false`, and only the refresh button asks for a sample. That button could not have worked. The usage popover is portalled to `document.body`, and the account menu's outside-click guard knew only about the contact/learn-more submenu; a mousedown on the button therefore closed the menu and unmounted the button before the click dispatched, so its `onClick` never ran. Same class as the submenu hover bug. The guard covers both flyouts now.
`GET /api/models` answers with the engine's `model` config option, and `value` is the engine's encoding — `m:<provider>:<model>:v:<variant>`. Before a session exists there is no option, and the route fell back to webui's own `DEFAULT_MODEL`, `minimax_api/MiniMax-M3`. That is a different encoding *and* a different fact: the composer rendered it as the active model while the session was running `m:custom_provider%3Aopencode-go:deepseek-v4.1-flash:v:thinking`. Worse, the route wrote that value back into `cs.model.name`, so it was also what a later prompt would carry. `current` is `null` when the engine has not named a model, nothing is written back, and the composer shows its neutral `composer.model` label until a catalogue arrives. A catalogue that simply lacks the state's value now shows the engine's own string rather than a stripped or invented one. Measured against a live engine while writing the test: loading a session and changing its model through `session/set_config_option` changes no session row (the runtime selects in place), so a model switch is not itself what adds entries to the sidebar.
Both are submenus whose every entry points at a product page or a support mailbox this distribution does not have. In the last round they became rows of disabled placeholders, which is honest but also a menu that opens onto nothing; the user asked for them to go until there is a real target. Removing them takes the whole mechanism with it: the two submenu components, the shared flyout, the submenu trigger, the `submenu` state, the portal entry in the account menu's outside-click guard, and the nine `userMenu.*` keys that had no other reader. The doc comment above the menu says what was removed and what to add back, since the reference client does have these rows.
…ter-sqlite3 Two install shapes the candidate chain never reached, both verified on a Windows host with the 0.5.2 installer: - The Windows installer puts mcode.cmd at the install ROOT and forwards to releases/<version>/, reading the sibling `current` file. Both MCODE_CMD-derived candidates (npm-style and flat) are anchored at that root, so neither reaches the release dir; resolution failed on every machine with an installed engine. Emit the release path derived from `current`, validated with the same charset the launcher's findstr enforces so a stray file cannot inject a path segment. - A pnpm source checkout hoists better-sqlite3 to the REPOSITORY root's node_modules, one level above the tier-4c "packages/node_modules" probe. Append the repo-root candidate. Measured on the failing host: 6 candidates / 0 hits before, 2 hits after (the installed release dir and the repo root), and the "cannot load better-sqlite3" warning is gone at runtime.
/api/health and the settings snapshot answered mcodeVersion: "0.1.2" — a placeholder inherited from the plugin migration — while the installed engine is 0.5.2, so every consumer of those endpoints was told a wrong version. Read agentInfo.version from the ACP handshake via the existing read-only getMcodeServerInfo() accessor instead: the real version once a session has been initialized, "unknown" before that. Health still never spawns the engine to fill the field.
… Windows
Three tests that passed on the Linux host the branch was validated on
and failed on Windows, each for a different portability assumption:
- config.test.js asserted WEBUI_DATA_DIR.startsWith("/") — an absolute
Windows path starts with a drive letter. Use isAbsolute().
- fs-containment.test.js used /etc as the existing directory outside
the allowed roots; on Windows the route resolves the request to
"D:\etc", which does not exist, so the handler failed with ENOENT
("cannot resolve") before reaching the containment 403. Pick the
witness path per platform: SystemRoot on win32, /etc elsewhere.
- router-boot.test.js premised "the routes we hit do not invoke mcode"
but never neutralized MCODE_CMD. GET /api/state does spawn the ACP
singleton whenever a resolvable engine exists, and on a host with an
installed engine (the Windows .cmd launcher chain) that first spawn
overran the 10s client timeout. Point MCODE_CMD at a nonexistent
path so the suite is hermetic on any host, which is what its own
comment already claimed.
Post-fix on Windows: the three files pass, and the full suite stands at
1232 pass / 10 fail, where the remaining 10 are 8 symlink-privilege
EPERM failures (containment.test.mjs needs admin or Developer Mode to
create symlinks) and 2 sse-channel coalescing timing tests that pass in
isolation (16ms throttle window vs the ~15.6ms Windows timer
granularity under full-suite load).
Reported as "you cannot tell whether a task is running, or what state a session is in". The marquee for a running session already existed — it is the desktop's own indicator, a gradient swept across the title — but it was driven by the engine's `status` column as read through the sidebar's 15s cache, and nothing invalidated that cache when a turn started or ended. A running session therefore looked idle for up to fifteen seconds, and a finished one kept shimmering. Two liveness sources feed it now: - `running.active` arrives over SSE the moment a turn starts, and the active session's row reads it directly, so the marquee is immediate rather than cache-late. - That same transition forces a re-read (`?refresh=1`), so every other row's status is re-read when the engine rewrites it instead of waiting the cache out. The states that mean "this did not finish cleanly" were invisible too: `aborted` (27 rows on this machine), `interrupted` (22) and `error` (16) all rendered exactly like `idle`, so a session that died on an error was indistinguishable from a quiet one. Each carries a status dot whose tooltip names the state — the mark is never the only carrier of the meaning. The conversation header answers the same question where the transcript cannot: its own indicator only exists where the transcript is scrolled to, while the bar is always on screen. It shows the dot loader, the elapsed time and the token rate while a turn is in flight, and nothing when idle.
… the file browser
Rebasing onto main surfaced PR #20, which replaced the webui's SSE channels with a WebSocket transport — on the *previous* frontend. This branch is the frontend replacement, so main's transport is superseded and its changes are resolved in favour of this branch. A plain `-X theirs` rebase was not enough. It resolves content conflicts, but main's changes to files this branch also touched still landed where the two did not overlap, and its brand-new files stayed because no commit of ours deletes them. So: - removed main's ws/embed cluster — 8 server modules, 8 test files, 1 fixture, 3 design drafts. Nothing in this tree imported them; - restored `routes/alerts.js`: main had converted it to a REST snapshot, while `webapp/lib/alerts.ts` still opens an `EventSource` on it. That one was a live break, not dead code; - restored `state-bus.js`, `test/lib/state-bus.check.mjs` and `test/helpers/_setup.js`, which carried main's `event-bus` wiring; - restored `docs/HTTPS-REVERSE-PROXY.md` (+zh-CN), which main rewrote around the WebSocket Upgrade handshake; - restored `routes/health.js` and five test files where the two branches' changes had been interleaved. `acp-client.js` is the one file kept from main, and it is kept *surgically*. Main's change there carried two things: a genuine fix — stopping the singleton child before dropping the reference, so a failed probe no longer leaks a subprocess whose stdio pipes hold the event loop open — and a call to `syncActiveCapabilities`, part of the transport this branch removes. Taking main's file wholesale left that call behind with no import, because the rebase had already dropped the import from this branch's side: a `ReferenceError` waiting for the first session probe. The two `client.stop()` blocks are applied by hand to this branch's version instead, and nothing else. That single file is the whole remaining difference from this branch's own version, which is the check that no content was lost in the rebase. Rebased history is linear and `main` is now an ancestor, so the PR merges cleanly.
`upload-limits.test.js` — 8 MiB against a 64 KiB request cap — was the
one test that could still fail `pnpm verify` on an unchanged tree. It
failed three different ways over this session, and only the third is
about the test's budget:
1. `write EPIPE` — the server closing without draining makes the
client's next write race the 413. Fixed by letting the response
event decide instead of the interleaving.
2. `client wrote 4308992 of 8388718` — an assertion of `< 4 MiB`, a
proxy for "did not buffer the whole body" that measured how far the
client's write loop got. Replaced with the property itself,
`bytesWritten < body.length`.
3. `no response within 15000ms` — this one.
(3) is not a product race. The server writes the 413 and then drains the
unread remainder specifically so the response is not overtaken by an RST;
there is no `destroy()` on that path. Measured: 5/5 passes in isolation,
about 2 in 3 in a full run. `node --test` runs test *files* in parallel,
and this is the heaviest file in the suite — it spawns a real server and
pushes 8 MiB, then the server drains it. So the 15s bound was measuring
the machine's load, not the server.
Raised to 45s for this test, which keeps the assertion meaningful (a
server that never answers still fails) without turning the test into a
load detector.
Also plumbed the child's stdout/stderr into the timeout error. Without
it a failure reported only "no response within Nms" and the actual
reason — a boot warning, a crash, a slow start — was lost; that is why
the first two failures took a diagnosis each.
Measured after: 7/7 consecutive `pnpm test:webui` runs pass.
294b2b2 to
f674e00
Compare
`test:windows` failed on the Windows runner in the run for this branch:
× accepts the Windows checkout on a local NTFS volume
Error: Test timed out in 5000ms.
This is not a regression from this branch — the test and the script it
exercises are byte-identical to main's, and main's own Source
verification passes. It surfaced here for a different reason: the
previous run on this branch failed `check:source` after 2.3s, so
`pnpm verify` aborted before `test:windows` ever ran. Fixing the source
inventory let the run continue far enough to reach it.
The cause is the runner, not the assertion. The check spawns `fsutil`
twice *synchronously*, and the first spawn of a binary on a
Defender-scanned volume pays the scan — measured 8.4s against vitest's
5s default. main's green run passed inside the default, which is exactly
what makes this read as flaky rather than broken.
A 60s explicit budget keeps what the test asserts — that a real Windows
host accepts this checkout on a local NTFS volume — and stops it from
doubling as a stopwatch on the runner.
Local gates cannot exercise this: `test:windows` skips off win32, so the
only verification is the Windows CI job.
Change
Why
packages/webuishipped a frontend that was no longer served and ran its HTTP server from source.Both were dead ends, and both had been worked around instead of fixed.
The server could not be shipped as source. Running it from source means the published archive
has to resolve its imports at runtime, and it cannot: every
@mavis/*package isprivate: truewith no build output (its
exportspoint at./dist/*.js, which only the esbuild plugin rewritesat build time). So "declare the dependency and import it" does not work in any published form. And a
bare specifier that reached the server without being listed in
cliExternalModulesproduced aruntime that failed on first import — which is how
honowent missing from the archive.The frontend was unreachable code.
public/app/**,public/styles/**andpublic/index.htmlwere not what the served frontend rendered; the served frontend is a Next.js static export. They
stayed alive only because
static.jskept apublic/fallback for them.Several other mechanisms had the same shape — a workaround that outlived the limitation it was
written for, with a comment still asserting the limitation:
session/cancelwas refused and sent as a request, so the graceful cancel path never ran andevery stop SIGKILLed the whole engine;
The through-line is that the webui was guessing at, or routing around, an engine it can simply ask.
This PR removes the guessing.
What
The server is one bundle, and the artifact is gated —
build(webui): bundle the server and gate the artifact.scripts/build.mjsbundlesserver/bootstrap.jsintodist/webui/server.js, sharingthe workspace-source plugin with the CLI build.
scripts/check-webui-bundle.mjsis a newverification gate so the bundle's bare specifiers,
cliExternalModulesand the release manifestcannot drift apart again. The entry stays
server.jsat the webui root, becausepackages/tui'slauncher resolves exactly that path.
The frontend is the Next.js desktop stack (
feat(webui): rebuild the frontend on the Next.js desktop stack), and the legacy tree plus thestatic.jsfallback that kept it alive are gone(
refactor(webui): serve one static root and delete the unreachable legacy UI), so the serverserves a single static root.
The API moved onto Hono (
refactor(webui): move the API onto Hono).OWNED_ROUTESinserver/app.jsis the migration ledger, asserted bytest/server/app-hono.test.js.router.jskeeps static/HTML handling, the two SSE channels (the buffered response capture cannot model a
streaming write), and
/api/health+/api/settings— retained deliberately so theorigin/CORS/rate-limit gate tests exercise the legacy path and get a 200 instead of the 404 path;
the Hono app owns both endpoints for real consumers.
The server libraries are split by responsibility (
refactor(webui): split the server libraries by responsibility): the four-layer better-sqlite3 resolution chain, the session-delete SQL, thestreaming chat-line writer and the context percentage each became their own module, and
layout.jsis the one place the bundle resolves the webui root from the entry path.
The test tree is organised by subject (
test(webui): reorganise the test tree by subject):checks/folds intotest/, and files are named for the module or route under test(
test/lib/<module>.check.mjs,test/routes/<route>.check.mjs) instead of a shared prefix that onlyrecorded where a file came from.
Documentation and comments describe the current implementation (
docs(webui): align the documentation and comments with the implementation,docs(webui): correct the comments and notes the ACP change falsified). Module inventories named deleted files,API.mddocumented fallbacks therouter does not implement,
CAPABILITIES.mdpointed at deleted frontend symbols, and commentsnarrated how a file had evolved instead of stating what a reader cannot recover from the code. Each
claim is restated against the code. Comments now keep the external contracts — paths, environment
variables, protocol methods, ordering/idempotency invariants — and drop the rest. The 41 translation
keys no component reads are removed from both dictionaries (a key is only reachable through
t(),and
MessageKeyis derived from the English dictionary, so nothing flags an unused one).The behaviour changes this PR also carries
The commits above are organised by the files they touch, so the behavioural fixes sit inside them
rather than in commits of their own. Listed here so they are not missed in review.
The ACP wrapper now reaches what the engine implements (in
refactor(webui): split the server libraries+refactor(webui): move the API onto Hono). Six methods were refused before the callleft the process. Five have engine handlers, and the two with real callers also sent the wrong wire
fields:
session/set_modemodeId, validated against the session'savailableModesmodesession/set_config_optionconfigIdkeysession/activatewas refused with a comment claiming it is not a public JSON-RPC method;extensions.tsregisters it.session/forkandsession/resumehave no route at all, so the entryonly misreported the engine.
session/deleteis the one method the engine registers but neverimplements, and nothing called it either — deletes go through SQL on the
local_runtime_*tables.With the refusal set gone, the refusal branch in
callRpchad no reachable caller and was removed;MCODE_ACP_CAPABILITIESnow reports what the wrapper can actually reach.Cancel goes through the engine instead of SIGKILLing it (same two commits). The engine registers
session/cancelwithapp.onNotificationand aborts the active prompt's AbortController. Thewrapper both refused it and sent it as a request, which has no handler — so the graceful path never
executed and
/api/stopalways fell through tochild.kill(), taking every other session in thatprocess down with it. It is now sent as a notification; SIGKILL is kept only for an unreachable
client and for a child that outlives the grace window.
The model catalogue and the permission mode come from the engine (in the same two commits, plus
feat(webui): rebuild the frontend on the Next.js desktop stackfor the frontend re-fetch). Thecatalogue was scraped from mcode's
dist/cli.jsand itschunks/*.js, and/api/modelsreportedsource: "mcode-cli-bundle". The engine already publishes it as amodelconfig option (values fromruntime.listModels) and returns it fromsession/newandsession/load, so the route reads thatinstead.
/api/permissionsnow callsset_config_option{configId:'permissionMode'}rather thanwriting local state and answering
mcodeSynced: false. Theconfig_option_updatehandler expected{key, value}, while the engine sends the wholeconfigOptionsarray — so it never matched andcs.permissionscould not follow a change made by another client; it now replaces the array andre-derives the label.
Session rename is carried onto the current stack (in the frontend, Hono and server-library
commits). The rename work landed on
mainagainst the legacy frontend this branch deletes, so it isre-implemented on the current one: the Hono route and its ledger entry, the inline rename affordance
(Enter/blur commits, Escape discards; editing swaps the row's element because a
<button>may notcontain an
<input>), and atitleCustomoverlay inbuildTree. The overlay is not cosmetic — thesidebar reads titles from mcode's runtime db, where a user title does not exist, so without it a
rename would never reach the UI. The rename handler also drops the tree cache, or the old title
survives for its TTL.
Also merged from
mainduring the rebase onto 0.5.2: the session-rename CRUD route, the workspacegate on
POST /api/sessions, draft↔mcode binding atsession/new, and three SSE/ACP memory fixes.Second round: the reviewer's findings, engine-sourced quota, and the defects behind the reports
The plan quota no longer comes from a key the web server stored. The usage popover read its
5-hour and weekly figures by calling MiniMax's quota endpoint with a Subscription Key the operator
pasted into the web settings — a plaintext secret in
settings.json, kept in order to repeat a callthe engine already makes. It now maps the
mcode/account/statusACP projection(
server/lib/usage.js), andhandleUsageno longer answers{ok:true}before the figures arrive —the popover reads the response body, so a successful fetch still rendered "unavailable". What left
with the key: the
quotaEnabled/tokenPlanApiKeysettings,MCODE_WEBUI_TOKEN_PLAN_KEYand its_FILEvariant, and the snapshot fields that published them.buildPersistBody()is an explicitwhitelist, so the first start after this rewrites
settings.jsonwithout the retired fields ratherthan leaving a credential on disk for a feature that no longer reads it. Two smaller fixes rode along:
the old parser zeroed the
session*counters on every/api/usagecall (they belong to the chatflow), and the weekly reset time is recorded for the forecast instead of a hardcoded
null.The usage popover had never shown anything at all. Live verification found
getQuota()callingGET /api/usage, which no route registers — the call 404'd, so the popover rendered its failure linewhatever the engine reported. Both
/api/usageand/api/usage-triggerare POST because the routefetches from the engine and appends to the forecast history. It also drew a single row labelled
"Quota" from the 5-hour pair while
weeklyRemaining/weeklyResetAtwent unused, so the weeklywindow was fetched and discarded; there is one row per window now, with the percentage labelled
"Used" because a bare "7%" beside "Quota" read as 7% left rather than 7% consumed.
Reviewer findings (
@modacker):ownsRequestbuilt a second Hono app per request just to decide dispatch;createHonoListener()returns{ app, listener }and the bootstrap threads one app through both.POST /api/protocol/cancelanswered an undeliverable notification withfallback: "hard_kill"while nothing on that path kills anything; the payload now names
POST /api/stop, which carries thecascade.
routes/chat.jsdropped a dynamic import of a module already in the cache.local_runtime_*tables inmcode-session-delete.jsare left as they are — thereviewer marked that non-blocking and the fix belongs on the engine side (see Known gaps).
Transcript rendered raw protocol text. A
tool_call_updatewhosetool_callnever arrivedappended
[status]/@ pathlines with no→ nameheader above them, anddecodeTranscriptcannot attribute an indented line to a tool without one — so a run of them became a block labelled
系统. The update writes its own header now, and the decoder refuses to fabricate a system row from a
line opening with a protocol glyph.
config_option_updatealso propagates a model change, which itsown comment had always claimed it did.
Sidebar and layout. The Local/Cloud segmented control could only ever be half real (mcode exposes
no cloud sessions), and above it sat a folder glyph whose only effect was to open the workspace panel
the toolbar already opens —
setWorkspacehas no caller at all, so it never switched anything. Bothare gone. The drawer no longer opens on 工作区 by itself. The AI-content disclaimer moved into the
conversation column, because as a sibling of the drawer row its
text-centercentred it across thedrawer too. The account menu's submenus are usable for the first time: the panel is a sibling of its
trigger, so the trigger's
mouseleaveclosed it the instant the pointer set off toward the panel, andright-[calc(100%-8px)]resolved to a position 8px from the trigger's left edge, laying the panelout past the left edge of the window. Their rows also pointed at
example.comandsupport@example.com— links that look live and go nowhere — and are disabled rows now.Three display corrections, measured against the engine's own database.
sessionCountcountedroots + children, and every child is a
session_kind='task'sub-agent: the CTAS project's pill read371 for 175 conversations.
mcodeVersionin/api/healthand/api/settingswas a pinned"0.1.2"— both read the engine's
agentInfonow (0.5.2 here). And the composer's model chip printed theengine's encoded
valuewhile the dropdown a few pixels away printed the displayname, so one modelhad two names.
Third round: quota polling, and a model name that was never the engine's
The usage popover is polled now. It fetched only when hovered, so the figure
was as old as the last visit and the refresh button was the only way to get a
current one.
startQuotaPolling(in the app store) reads it on load and everytwo minutes while the page is open, and a hidden tab catches up on
visibilitychange.Polling is a read, not a measurement. Every
POST /api/usageused to append asample to the forecast history; one sample every two minutes would grow that file
without bound for a forecast that only reads within the weekly window.
recordnow decides (
lib/usage.js, defaulting to true so a caller that says nothing isunchanged), the poll sends
record: false, and only the refresh button asks fora sample.
That button could not have worked. The usage popover is portalled to
document.body, and the account menu's outside-click guard knew only about thecontact/learn-more submenu. A mousedown on the button therefore closed the menu
and unmounted the button before the click dispatched, so its
onClicknever ran.The guard covers both flyouts now — the same class of bug as the submenu hover
fixed in the second round.
The composer named a model the engine had not named.
GET /api/modelsanswers with the engine's
modelconfig option, whosevalueis the engine'sencoding —
m:<provider>:<model>:v:<variant>. With no session there is no option,and the route fell back to webui's own
DEFAULT_MODEL,minimax_api/MiniMax-M3:a different encoding and a different fact, rendered as the active model while
the session was running
m:custom_provider%3Aopencode-go:deepseek-v4.1-flash:v:thinking. It also wrotethat value into
cs.model.name, which is what a later prompt would carry. Theroute answers
nullnow, writes nothing back, and the composer shows its neutrallabel until a catalogue arrives.
A model switch does not add a session. Reported as "switching the model adds
sessions to the sidebar"; measured against a live engine (load a session, change
its model through
session/set_config_option, revert) the session row count isunchanged and no row is added — the runtime selects the model in place. Recorded
here so the next report can start from that.
Validation
Run on this PR's head, rebased onto
mainat85f74a6(0.5.2).pnpm verify— the same gates as CI,in order — exits 0: all 16 gates pass.
pnpm buildpnpm typecheck/pnpm webui:typecheckpnpm test:webuipnpm test:webapppnpm check:sourcepnpm check:tsconfigpnpm check:standalonepnpm check:webui-bundledist/webui/server.js, 434537 B, 56.6× sourcepnpm test:release-toolspnpm test:capabilities/test:status-contract/test:smoke/test:byok/test:artifact/test:policyLive acceptance, this round. Earlier rounds were offline only; these changes were driven against a
running engine on this host (
pnpm mcode-web,dist/built from this head) and a real browser:GET /api/accounttokenPlanQuotaState: available,fiveHour.remainingPercent 93,weekly.remainingPercent 85, both reset instants,identity.name MiniMax802592,tokenPlan.tier Ultra Plan— and no credential value anywhere in the payloadPOST /api/usage{ok: true, source: "acp", remaining: 93, weeklyRemaining: 85, resetAt, weeklyResetAt}role="tablist"and no workspace chip; no drawer auto-opened on the home screen; the disclaimer's box ends at the drawer's left edge (240→1112 of a 1400px window) and centres at 676, i.e. in the conversation columnaria-disabled)POST /api/usage {"record":false}fired on load with no interaction; the forecast history file stayed absent through repeated polls; clicking refresh sent{"record":true}and appended exactly one sampleGET /api/models, no session{models: [], current: null, reason: "no_session_config"}; the composer chip renders its neutralModellabel, where it previously claimedMiniMax-M3modelviasession/set_config_option, reverted: total session rows 585 → 585, 0 addedsessionCountNOT RUN / not covered:
windows: true, since it validates an artifact only the profile that runsbuildproduces.session/cancelwas not exercised against a live prompt. The route now sends the engine'ssession/cancelnotification rather than rejecting it, and the offline tests cover the wire shape;an interrupted live turn was not run.
GET /api/usageis still unregistered. The client now uses POST; a GET returns 404 by design.test:capabilitiesfailed on the last run of this host (2 of 4484), on tests this change cannotreach. They are
packages/tui/test/unit/tui/theme/runtime.test.ts's two custom-theme reload cases,which wait for an
fs.watchcallback; both fail in isolation, on a tree wheregit diff e3f903b..HEAD -- packages/tuiis empty, and the file imports nothing butpackages/tui/src/tui/theme/*. The host has 294 open inotify instances against a per-user limit of128 with several browsers and editors running, which is why the watcher never fires. The same suite
passed earlier in this session on this head's ancestry.
Performance: NOT RUN.
perf:fullis not applicable to this change.Publication and contribution checks
release/public-source.json; new tests are declared intest/vitest-suites.jsonwhere applicable.Notes:
release/public-source.jsonwas regenerated withnode scripts/source-inventory.mjs --write(4541 paths). The three new
scripts/files and the added frontend files were reviewed first,since recording a file is not the same as deciding it may be published.
test/vitest-suites.json— that file lists the repository-level Vitest gates, which this changedoes not add to.
release/public-source.jsongained exactly one path this round,packages/webui/server/routes/account.js— a route file added when the account card startedreading the engine, and missed from the inventory at that time. Reviewed, then recorded; the
file holds no credential handling.
Maintainer handoff
Publication scope or license changes: none. No dependency was removed.
@mavis/sharedbecame adeclared dependency of
packages/webui(it was already imported, just undeclared).Shared-source port: the changes are confined to
packages/webui/**,docs/webui*.mdand threefirst-party
scripts/files. Nothird_party/vendored source is touched and no upstream commit wascherry-picked.
Reviewer follow-up
@modacker's review is answered in the commits above: the per-request Hono rebuild, the cancel payloadthat promised a kill it never performed, and the dynamic import of a loaded module are all fixed, each
with a test that fails before the change. The 32 hand-written
local_runtime_*tables are acknowledgedbelow as a known gap and left alone, as the review suggested.
Known gaps, deliberately not fixed here
session/deletestill goes through SQL. The engine registers the method in its protocol layerbut implements no handler, so deletes write ~32
local_runtime_*tables by hand(
server/lib/mcode-session-delete.js;sqlite-resolver.jsrecords the probe that establishedthis). Those tables are schema knowledge the webui does not own and cannot keep correct. The engine
already has the real delete path
(
local-runtime-v2/.../sessions/lifecycle/deletion-service.ts) and already exposes extensionmethods of that shape (
mcode/session/queue/delete), so the fix belongs on the engine side as anmcode/session/delete; the webui can then drop the SQL. Not done here because it needs a change ina package this distribution vendors from upstream.
session's full history as
session/updatenotifications onsession/load/session/resume, andthe webui currently drops them (nothing subscribes during the load —
acp.mjs's listener window isscoped to a prompt, and
user_message_chunkis not in its mapping). Two readers can move onto thatreplay: the switch-path backfill and the export enrichment. The switch path cannot do so as-is,
because it never loads the session in the engine — using the replay there means making a sidebar
click attach the session, which is a behaviour change worth reviewing on its own. The third reader
cannot move at all:
lib/transcript-sync.jspolls the db so a browser tab tracks a session beingdriven by another client, whose writes are not pushed to this connection.
test/integration/upload-limits.test.jsis flaky independent of this change. An A/B probe onthis host — 5 runs with the change set, 5 without — put the same failure rate on both (4/5 each,
failing on
client wrote N of 8388718or a prematureEPIPE). Its assertion depends on how muchthe client can push into the kernel socket buffers before the server's 413 closes the connection,
which is not a property of this diff. Two ad-hoc test-isolation defects found while probing are
fixed:
router-boot.test.jsnever redirected the usage-history path, so its "fresh server"assertion depended on the operator's real
~/.mcode-webui/usage-history.ndjson, androuter-readonly.test.jswrote the operator's realsettings.jsonandevents.ndjson.type TranscriptLine = stringis a locally invented line grammar. Replacing it with theengine's own
session/updatepayloads is the same change as the replay work above, so it is leftwith it rather than designed twice.