Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions docs/releasing-webui-npm.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
# WebUI package on the npm registry

The WebUI is packaged as `@fectivnfy112358/minimax-code-web` and published to
`https://registry.npmjs.org` under the `preview` dist-tag. Publishing is fully
automatic: every push to the `webui` branch builds the standalone package and
publishes a new preview version. No tag, no version edit, no manual step.
`https://registry.npmjs.org` under the `preview` dist-tag.

> **Preview publishing is currently OFF, and this section used to say
> otherwise.** As of 2026-10-05 the repository has **no Actions secret named
> `NPM_TOKEN`**, so `HAS_NPM_TOKEN` in `webui-npm-preview.yml` evaluates to
> `false` and **the publish step never runs**. The job still goes green — the
> steps emit `::warning::` instead of failing, so "no credential" and
> "published" are the same colour. Verified with `gh secret list` (empty) and
> `gh api .../branches/webui/protection` (404, so this job was never a merge
> gate either).
>
> To turn it back on: add the `NPM_TOKEN` secret, and nothing in the workflow
> needs to change. Be aware the first run with a real credential is a **cold
> start** — version resolution and `npm publish` have not been exercised since
> the gate was relaxed.

## How a release happens

Expand Down
19 changes: 17 additions & 2 deletions packages/webui/src/client/components/SessionComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,23 @@ export function WebuiWorkspaceDirectoryBrowser({
setError(undefined);
void browseWorkspaceDirs(dir ? { dir } : {})
.then((next) => {
setListing(next);
setManualPath(next.dir);
// The transport resolves the server body verbatim
// (`resolve(frame.body as T)`), so a body that is missing `entries`
// — or is not a listing at all — used to reach the render, where
// `listing?.entries.length` threw
// `Cannot read properties of undefined (reading 'length')` and the
// whole app tree fell into the error boundary. The `?.` stopped one
// level too early: it guarded `listing` and left `entries` bare.
// Normalise at the door instead of at the use site.
const raw = next as Partial<WebuiWorkspaceDirectoryListing> | null;
const normalized: WebuiWorkspaceDirectoryListing = {
dir: typeof raw?.dir === "string" ? raw.dir : "",
parent: typeof raw?.parent === "string" ? raw.parent : undefined,
entries: Array.isArray(raw?.entries) ? raw.entries : [],
truncated: raw?.truncated === true,
};
setListing(normalized);
setManualPath(normalized.dir);
})
.catch((reason: unknown) => {
setError(
Expand Down
38 changes: 36 additions & 2 deletions packages/webui/src/client/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,35 @@ function SigninSkeleton(): ReactElement {
);
}

/**
* Coerce a sign-in panel off the wire before it reaches state.
*
* The transport resolves the server body verbatim (`resolve(frame.body as T)`
* in `transport.ts`), and `SigninProgress` below does `[...days].sort(...)`
* with no guard. A body that is not a panel — `{}`, a server error envelope,
* anything without `days` — therefore threw `days is not iterable` and took
* the entire app tree into the error boundary.
*
* The server does validate (`validateSigninPanel` throws
* `Invalid sign-in panel`), so this is defence at the boundary rather than a
* known live shape. It is here because the blast radius is the whole UI, not
* one row: a malformed panel must degrade to an empty seven-day strip, never
* to a blank page.
*/
function normalizeSigninPanel(value: unknown): WebuiSigninPanelView {
const raw = value as Partial<WebuiSigninPanelView> | null;
const days = Array.isArray(raw?.days)
? raw.days.filter((day): day is WebuiSigninPanelView["days"][number] =>
Boolean(day) && typeof day === "object",
)
: [];
return {
scene:
typeof raw?.scene === "number" ? (raw.scene as WebuiSigninPanelView["scene"]) : 0,
days,
};
}

/** Seven-day progress row (desktop `eY`). */
function SigninProgress({
days,
Expand Down Expand Up @@ -830,7 +859,12 @@ export function UserMenu({
void getSigninPanel()
.then((panel) => {
// Keep an in-flight claim flag: a silent refresh must not clear it.
setSignin((state) => ({ loading: false, claiming: state.claiming, panel, error: undefined }));
setSignin((state) => ({
loading: false,
claiming: state.claiming,
panel: normalizeSigninPanel(panel),
error: undefined,
}));
})
.catch((error: unknown) => {
setSignin((state) => ({ ...state, loading: false, error: formatSigninError(error) }));
Expand All @@ -845,7 +879,7 @@ export function UserMenu({
setAnimatedDay(
result.claim_result === SigninClaimResult.Claimed ? result.day_no : null,
);
setSignin({ loading: false, claiming: false, panel: result.panel });
setSignin({ loading: false, claiming: false, panel: normalizeSigninPanel(result.panel) });
})
.catch((error: unknown) => {
setSignin((state) => ({ ...state, claiming: false, error: formatSigninError(error) }));
Expand Down
10 changes: 10 additions & 0 deletions packages/webui/src/client/projection/message-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,13 @@ function normalizeWebuiClientMessage(
kind: _incomingKind,
timestamp: _incomingTimestamp,
contextUsage: _incomingContextUsage,
// `attachments` rides through `...remainder` untouched without this, and
// `projectMessageAttachments` iterates it with a bare `for...of` — so
// `attachments: {}` / `42` / `true` on the wire threw
// `TypeError: attachments is not iterable` and took the whole transcript
// render with it. Same class as the fields above; it was just missing from
// the list, so the guard was not authoritative in both directions.
attachments: _incomingAttachments,
...remainder
} = message;
return {
Expand All @@ -395,6 +402,9 @@ function normalizeWebuiClientMessage(
...(typeof thinkingDurationMs === "number" ? { thinkingDurationMs } : {}),
...(Array.isArray(toolCalls) ? { toolCalls } : {}),
...(Array.isArray(parts) ? { parts: parts as Record<string, unknown>[] } : {}),
...(Array.isArray(read("attachments"))
? { attachments: read("attachments") as WebuiMessageAttachment[] }
: {}),
...(typeof read("role") === "string" ? { role: read("role") as string } : {}),
...(typeof read("source") === "string" ? { source: read("source") as string } : {}),
...(typeof read("kind") === "string" ? { kind: read("kind") as string } : {}),
Expand Down
8 changes: 8 additions & 0 deletions packages/webui/src/client/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,14 @@ export function createWebuiTransport({
if (reconnectTimer !== undefined) clearTimeout(reconnectTimer);
reconnectTimer = undefined;
const previous = socket;
// The close handler below guards on `socket !== ws`, and this function
// has already cleared `socket`, so that guard would have skipped
// `markWebuiEventWatcherDown` entirely. A forced reconnect — tab coming
// back into view, network coming back — therefore reported the channel
// as healthy for the whole reconnect window, which is exactly the silent
// "connected" lie this watcher exists to remove. Mark it down here, on
// the path that is closing the socket without going through the guard.
if (previous) markWebuiEventWatcherDown(watcherToken);
socket = undefined;
previous?.close();
connect();
Expand Down
34 changes: 34 additions & 0 deletions packages/webui/test/unit/connection-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,38 @@ describe("the transport's watcher reporting", () => {
// though its socket never came back before the unsubscribe.
expect(isWebuiEventChannelDegraded()).toBe(false);
});
it("marks the channel down on a FORCED reconnect, not only on a guarded close", () => {
// The defect: `reconnectWhenAvailable` (tab back into view, network back)
// cleared `socket` and then called `previous.close()`. The close handler
// guards on `socket !== ws`, which is therefore always true on that path,
// so `markWebuiEventWatcherDown` was skipped and the banner read 已连接
// for the whole reconnect window — the exact lie this watcher exists to
// remove. Zero tests reached it: this file had no `dispatchEvent` at all.
const online = new Map<string, () => void>();
const globals = globalThis as unknown as Record<string, unknown>;
const saved = { document: globals.document, window: globals.window };
globals.document = {
visibilityState: "visible",
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
globals.window = {
addEventListener: (type: string, fn: () => void) => online.set(type, fn),
removeEventListener: (type: string) => online.delete(type),
};
try {
const { transport, sockets } = watchEventsHarness();
const unsubscribe = transport.watchEvents(() => undefined);
accept(sockets[0]!);
expect(isWebuiEventChannelDegraded()).toBe(false);

online.get("online")?.();

expect(isWebuiEventChannelDegraded()).toBe(true);
unsubscribe();
} finally {
globals.document = saved.document;
globals.window = saved.window;
}
});
});
22 changes: 22 additions & 0 deletions packages/webui/test/unit/message-parts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,26 @@ describe("wire message normalization drops a non-string content field", () => {
msgContent: JSON.stringify({ eventType: "todo_updated", todos: [] }),
})).toEqual([]);
});
it("does not throw when `attachments` is not an array on the wire", () => {
// `attachments` was the one field the normalizer guarded everywhere
// EXCEPT: it is not in the strip list and not in the conditional spreads,
// so it rode through `...remainder` and `projectMessageAttachments` ran a
// bare `for...of` over it. Measured on f56b26f:
// attachments={"a":1} -> TypeError: attachments is not iterable
for (const attachments of [{ a: 1 }, 42, true, "str"] as unknown[]) {
expect(() =>
projectWebuiMessage({ msgId: "bad-attachments", role: "assistant", msgContent: "ok", attachments } as never),
).not.toThrow();
}
});

it("keeps a real attachments array working", () => {
const items = projectWebuiMessage({
msgId: "ok-attachments",
role: "user",
msgContent: "see this",
attachments: [{ id: "a1", type: "image", file_name: "shot.png" }],
} as never);
expect(JSON.stringify(items)).toContain("shot.png");
});
});
1 change: 1 addition & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 42 additions & 1 deletion test/webui-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ export function installFixtureTransport() {
// block (`{}` — the default — has no signedIn field and reads as signed
// in but quota-less, not as signed out).
let usageQuotaResult = {};
// The composer's permission-mode write is verified by reading it back
// (`setPermissionMode` then `getPermissionMode`, compared in
// `SessionComposer.changePermissionMode`). With the old pair — a
// no-op write and a `getPermissionMode` that always answered "default" —
// every mode other than `default` raised 「授权模式未能保存,请重试」, so the
// read-back could never pass. Store it, so the verification can pass.
let permissionMode = "default";
// A seven-day sign-in panel, shaped exactly like the server's
// `validateSigninPanel` output. The empty default has no `days`, and
// `SigninProgress` does `[...days]` with no guard, so a fixture that answered
// `{}` crashed the whole app the first time a human opened the sign-in row.
// Day 3 is today and claimable; days 1-2 are behind us and claimed; 4-7 are
// still ahead. `SigninDayStatus`: 1 Upcoming, 2 Claimable, 3 Claimed.
const signinPanel = {
scene: 2,
days: Array.from({ length: 7 }, (_, index) => {
const dayNo = index + 1;
return {
day_no: dayNo,
points: 10,
bonus_points: dayNo === 3 ? 400 : 0,
is_today: dayNo === 3,
status: dayNo < 3 ? 3 : dayNo === 3 ? 2 : 1,
};
}),
};
let activeTurn;
const requests = [];
const sockets = new Set();
Expand Down Expand Up @@ -129,7 +155,22 @@ export function installFixtureTransport() {
if (operation === "listModels" || operation === "loadProjects") return [];
if (operation === "getUsageQuota") return clone(usageQuotaResult);
if (operation === "isGoalEnabled") return false;
if (operation === "getPermissionMode") return { mode: "default" };
if (operation === "getPermissionMode") return { mode: permissionMode };
if (operation === "setPermissionMode") {
if (typeof body?.mode === "string") permissionMode = body.mode;
return { mode: permissionMode };
}
// A directory listing with an EMPTY `entries` array, not the `{}` default:
// `WebuiWorkspaceDirectoryBrowser` reads `listing?.entries.length` and the
// missing array threw `Cannot read properties of undefined (reading
// 'length')` on every open of the workspace picker.
if (operation === "browseWorkspaceDirs")
return {
dir: typeof body?.dir === "string" ? body.dir : "",
entries: [],
truncated: false,
};
if (operation === "getSigninPanel") return clone(signinPanel);
if (operation === "getSessionUsage") return {};
return {};
};
Expand Down
95 changes: 95 additions & 0 deletions test/webui-browser/recovered-surfaces.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Three surfaces that 93 specs could not reach, and that a human could not get
// past either.
//
// The shared root is one line of the transport: `resolve(frame.body as T)`.
// The server's body reaches the component verbatim, so every consumer
// inherits the server's shape with nothing in between. The browser fixture
// answered every unknown operation with `return {}`, and each of these three
// consumers read a nested field off that `{}` without a guard:
//
// sign-in UserMenu -> setSignin({ panel }) -> SigninProgress `[...days]`
// -> `days is not iterable`
// workspace SessionComposer -> setListing(next) -> `listing?.entries.length`
// -> `Cannot read properties of undefined (reading 'length')`
// permission setPermissionMode was a no-op while getPermissionMode always
// answered "default", so the write-then-read-back check in
// `changePermissionMode` could never pass and every mode but
// `default` raised 「授权模式未能保存,请重试」
//
// Each one is an error BOUNDARY trip: the whole app tree is replaced, not one
// row. So the assertion that matters is the negative — the boundary must stay
// out of the page — plus the positive that the surface actually rendered.

import { expect } from "@playwright/test";

import { openApp, test } from "./harness.mjs";

const boundary = (page) => page.locator('[data-testid="webui-error-boundary"]');

async function openUserMenu(page) {
await page.locator('[data-testid="sidebar-user-menu-trigger"]').click();
await expect(page.locator(".webui-user-menu-uid")).toBeVisible();
}

test("the sign-in row renders seven days instead of tripping the error boundary", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));

await openApp(page, "#session=A");
await openUserMenu(page);

// Hovering the row is what loads the panel (`onMouseEnter` -> loadSignin),
// so the request is what a real pointer produces.
await page.locator(".webui-user-menu-signin-anchor").hover();

await expect(page.locator('[data-testid="signin-card"]')).toBeVisible();
await expect(page.locator('[data-testid="signin-day-1"]')).toBeVisible();
await expect(page.locator('[data-testid="signin-day-7"]')).toBeVisible();
// No error card, and no card-level error panel either: a degraded panel is
// allowed, a crashed one is not.
await expect(boundary(page)).toHaveCount(0);
await expect(page.locator('[data-testid="signin-card-error"]')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});

test("the workspace picker opens 选择新项目 without tripping the error boundary", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));

// The project row renders only on the home surface — the bar is inside
// `!sessionLayout` (SessionComposer.tsx). Opening `#session=A` puts the
// composer in a session, where there is no workspace bar to click at all.
await openApp(page, "");
await page.locator(".webui-workspace-bar-trigger").click();
await page.getByRole("option", { name: "选择新项目" }).click();

// The directory browser reads `listing?.entries.length`; with the fixture
// answering `{}` this used to throw before a single entry rendered.
const browser = page.locator('[data-webui-workspace-browser="true"]');
await expect(browser).toBeVisible();
await expect(page.locator(".webui-composer-menu-empty")).toBeVisible();
await expect(boundary(page)).toHaveCount(0);
expect(pageErrors).toEqual([]);
});

test("choosing a permission mode survives the write-then-read-back check", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));

await openApp(page, "#session=A");

const trigger = page.locator('[data-testid="composer-permission-mode"]');
await trigger.click();
// The options are plain buttons with no `role="menuitem"`, so they are
// located by their own class rather than by ARIA role.
await page.locator(".webui-composer-permission-option", { hasText: "帮我批准" }).click();

// The verification itself is the subject: `changePermissionMode` writes, then
// reads back and throws when the two disagree. It is the right pattern — the
// fixture was the thing that could not satisfy it — so the assertion is that
// the error message never appears, not that the check was removed.
await expect(page.getByText("授权模式未能保存")).toHaveCount(0);
await expect(trigger).toContainText("帮我批准");
await expect(boundary(page)).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
Loading