diff --git a/.github/workflows/webui-ci.yml b/.github/workflows/webui-ci.yml index 2ed448c01..2cfe92005 100644 --- a/.github/workflows/webui-ci.yml +++ b/.github/workflows/webui-ci.yml @@ -30,7 +30,8 @@ jobs: shell: bash run: | echo "MCODE_WEBUI_VERIFY_DIR=$RUNNER_TEMP/webui-package-checks" >> "$GITHUB_ENV" - echo "WEBUI_BROWSER_OUTPUT_DIR=$RUNNER_TEMP/webui-browser-results" >> "$GITHUB_ENV" + echo "WEBUI_PLAYWRIGHT_OUTPUT_DIR=$RUNNER_TEMP/webui-playwright-results" >> "$GITHUB_ENV" + echo "WEBUI_BROWSER_FONT_CACHE_DIR=$RUNNER_TEMP/webui-browser-font-cache" >> "$GITHUB_ENV" if [ "$RUNNER_OS" = "macOS" ]; then echo "WEBUI_BROWSER_HEADED=1" >> "$GITHUB_ENV" fi @@ -66,11 +67,17 @@ jobs: if: runner.os == 'Linux' run: pnpm exec playwright install-deps chromium - name: Test WebUI units and integrations + env: + WEBUI_BROWSER_OUTPUT_DIR: ${{ runner.temp }}/webui-browser-diagnostics/units run: pnpm test:webui - name: Test WebUI browser interactions + env: + WEBUI_BROWSER_OUTPUT_DIR: ${{ runner.temp }}/webui-browser-diagnostics/browser if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.browsers.outcome == 'success' }} run: pnpm test:webui-browser - name: Verify WebUI package installation + env: + WEBUI_BROWSER_OUTPUT_DIR: ${{ runner.temp }}/webui-browser-diagnostics/package if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: pnpm test:webui-package - name: Upload WebUI browser diagnostics @@ -78,6 +85,8 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: webui-browser-${{ matrix.os }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/webui-browser-results/ + path: | + ${{ runner.temp }}/webui-browser-diagnostics/ + ${{ runner.temp }}/webui-playwright-results/ retention-days: 7 if-no-files-found: ignore diff --git a/packages/local-runtime/test/unit/local-permission-facade.test.ts b/packages/local-runtime/test/unit/local-permission-facade.test.ts index e0632e817..9147b5c97 100644 --- a/packages/local-runtime/test/unit/local-permission-facade.test.ts +++ b/packages/local-runtime/test/unit/local-permission-facade.test.ts @@ -2018,11 +2018,12 @@ describe('LocalPermissionFacade', () => { }); it('write to a path inside dataDir still ASKs (sandbox is read-only)', async () => { - const { facade, dataDir } = freshFacade({ permissionMode: 'default' }); + const { facade, dataDir } = freshFacade({ permissionMode: 'default', workspaceDir: '/synthetic-workspace' }); try { const r = await facade.checkPermission({ toolName: 'write', input: { path: path.join(dataDir, 'skills', 'generated', 'SKILL.md') }, + agentName: 'synthetic-agent', }); // Writes to dataDir go through normal review. The sandbox // auto-allow is scoped to READ-ONLY tools because fs-permission's @@ -2118,7 +2119,7 @@ describe('LocalPermissionFacade', () => { describe('turn-scoped trusted exact writes', () => { it('allows only the supplied exact Plan target without persisting a rule', async () => { - const { facade, ruleStore, dataDir } = freshFacade({ permissionMode: 'default' }); + const { facade, ruleStore, dataDir } = freshFacade({ permissionMode: 'default', workspaceDir: '/synthetic-workspace' }); const planPath = path.join(dataDir, 'v2', 'sessions', 'session-a', 'artifacts', 'plan.md'); try { await expect( @@ -2126,6 +2127,7 @@ describe('LocalPermissionFacade', () => { toolName: 'write', input: { path: planPath, content: '# Plan' }, sessionId: 'session-a', + agentName: 'synthetic-agent', trustedExactWritePaths: [planPath], }), ).resolves.toMatchObject({ behavior: 'allow' }); @@ -2134,6 +2136,7 @@ describe('LocalPermissionFacade', () => { toolName: 'write', input: { path: `${planPath}.bak`, content: '# Escape' }, sessionId: 'session-a', + agentName: 'synthetic-agent', trustedExactWritePaths: [planPath], }), ).resolves.toMatchObject({ behavior: 'ask' }); diff --git a/packages/webui/docs/client/ownership.md b/packages/webui/docs/client/ownership.md index 37455cca7..a0747b5f1 100644 --- a/packages/webui/docs/client/ownership.md +++ b/packages/webui/docs/client/ownership.md @@ -87,7 +87,10 @@ and extraction revision. Channel filter writes use the selected bot's detail rev Account login uses attempt and status-read revisions across begin, poll, cancel and dialog close. Credential removal is shared by switch and sign-out: retire the credential epoch and reconcile account/usage before beginning another login. Malformed check-in and directory responses enter -recoverable error states. Settings subscribes once per resource slot, independently of repeated +recoverable error states. Repeated hover/focus on an open usage panel does not start another quota +read; explicit refresh and retry still do. The rail displays the owner's last quota result while +a new read is loading, then adopts the new answer instead of hiding the subscription name. +Settings subscribes once per resource slot, independently of repeated reads; retained model answers retire with their epoch/session resource. Composer catalogue invalidation also retires outstanding promises, preventing stale successful consumption. diff --git a/packages/webui/docs/client/transcript-rendering.md b/packages/webui/docs/client/transcript-rendering.md index 990c6fa05..c5fb0dd93 100644 --- a/packages/webui/docs/client/transcript-rendering.md +++ b/packages/webui/docs/client/transcript-rendering.md @@ -24,7 +24,12 @@ Workspace file references and delivered-file rows share the same inline media re (including SVG and PNG), supported video and audio files render their contents in the transcript; video and audio use native controls without autoplay. Markdown image syntax and recognized bare media filenames in inline code use the same workspace resource path. Non-media files keep links. -Remote HTTP(S) media and paths outside the current workspace are not automatically previewed. +Remote HTTP(S) media are not automatically previewed. Explicit absolute local paths also work +outside the session workspace: the file tab and authenticated media URL use the named file's +containing directory as their read root. Relative traversal paths remain invalid. + +Delivered-file rows keep their icon and display name together, including when a reference cannot +be opened. The icon type comes from the source filename, independently of a human-readable caption. The host serves actual workspace bytes with byte-range support. A transient media load failure retries a bounded number of times; content synchronization and media-byte availability remain @@ -69,6 +74,12 @@ Pinned by `test/unit/markdown-codeblock-c2-line-numbers.test.tsx`. - A reference of the form `` `path/to/file.ts:120` `` renders as a clickable anchor carrying the line number, not as literal text. - Clicking it invokes `onOpenFile` with the parsed reference. +- Absolute local references outside the workspace retain their absolute identity until the file + tab or media URL splits them into directory and filename. Existing workspace-relative references + keep their original meaning. +- If a relative path with directories is absent, the runtime adapter may complete a unique, + segment-aligned suffix from workspace search. Ambiguous or truncated search results do not pick + a file. Bare-filename references retain their existing workspace search behavior. - A bare `` `file.ts` `` with no line number is still a valid reference. - A string that merely contains a colon is not turned into a reference. diff --git a/packages/webui/docs/design/design-language.md b/packages/webui/docs/design/design-language.md index a9df1b0a8..09258a269 100644 --- a/packages/webui/docs/design/design-language.md +++ b/packages/webui/docs/design/design-language.md @@ -249,6 +249,13 @@ contract. is scoped to the shell, so a page-level form keeps the field contract's own scale. Popovers and menus stay non-modal. The app-wide Settings host keeps its page-like full-viewport layout as a documented exception. + Clicking the backdrop dismisses a dialog by default. A caller that must block dismissal + passes `dismissOnBackdrop={false}` or gates `onClose` while its command is running. + Escape dismissal remains opt-in. The settings dialog wrapper inherits the backdrop default. +- **Progress overview sections size to their content.** The environment keeps every action visible; + short todo lists do not reserve the long-list height. Todo and subagent lists retain bounded + scrolling. `WebuiDisclosure` owns the animated body height; section shells must not impose a + competing collapsed height or flex basis. - **Repeated generic grouping uses `WebuiInline` and `WebuiStack`.** They own direction, alignment, main-axis distribution (start/center/end/between), inline formatting and named spacing steps (`hairline`, `micro`, `tight`, `compact`, `small`, `medium`, `roomy`, `large`). `compact` maps to the shared 6px spacing token. Alignment also includes `baseline` for text-centered inline groups. Callers keep content-specific width, clipping, diff --git a/packages/webui/docs/runtime/boot.md b/packages/webui/docs/runtime/boot.md index bdffd0b29..7bb3e7954 100644 --- a/packages/webui/docs/runtime/boot.md +++ b/packages/webui/docs/runtime/boot.md @@ -96,7 +96,7 @@ session transfer · the terminal process manager (one per service) · the six sl | Entry | Path | Notes | | --- | --- | --- | -| Development server | `scripts/run-webui-server.mjs` via `pnpm --filter @mavis/webui dev:server` | Spawns itself under `tsx` with the repository's `tsconfig.standalone.json`; assembles the host, constructs `new WebuiService(...)` with the registry and the terminal manager, calls `service.start()`, and closes the service on SIGINT/SIGTERM. Serves the built client from `dist-webui/client/`. `WEBUI_SERVER_PORT` overrides 2233. `WEBUI_HOST` overrides the bind address (`127.0.0.1` by default); a wider bind prints the LAN URLs and the per-start credential, and a request that did not arrive over loopback presents that credential (ADR 0031). | +| Development server | `scripts/run-webui-server.mjs` via `pnpm --filter @mavis/webui dev:server` | Spawns itself under `tsx` with the repository's `tsconfig.standalone.json`; assembles the host, constructs `new WebuiService(...)` with the registry and the terminal manager, calls `service.start()`, and closes the service on SIGINT/SIGTERM or an authenticated `POST /webui-shutdown`. The HTTP route acknowledges the request before the same bounded process cleanup starts. Serves the built client from `dist-webui/client/`. `WEBUI_SERVER_PORT` overrides 2233. `WEBUI_HOST` overrides the bind address (`127.0.0.1` by default); a wider bind prints the LAN URLs and the per-start credential, and a request that did not arrive over loopback presents that credential (ADR 0031). | | Packaged service | `runtime/index.ts` + `server/index.ts` | The runtime's public entry and the server build entry. `scripts/build-webui.mjs` bundles both. | | npm preview package | `release/webui-npm/` | See [`../releasing.md`](../releasing.md). | diff --git a/packages/webui/src/client/features/account/ui/UserMenu.tsx b/packages/webui/src/client/features/account/ui/UserMenu.tsx index 624f5f916..c478a1313 100644 --- a/packages/webui/src/client/features/account/ui/UserMenu.tsx +++ b/packages/webui/src/client/features/account/ui/UserMenu.tsx @@ -282,6 +282,7 @@ export function UserMenu({ const loadUsage = (forceRefresh = false) => { setUsageOpen(true); setSigninOpen(false); + if (!forceRefresh && (usageOpen || accountSnapshot.usage.status === "loading")) return; void accountWorkflows?.loadUsage(forceRefresh); }; @@ -310,7 +311,7 @@ export function UserMenu({ const avatarUrl = profile?.avatarUrl?.trim() || profile?.defaultAvatarUrl?.trim(); const planLabel = formatRailPlanLabel( - usage.status === "ready" ? usage.result : undefined, + accountSnapshot.usage.result, ); const identity = objectValue(account?.identity); const auth = objectValue(account?.auth); diff --git a/packages/webui/src/client/features/sessions/ui/SessionRailRegion.tsx b/packages/webui/src/client/features/sessions/ui/SessionRailRegion.tsx index 71e0882a2..ce694d902 100644 --- a/packages/webui/src/client/features/sessions/ui/SessionRailRegion.tsx +++ b/packages/webui/src/client/features/sessions/ui/SessionRailRegion.tsx @@ -97,7 +97,7 @@ export function SessionRailRegion(props: { : null}
正在加载文件…
无法在文本预览中显示二进制文件。
: result?.content ? previewMarkdown ?{text.split("\n").map((line, index) => {
const lineNumber = index + 1;
diff --git a/packages/webui/src/client/features/workspace/ui/workspace-progress.css b/packages/webui/src/client/features/workspace/ui/workspace-progress.css
index 9a26c54ed..9881ca2d8 100644
--- a/packages/webui/src/client/features/workspace/ui/workspace-progress.css
+++ b/packages/webui/src/client/features/workspace/ui/workspace-progress.css
@@ -5,15 +5,14 @@
.webui-progress-overview-card, .webui-progress-scroll, .webui-subagents-scroll { scrollbar-width: none; -ms-overflow-style: none; scrollbar-gutter: auto; }
.webui-progress-overview-card::-webkit-scrollbar, .webui-progress-scroll::-webkit-scrollbar, .webui-subagents-scroll::-webkit-scrollbar { display: none; width: 0; height: 0; }
.webui-progress-overview-main { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 0 1 auto; flex-direction: column; overflow: visible; }
- .webui-progress-section { display: flex; width: 100%; height: 200px; min-width: 0; min-height: 58px; box-sizing: border-box; flex: 0 0 200px; flex-direction: column; overflow: hidden; }
- .webui-progress-section.webui-environment-panel { height: 160px; min-height: 58px; flex: 0 0 160px; }
+ .webui-progress-section { display: flex; width: 100%; height: auto; max-height: 200px; min-width: 0; min-height: 58px; box-sizing: border-box; flex: 0 0 auto; flex-direction: column; overflow: hidden; }
+ .webui-progress-section.webui-environment-panel { height: auto; max-height: none; min-height: 58px; flex: 0 0 auto; }
.webui-progress-section.webui-subagents-panel { height: auto; max-height: 200px; flex: 0 0 auto; }
- .webui-progress-section.webui-subagents-panel.is-collapsed { height: auto; max-height: 28px; min-height: 0; flex-basis: auto; }
- .webui-progress-section.is-collapsed { height: 28px; min-height: 0; flex-basis: 28px; }
+ .webui-progress-section.is-collapsed { height: auto; min-height: 0; flex-basis: auto; }
.webui-environment-panel, .webui-progress-panel, .webui-subagents-panel { min-width: 0; box-sizing: border-box; }
.webui-progress-section-body { width: 100%; min-height: 0; flex: 1 1 auto; }
- .webui-progress-section-body > .webui-disclosure-content { display: flex; height: 100%; flex-direction: column; }
- .webui-progress-scroll { height: 100%; min-height: 0; overflow-y: auto; overflow-x: hidden; }
+ .webui-progress-section-body > .webui-disclosure-content { display: flex; height: auto; flex-direction: column; }
+ .webui-progress-scroll { height: auto; max-height: 171px; min-height: 0; overflow-y: auto; overflow-x: hidden; }
.webui-progress-empty { margin: 0; padding: 0 6px 6px; color: var(--text_default_tertiary); font-size: var(--size_14); line-height: 20px; }
.webui-subagents-scroll { height: auto; max-height: 171px; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; -ms-overflow-style: none; scrollbar-gutter: auto; }
.webui-environment-panel + .webui-progress-panel { border-top: 1px solid var(--border_light); }
diff --git a/packages/webui/src/client/platform/wire.ts b/packages/webui/src/client/platform/wire.ts
index 44a0f4f44..23775b5b9 100644
--- a/packages/webui/src/client/platform/wire.ts
+++ b/packages/webui/src/client/platform/wire.ts
@@ -16,6 +16,7 @@
import type { WebuiDeliveryHandle } from "../features/turn/contracts/execution-port.js";
import type { WebuiStreamFrame } from "../../shared/contracts/stream.js";
import { randomId } from "../../shared/random-id.js";
+import { absoluteFileLocation } from "../projection/common/paths.js";
import type {
WebuiMiniAppPresentationIntent,
WebuiMiniAppPresentationState,
@@ -967,7 +968,8 @@ export function createWebuiWire({
// `http:`/`https:` respectively, and it must happen before the token is
// appended — the server checks the token on this route like any other.
const base = httpBaseFromWebsocketUrl(baseWebsocketUrl);
- const query = new URLSearchParams({ dir: workspaceDir, path: filePath, token });
+ const location = absoluteFileLocation(filePath);
+ const query = new URLSearchParams({ dir: location?.workspaceDir ?? workspaceDir, path: location?.path ?? filePath, token });
return `${base}/workspace-file?${query.toString()}`;
}
diff --git a/packages/webui/src/client/projection/common/paths.ts b/packages/webui/src/client/projection/common/paths.ts
index aae9642c7..af3064a80 100644
--- a/packages/webui/src/client/projection/common/paths.ts
+++ b/packages/webui/src/client/projection/common/paths.ts
@@ -104,6 +104,18 @@ export function isAbsolutePath(value: string): boolean {
return posix.startsWith("/") || /^[a-z]:\//iu.test(posix);
}
+/** Split an explicitly named local file for the directory-scoped read APIs. */
+export function absoluteFileLocation(value: string): { workspaceDir: string; path: string } | undefined {
+ const normalized = toPosixPath(value);
+ if (!isAbsolutePath(normalized) || normalized.startsWith("//")) return undefined;
+ const separator = normalized.lastIndexOf("/");
+ if (separator < 0 || separator === normalized.length - 1) return undefined;
+ const segments = normalized.slice(normalized.startsWith("/") ? 1 : 3).split("/");
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) return undefined;
+ const rootLength = normalized.startsWith("/") ? 1 : 3;
+ return { workspaceDir: normalized.slice(0, Math.max(separator, rootLength)), path: normalized.slice(separator + 1) };
+}
+
/**
* What an absolute path's root is spelled as.
*
diff --git a/packages/webui/src/client/styles/markdown.css b/packages/webui/src/client/styles/markdown.css
index b493ed32e..6ce2d0a1d 100644
--- a/packages/webui/src/client/styles/markdown.css
+++ b/packages/webui/src/client/styles/markdown.css
@@ -93,7 +93,9 @@
}
.webui-markdown a.webui-message-file-link {
color: var(--text_default_accent);
+ align-items: baseline;
}
+ .webui-markdown a.webui-message-file-link > [aria-hidden="true"] { align-self: center; }
.webui-markdown a:hover {
color: var(--text_default_accent);
text-decoration: underline;
diff --git a/packages/webui/src/client/ui/dialog.tsx b/packages/webui/src/client/ui/dialog.tsx
index f9d1ed5dd..89d43fd15 100644
--- a/packages/webui/src/client/ui/dialog.tsx
+++ b/packages/webui/src/client/ui/dialog.tsx
@@ -51,6 +51,7 @@ export interface WebuiDialogProps {
readonly closeLabel?: string;
readonly closeTestId?: string;
readonly dismissOnEscape?: boolean;
+ /** Defaults to true; pass false when dismissal must be blocked. */
readonly dismissOnBackdrop?: boolean;
readonly width?: WebuiDialogWidth;
readonly portalTarget: HTMLElement | null;
@@ -108,7 +109,7 @@ export function WebuiDialog({
closeLabel = "关闭",
closeTestId,
dismissOnEscape = false,
- dismissOnBackdrop = false,
+ dismissOnBackdrop = true,
width = "standard",
portalTarget,
testId,
diff --git a/packages/webui/src/runtime/harness/workspace.ts b/packages/webui/src/runtime/harness/workspace.ts
index d03111f4f..0600158ba 100644
--- a/packages/webui/src/runtime/harness/workspace.ts
+++ b/packages/webui/src/runtime/harness/workspace.ts
@@ -121,25 +121,28 @@ export function createWorkspaceAdapter(
if (
result.type !== "binary" ||
result.error !== "Path traversal denied" ||
- request.path.includes("/") ||
- request.path.includes("\\") ||
+ path.isAbsolute(request.path) ||
+ request.path.replace(/\\/gu, "/").split("/").some((segment) => !segment || segment === "." || segment === "..") ||
!cliService.searchWorkspaceFiles
) return result;
- // Assistant replies sometimes link only a basename (for example,
- // `SessionComposer.tsx`) even when the file lives in a nested package.
- // Resolve only an exact, unique basename; never guess among duplicates.
+ // A reply may omit the package prefix. Only a unique segment-aligned
+ // suffix can complete it; the existing bare-name behavior stays intact.
try {
+ const suffix = request.path.replace(/\\/gu, "/");
const matches = await cliService.searchWorkspaceFiles({
workspaceDir: request.workspaceDir,
- query: request.path,
+ query: pathPosix.basename(suffix),
limit: 100,
});
const exactMatches = matches.filter(
- (candidate) => pathPosix.basename(candidate.replace(/\\/gu, "/")) === request.path,
+ (candidate) => {
+ const normalized = candidate.replace(/\\/gu, "/");
+ return normalized === suffix || normalized.endsWith(`/${suffix}`);
+ },
);
const [exactMatch] = exactMatches;
- if (exactMatches.length === 1 && exactMatch) {
+ if (exactMatches.length === 1 && exactMatch && (!suffix.includes("/") || matches.length < 100)) {
const resolved = (await cliService.readWorkspaceFile!({
...request,
path: exactMatch,
@@ -150,8 +153,10 @@ export function createWorkspaceAdapter(
type: "text",
content: "",
error:
- exactMatches.length > 1
- ? `工作区中有多个名为 ${request.path} 的文件,请使用完整相对路径。`
+ exactMatches.length > 1 || (suffix.includes("/") && matches.length >= 100)
+ ? suffix.includes("/")
+ ? `工作区中有多个匹配 ${request.path} 的文件,请使用完整相对路径。`
+ : `工作区中有多个名为 ${request.path} 的文件,请使用完整相对路径。`
: `工作区中没有找到 ${request.path}。`,
};
} catch {
diff --git a/packages/webui/test/browser/account-login.spec.mjs b/packages/webui/test/browser/account-login.spec.mjs
index 868fba1f9..4646c589b 100644
--- a/packages/webui/test/browser/account-login.spec.mjs
+++ b/packages/webui/test/browser/account-login.spec.mjs
@@ -33,6 +33,39 @@ test.beforeEach(async ({ page }) => {
page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); });
});
+for (const theme of ["light", "dark"]) {
+test(`usage hover keeps the subscription label stable during refresh (${theme})`, async ({ page }) => {
+ await page.addInitScript((value) => window.localStorage.setItem("webui-theme", value), theme);
+ await configureFixture(page, () => window.__fixture.setUsageQuotaResult({ signedIn: true, hasTokenPlan: true, tokenPlanTier: "Explore" }));
+ await openApp(page);
+ const trigger = page.getByTestId("sidebar-user-menu-trigger");
+ await trigger.click();
+ const usage = page.getByTestId("user-menu-usage");
+ await usage.hover();
+ await expect(trigger.locator("small")).toHaveText("Explore");
+ await trigger.evaluate((element) => {
+ window.__railLabelChanges = [];
+ new MutationObserver(() => window.__railLabelChanges.push(element.querySelector("small")?.textContent ?? null)).observe(element, { childList: true, subtree: true, characterData: true });
+ });
+ const requestsBefore = await page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "getUsageQuota").length);
+ for (let index = 0; index < 12; index += 1) {
+ await page.getByTestId("user-menu-settings").hover();
+ await usage.hover();
+ }
+ expect(await page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "getUsageQuota").length)).toBe(requestsBefore);
+ expect(await page.evaluate(() => window.__railLabelChanges.filter((label) => label !== "Explore"))).toEqual([]);
+ await page.mouse.move(700, 150);
+ await expect(page.getByTestId("usage-popover")).toHaveCount(0);
+ await page.evaluate(() => window.__fixture.delayEvery("getUsageQuota", {}));
+ await usage.hover();
+ await expect(page.getByTestId("usage-popover-loading")).toBeVisible();
+ await expect(trigger.locator("small")).toHaveText("Explore");
+ await page.screenshot({ path: test.info().outputPath(`subscription-refresh-${theme}.png`), animations: "disabled" });
+ await page.evaluate(() => window.__fixture.resolveAllPending("getUsageQuota", {}, { signedIn: true, hasTokenPlan: true, tokenPlanTier: "Build" }));
+ await expect(trigger.locator("small")).toHaveText("Build");
+});
+}
+
const loginPage = (page) => page.locator('[data-testid="account-login-page"]');
async function openFromUserMenu(page, testId) {
diff --git a/packages/webui/test/browser/feedback.spec.mjs b/packages/webui/test/browser/feedback.spec.mjs
index 2192a9a27..dd196ee96 100644
--- a/packages/webui/test/browser/feedback.spec.mjs
+++ b/packages/webui/test/browser/feedback.spec.mjs
@@ -68,8 +68,10 @@ test("a dynamic field error is announced once and stays associated with its cont
expect(alertId, "the error has an id a control can point at").toBeTruthy();
expect(describedBy.split(" "), "the control is described by the error").toContain(alertId);
await expect(endpoint).toHaveAttribute("aria-invalid", "true");
- // This production dialog enables Escape and leaves backdrop dismissal off.
+ // The shared dialog dismisses on backdrop clicks by default.
await page.mouse.click(6, 6);
+ await expect(form).toHaveCount(0);
+ await page.getByRole("dialog", { name: "设置" }).getByRole("button", { name: "添加模型", exact: true }).click();
await expect(form).toBeVisible();
await page.keyboard.press("Escape");
await expect(form).toHaveCount(0);
diff --git a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Agent-add-form-is-centered-fields-are-aligned-and-backdrop-dismissal-works-1-linux.png b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Agent-add-form-is-centered-fields-are-aligned-and-backdrop-dismissal-works-1-linux.png
index a9f960c82..b41953c9d 100644
Binary files a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Agent-add-form-is-centered-fields-are-aligned-and-backdrop-dismissal-works-1-linux.png and b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Agent-add-form-is-centered-fields-are-aligned-and-backdrop-dismissal-works-1-linux.png differ
diff --git a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Git-plugin-import-form-is-an-accessible-viewport-overlay-and-previews-local-fixture-data-1-linux.png b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Git-plugin-import-form-is-an-accessible-viewport-overlay-and-previews-local-fixture-data-1-linux.png
index 2752a22e8..57da72331 100644
Binary files a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Git-plugin-import-form-is-an-accessible-viewport-overlay-and-previews-local-fixture-data-1-linux.png and b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Git-plugin-import-form-is-an-accessible-viewport-overlay-and-previews-local-fixture-data-1-linux.png differ
diff --git a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/MCP-add-form-validates-inputs-switches-transport-and-sends-advanced-HTTP-config-1-linux.png b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/MCP-add-form-validates-inputs-switches-transport-and-sends-advanced-HTTP-config-1-linux.png
index 9fee0bc12..7dcbd3894 100644
Binary files a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/MCP-add-form-validates-inputs-switches-transport-and-sends-advanced-HTTP-config-1-linux.png and b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/MCP-add-form-validates-inputs-switches-transport-and-sends-advanced-HTTP-config-1-linux.png differ
diff --git a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Skill-add-form-is-centered-usable-and-submits-through-plugin-management-1-linux.png b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Skill-add-form-is-centered-usable-and-submits-through-plugin-management-1-linux.png
index b960e15e4..2b5f91f24 100644
Binary files a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Skill-add-form-is-centered-usable-and-submits-through-plugin-management-1-linux.png and b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs-snapshots/Skill-add-form-is-centered-usable-and-submits-through-plugin-management-1-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1024-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1024-linux.png
index a00b0673a..3898a34e5 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1024-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1024-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1280-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1280-linux.png
index 265570ed8..b234dad6f 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1280-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1280-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1440-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1440-linux.png
index eb5822246..71398df37 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1440-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-1440-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-768-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-768-linux.png
index 5c73c430e..aef0f9768 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-768-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-768-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-900-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-900-linux.png
index 9b030f2ab..9e530cf47 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-900-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/shell-at-900-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1024-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1024-linux.png
index c72b95805..b9ced8374 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1024-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1024-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1440-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1440-linux.png
index 4cd9b7d10..c2ac36c09 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1440-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-1440-linux.png differ
diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-768-linux.png b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-768-linux.png
index becbae4cc..a1c1c7014 100644
Binary files a/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-768-linux.png and b/packages/webui/test/browser/responsive-baselines.spec.mjs-snapshots/workspace-panel-at-768-linux.png differ
diff --git a/packages/webui/test/browser/transcript-file-media.spec.mjs b/packages/webui/test/browser/transcript-file-media.spec.mjs
index c69017879..02afcc749 100644
--- a/packages/webui/test/browser/transcript-file-media.spec.mjs
+++ b/packages/webui/test/browser/transcript-file-media.spec.mjs
@@ -56,6 +56,40 @@ function deliveredRow(page) {
return page.locator('[data-testid="delivered-assets-list"] .webui-delivered-asset');
}
+for (const theme of ["light", "dark"]) {
+test(`outside-workspace deliveries retain typed links, align labels and open previews (${theme})`, async ({ page }) => {
+ await page.addInitScript((value) => window.localStorage.setItem("webui-theme", value), theme);
+ await page.route("**/workspace-file**", (route) => route.fulfill({ status: 200, contentType: "image/png", body: PNG }));
+ await configureFixture(page, () => {
+ window.__fixture.setResponse("readWorkspaceFile", { type: "text", content: "# Outside architecture" });
+ window.__fixture.setPage("A", { messages: [{ msgId: "outside-files", role: "assistant", msgContent: ' ' }], hasMore: false });
+ });
+ await openApp(page, "#session=A");
+ const rows = deliveredRow(page);
+ const imageLink = rows.getByRole("link", { name: "架构设计图" });
+ await expect(imageLink.locator("svg.tabler-icon-file-type-png")).toHaveCount(1);
+ await expect(rows.locator("img")).toHaveJSProperty("naturalWidth", 1);
+ const imageUrl = new URL(await rows.locator("img").getAttribute("src"), page.url());
+ expect(imageUrl.searchParams.get("dir")).toBe("/synthetic/artifacts");
+ expect(imageUrl.searchParams.get("path")).toBe("architecture.png");
+ for (const text of ["架构设计图", "架构文档", "远程文件"]) {
+ const delta = await rows.filter({ hasText: text }).evaluate((row) => {
+ const icon = row.querySelector("svg").getBoundingClientRect();
+ const label = row.querySelector(".webui-inline > span:last-child").getBoundingClientRect();
+ return Math.abs(icon.top + icon.height / 2 - label.top - label.height / 2);
+ });
+ expect(delta).toBeLessThanOrEqual(1);
+ }
+ await imageLink.click();
+ await expect(page.getByTestId("workspace-file-preview").locator("img")).toHaveJSProperty("naturalWidth", 1);
+ await rows.getByRole("link", { name: "架构文档" }).click();
+ await expect(page.getByTestId("workspace-file-preview")).toContainText("Outside architecture");
+ const reads = await page.evaluate(() => window.__fixture.requests.filter((request) => request.operation === "readWorkspaceFile").map((request) => request.body));
+ expect(reads).toContainEqual({ workspaceDir: "/synthetic/artifacts", path: "design.md" });
+ await page.screenshot({ path: test.info().outputPath(`outside-files-${theme}.png`), animations: "disabled" });
+});
+}
+
test("a delivered picture appears after one failed load, without a reload", async ({ page }) => {
let attempts = 0;
await page.route("**/workspace-file**", async (route) => {
@@ -221,6 +255,31 @@ test("a delivered source file stays a plain link", async ({ page }) => {
expect(attempts).toBe(0);
});
+for (const theme of ["light", "dark"]) {
+test(`an inline file label shares the surrounding prose baseline (${theme})`, async ({ page }) => {
+ await page.addInitScript((value) => window.localStorage.setItem("webui-theme", value), theme);
+ await configureFixture(page, () => window.__fixture.setPage("A", {
+ messages: [{ msgId: "inline-baseline", role: "assistant", msgContent: "正文 before `runtime/port.ts` after 是唯一能力边界。\n\n### 标题 before `runtime/port.ts` after 是唯一能力边界。" }], hasMore: false,
+ }));
+ await openApp(page, "#session=A");
+ const links = page.locator("a.webui-message-file-link");
+ await expect(links).toHaveCount(2);
+ for (const link of await links.all()) {
+ const geometry = await link.evaluate((anchor) => {
+ const text = anchor.previousSibling;
+ const index = text.textContent.indexOf("before");
+ const prose = document.createRange();
+ prose.setStart(text, index); prose.setEnd(text, index + 6);
+ const label = document.createRange();
+ label.selectNodeContents(anchor.lastElementChild);
+ return { prose: prose.getBoundingClientRect().bottom, label: label.getBoundingClientRect().bottom };
+ });
+ expect(Math.abs(geometry.prose - geometry.label)).toBeLessThanOrEqual(1);
+ }
+ await page.locator(".webui-markdown").filter({ hasText: "正文 before" }).screenshot({ path: test.info().outputPath(`inline-file-baseline-${theme}.png`), animations: "disabled" });
+});
+}
+
test("a file link icon is vertically centered with its label", async ({ page }) => {
await page.route("**/workspace-file**", async (route) =>
route.fulfill({ status: 200, contentType: "image/svg+xml", body: SVG }),
diff --git a/packages/webui/test/browser/user-menu-shutdown.spec.mjs b/packages/webui/test/browser/user-menu-shutdown.spec.mjs
index 4b736e0a0..b5c859141 100644
--- a/packages/webui/test/browser/user-menu-shutdown.spec.mjs
+++ b/packages/webui/test/browser/user-menu-shutdown.spec.mjs
@@ -4,9 +4,8 @@
// item asks first: the confirmation is what the person answers, and the
// request is what the answer asks for.
//
-// The spec stops at the confirmation. The request itself ends the server the
-// suite runs against, so a spec that confirmed it would take the suite down
-// with it.
+// Requests in this browser spec are intercepted; actual process exit is
+// exercised by the isolated development-entry integration test.
import { expect } from "@playwright/test";
@@ -29,3 +28,46 @@ test("the user menu asks before it stops the server", async ({ page }) => {
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(page.getByText("关闭本地 WebUI 服务")).toBeVisible();
});
+
+test("the shutdown confirmation dismisses on a backdrop click by default", async ({ page }) => {
+ await openApp(page);
+ await page.locator('[data-testid="sidebar-user-menu-trigger"], [data-testid="sidebar-user-menu-trigger-rail"]').first().click();
+ await page.getByTestId("user-menu-shutdown").click();
+ const dialog = page.getByRole("alertdialog");
+ await expect(dialog).toBeVisible();
+ await page.locator('[data-webui-dialog-scrim="true"]').click({ position: { x: 8, y: 8 } });
+ await expect(dialog).toHaveCount(0);
+});
+
+test("a pending shutdown keeps the dialog open, then closes after acknowledgement", async ({ page }) => {
+ let release;
+ const pending = new Promise((resolve) => { release = resolve; });
+ await page.route("**/webui-shutdown?*", async (route) => {
+ await pending;
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, closing: true }) });
+ });
+ await openApp(page);
+ await page.locator('[data-testid="sidebar-user-menu-trigger"], [data-testid="sidebar-user-menu-trigger-rail"]').first().click();
+ await page.getByTestId("user-menu-shutdown").click();
+ const dialog = page.getByRole("alertdialog");
+ await dialog.getByRole("button", { name: "关闭服务", exact: true }).click();
+ await expect(dialog.getByRole("button", { name: "取消", exact: true })).toBeDisabled();
+ await page.locator('[data-webui-dialog-scrim="true"]').click({ position: { x: 8, y: 8 } });
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeVisible();
+ release();
+ await expect(dialog).toHaveCount(0);
+ await page.screenshot({ path: test.info().outputPath("shutdown-acknowledged.png"), animations: "disabled" });
+});
+
+test("a refused shutdown can be dismissed by clicking outside after the error", async ({ page }) => {
+ await page.route("**/webui-shutdown?*", (route) => route.fulfill({ status: 404, body: "Not Found" }));
+ await openApp(page);
+ await page.locator('[data-testid="sidebar-user-menu-trigger"], [data-testid="sidebar-user-menu-trigger-rail"]').first().click();
+ await page.getByTestId("user-menu-shutdown").click();
+ const dialog = page.getByRole("alertdialog");
+ await dialog.getByRole("button", { name: "关闭服务", exact: true }).click();
+ await expect(dialog).toContainText("当前 WebUI 不支持关闭服务。");
+ await page.locator('[data-webui-dialog-scrim="true"]').click({ position: { x: 8, y: 8 } });
+ await expect(dialog).toHaveCount(0);
+});
diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/progress-panel-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/progress-panel-linux.png
index 5df2bed6f..caf88d951 100644
Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/progress-panel-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/progress-panel-linux.png differ
diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/shell-and-rail-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/shell-and-rail-linux.png
index eb5822246..71398df37 100644
Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/shell-and-rail-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/shell-and-rail-linux.png differ
diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/transcript-and-composer-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/transcript-and-composer-linux.png
index aee09c15b..5cc20c065 100644
Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/transcript-and-composer-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/transcript-and-composer-linux.png differ
diff --git a/packages/webui/test/browser/workspace-progress-live.spec.mjs b/packages/webui/test/browser/workspace-progress-live.spec.mjs
index 28ee37fb0..d56fe11d1 100644
--- a/packages/webui/test/browser/workspace-progress-live.spec.mjs
+++ b/packages/webui/test/browser/workspace-progress-live.spec.mjs
@@ -91,6 +91,36 @@ test("environment loading skeleton geometry", async ({ page }, testInfo) => {
await testInfo.attach("environment-loading", { body: await environment.screenshot(), contentType: "image/png" });
});
+for (const theme of ["light", "dark"]) {
+test(`compact progress fits its contents without clipping terminal hover (${theme})`, async ({ page }) => {
+ await page.addInitScript(value => window.localStorage.setItem("webui-theme", value), theme);
+ await configureFixture(page, () => window.__fixture.setResponse("getWorkspaceEnvironment", { isGitRepo: true, branch: "synthetic-progress-layout", changedFiles: 1, insertions: 3, deletions: 1, lineStatsStatus: "ready" }));
+ await openApp(page);
+ await attachLiveTurn(page);
+ await emitAgentMessage(page, "A", { msg_id: "compact-todos", msg_content: JSON.stringify({ eventType: "todo_updated", todos: Array.from({ length: 4 }, (_, index) => ({ content: `已完成步骤 ${index + 1}`, status: "completed" })) }) });
+ const terminal = page.getByRole("button", { name: "打开终端", exact: true });
+ await expect(page.locator(".webui-environment-panel")).toContainText("synthetic-progress-layout");
+ await terminal.hover();
+ const geometry = await page.locator(".webui-progress-overview-card").evaluate(card => {
+ const environment = card.querySelector(".webui-environment-panel").getBoundingClientRect();
+ const action = [...card.querySelectorAll(".webui-environment-action")].at(-1).getBoundingClientRect();
+ const rows = card.querySelectorAll(".webui-progress-row");
+ if (rows.length !== 4) throw new Error(`Expected four todos, got ${rows.length}`);
+ const last = rows[3].getBoundingClientRect();
+ const progress = card.querySelector(".webui-progress-panel").getBoundingClientRect();
+ const button = [...card.querySelectorAll(".webui-environment-action")].at(-1);
+ let clippingBottom = environment.bottom;
+ for (let parent = button.parentElement; parent && card.contains(parent); parent = parent.parentElement) {
+ if (getComputedStyle(parent).overflowY !== "visible") clippingBottom = Math.min(clippingBottom, parent.getBoundingClientRect().bottom);
+ }
+ return { clipped: action.bottom - clippingBottom, blank: progress.bottom - last.bottom };
+ });
+ expect(geometry.clipped).toBeLessThanOrEqual(0);
+ expect(geometry.blank).toBeLessThanOrEqual(12);
+ await page.locator(".webui-progress-overview-card").screenshot({ path: test.info().outputPath(`compact-progress-${theme}.png`), animations: "disabled" });
+});
+}
+
test("the open terminal action uses a terminal icon", async ({ page }) => {
await configureFixture(page, () => window.__fixture.setResponse("getWorkspaceEnvironment", {
isGitRepo: true,
@@ -108,6 +138,39 @@ test("the open terminal action uses a terminal icon", async ({ page }) => {
await expect(action.locator("svg path")).toHaveCount(3);
});
+test("environment and progress collapse through the shared disclosure height animation", async ({ page }) => {
+ await configureFixture(page, () => window.__fixture.setResponse("getWorkspaceEnvironment", { isGitRepo: true, branch: "synthetic-motion", changedFiles: 0, insertions: 0, deletions: 0, lineStatsStatus: "ready" }));
+ await openApp(page);
+ await attachLiveTurn(page);
+ await emitAgentMessage(page, "A", { msg_id: "motion-todos", msg_content: JSON.stringify({ eventType: "todo_updated", todos: Array.from({ length: 4 }, (_, index) => ({ content: `步骤 ${index + 1}`, status: "pending" })) }) });
+ await expect(page.locator(".webui-progress-row")).toHaveCount(4);
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ for (const selector of [".webui-environment-panel", ".webui-progress-panel"]) {
+ const section = page.locator(selector);
+ for (const expanded of [true, false]) {
+ await section.evaluate(element => {
+ window.__sectionMotionDone = false;
+ window.__sectionHeights = [];
+ element.querySelector(".webui-workspace-section-title").addEventListener("click", () => {
+ const start = performance.now();
+ const sample = () => {
+ window.__sectionHeights.push(element.getBoundingClientRect().height);
+ if (performance.now() - start < 300) requestAnimationFrame(sample);
+ else window.__sectionMotionDone = true;
+ };
+ sample();
+ }, { once: true });
+ });
+ await section.getByRole("button", { expanded }).first().click();
+ await expect.poll(() => page.evaluate(() => window.__sectionMotionDone)).toBe(true);
+ const heights = await page.evaluate(() => window.__sectionHeights);
+ const initial = heights[0], final = heights.at(-1);
+ expect(Math.abs(initial - final)).toBeGreaterThan(20);
+ expect(heights.filter(height => height > Math.min(initial, final) + 1 && height < Math.max(initial, final) - 1).length, JSON.stringify(heights)).toBeGreaterThan(1);
+ }
+ }
+});
+
test("the workspace commit dialog completes the shared exit motion before unmounting", async ({ page }) => {
await configureFixture(page, () => window.__fixture.setResponse("getWorkspaceEnvironment", {
isGitRepo: true,
diff --git a/packages/webui/test/integration/loopback-harness-path.test.ts b/packages/webui/test/integration/loopback-harness-path.test.ts
index 871fb3df7..fc8693369 100644
--- a/packages/webui/test/integration/loopback-harness-path.test.ts
+++ b/packages/webui/test/integration/loopback-harness-path.test.ts
@@ -27,6 +27,10 @@
// No test in this repository can, and the plan no longer implies one does.
import { once } from "node:events";
+import { spawn } from "node:child_process";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import type { RawData } from "ws";
@@ -41,6 +45,10 @@ import type {
import type { WebuiSessionPage } from "../../src/shared/contracts/session.js";
import { createWebuiWire, type WebuiSocket } from "../../src/client/platform/wire.js";
import { createWebuiSettingsWorkflows } from "../../src/client/features/settings/application/settings-workflows.js";
+import { createWorkspaceAdapter } from "../../src/runtime/harness/workspace.js";
+import { readWorkspaceFile } from "../../../local-runtime-v2/src/service/workspace/operations/workspace-path.js";
+import { parseWebuiMessageFileReference } from "../../src/client/features/transcript/projection/message-file-reference.js";
+import { initialWorkspacePanelState, reduceWorkspacePanelState } from "../../src/client/features/workspace/projection/workspace-panel-state.js";
interface TestPort extends WebuiHarnessPort {
/** Every harness method the run actually reached, in call order. */
@@ -254,6 +262,96 @@ describe("loopback to harness-port evidence", () => {
service = undefined;
});
+ it("the development entry acknowledges shutdown and exits its isolated process", async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), "webui-dev-shutdown-"));
+ await writeFile(path.join(directory, "config.yaml"), "beta:\n mcodeTools: false\n");
+ const child = spawn(process.execPath, ["--import", "tsx/esm", "scripts/run-webui-server.mjs"], {
+ cwd: process.cwd(),
+ env: { ...process.env, MINIMAX_DATA_DIR: directory, WEBUI_SERVER_TSX: "1", WEBUI_SERVER_PORT: "0", WEBUI_MINIAPP_PORT: "0", WEBUI_HOST: "127.0.0.1", TSX_TSCONFIG_PATH: path.resolve("tsconfig.standalone.json") },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ const exited = once(child, "exit");
+ // The launcher prints its synthetic credential. Capture it for the request,
+ // but never include the process output in test diagnostics.
+ let output = "";
+ child.stderr.resume();
+ const ready = new Promise<{ port: string; token: string }>((resolve, reject) => {
+ child.on("error", reject);
+ child.stdout.on("data", (chunk) => {
+ output += String(chunk);
+ const port = output.match(/server listening at ws:\/\/127\.0\.0\.1:(\d+)/u)?.[1];
+ const token = output.match(/credential token: (\S+)/u)?.[1];
+ if (port && token) resolve({ port, token });
+ });
+ child.once("exit", (code) => reject(new Error(`Development server exited before readiness (${code})`)));
+ });
+ const bounded = async (work: Promise, label: string): Promise => {
+ let timer: ReturnType | undefined;
+ try { return await Promise.race([work, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(label)), 20_000); })]); }
+ finally { clearTimeout(timer); }
+ };
+ try {
+ const { port, token } = await bounded(ready, "Development server did not become ready");
+ const unauthorized = await fetch(`http://127.0.0.1:${port}/webui-shutdown`, { method: "POST" });
+ expect(unauthorized.status).toBe(401);
+ expect(child.exitCode).toBeNull();
+ const response = await fetch(`http://127.0.0.1:${port}/webui-shutdown?token=${encodeURIComponent(token)}`, { method: "POST" });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ ok: true, closing: true });
+ expect(await bounded(exited, "Development server did not exit after shutdown")).toEqual([0, null]);
+ await expect(fetch(`http://127.0.0.1:${port}/health`)).rejects.toThrow();
+ } finally {
+ if (child.exitCode === null && child.signalCode === null) {
+ child.kill("SIGTERM");
+ await bounded(exited, "Development server cleanup timed out");
+ }
+ await rm(directory, { recursive: true, force: true });
+ }
+ }, 45_000);
+
+ it("opens an external absolute reference through the real file reader and authenticated byte route", async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), "webui-file-reference-"));
+ const workspaceDir = path.join(directory, "repo");
+ const artifacts = path.join(directory, "artifacts");
+ try {
+ await mkdir(workspaceDir);
+ await mkdir(artifacts);
+ await writeFile(path.join(artifacts, "design.md"), "# External architecture\n");
+ const image = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", "base64");
+ await writeFile(path.join(artifacts, "图 1.png"), image);
+ // Only the CliService shell is supplied. The file read below is the
+ // production filesystem implementation, with its real-path checks.
+ const adapter = createWorkspaceAdapter({ apiHost: { close: async () => {} }, cliService: {
+ readWorkspaceFile: ({ workspaceDir, path }: { workspaceDir: string; path: string }) => readWorkspaceFile(workspaceDir, path),
+ } as never });
+ const port = { ...scriptedPort(), readWorkspaceFile: adapter.readWorkspaceFile };
+ service = new WebuiService({ port, operations: createOperationRegistry(port), credential: { token: "external-file-synthetic-token" } });
+ const info = await service.start();
+ const wire = createWebuiWire({ websocketUrl: `ws://127.0.0.1:${info.tcpPort}`, token: info.credential.token, webSocket: LoopbackSocket });
+ const reference = parseWebuiMessageFileReference(`${path.join(artifacts, "design.md")}:1`, workspaceDir)!;
+ const state = reduceWorkspacePanelState(initialWorkspacePanelState, { type: "open-file", sessionId: "external-file", workspaceDir, ...reference });
+ const content = state.tabs[0]?.content;
+ if (content?.kind !== "file-preview") throw new Error("External link did not open a file tab");
+ expect(content).toMatchObject({ workspaceDir: artifacts, path: "design.md", lineStart: 1 });
+ await expect(wire.request("readWorkspaceFile", { workspaceDir: content.workspaceDir, path: content.path })).resolves.toEqual({ type: "text", content: "# External architecture\n" });
+ const url = wire.workspaceFileUrl({ workspaceDir, path: path.join(artifacts, "图 1.png") });
+ const response = await fetch(url);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("image/png");
+ expect(Buffer.from(await response.arrayBuffer())).toEqual(image);
+ const range = await fetch(url, { headers: { Range: "bytes=0-7" } });
+ expect(range.status).toBe(206);
+ expect(Buffer.from(await range.arrayBuffer())).toEqual(image.subarray(0, 8));
+ const denied = new URL(url);
+ denied.searchParams.delete("token");
+ expect((await fetch(denied)).status).toBe(401);
+ } finally {
+ await service?.close();
+ service = undefined;
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
it("V20 reconciles a committed mutation after the real socket loses its response, without retrying", async () => {
let content = "before";
let writes = 0;
diff --git a/packages/webui/test/unit/transcript-file-anchor-anchors.test.tsx b/packages/webui/test/unit/transcript-file-anchor-anchors.test.tsx
index 0a9080d92..e017bfca2 100644
--- a/packages/webui/test/unit/transcript-file-anchor-anchors.test.tsx
+++ b/packages/webui/test/unit/transcript-file-anchor-anchors.test.tsx
@@ -61,6 +61,13 @@ const markdownSource = readFileSync(
);
describe("SPEC-C C-5 transcript file/line anchors", () => {
+ it("retains an absolute file outside the workspace, including its line target", () => {
+ expect(parseWebuiMessageFileReference("/artifacts/design.md:12", "/repo")).toEqual({ path: "/artifacts/design.md", lineStart: 12 });
+ expect(parseWebuiMessageFileReference("C:/artifacts/design.md", "C:/repo")).toEqual({ path: "C:/artifacts/design.md" });
+ expect(parseWebuiMessageFileReference("/artifacts/../secret.md", "/repo")).toBeUndefined();
+ expect(render("[Design](/artifacts/design.md:12)", () => undefined)).toContain('data-webui-file-reference="/artifacts/design.md"');
+ expect(render("[Design](C:/artifacts/design.md)", () => undefined)).toContain('data-webui-file-reference="C:/artifacts/design.md"');
+ });
it("renders a recognized inline-code path with a line number as a clickable file reference", () => {
const html = render("See `path/to/file.ts:120` for the failing case.", () => undefined);
@@ -220,7 +227,7 @@ describe("media file links", () => {
expect(renderServed("`schema.table_name`")).not.toContain("data-webui-file-media");
});
- it("never requests remote or outside-workspace image bytes", () => {
+ it("never requests remote, traversing or unsafe image references", () => {
expect(renderServed("")).not.toContain("
{
expect(wire.workspaceFileUrl({ workspaceDir: "/w", path: "a.ts" })).toBe(
"https://runtime.example:8443/workspace-file?dir=%2Fw&path=a.ts&token=fixture-token",
);
+ expect(wire.workspaceFileUrl({ workspaceDir: "/w", path: "/artifacts/图 1.png" })).toBe(
+ "https://runtime.example:8443/workspace-file?dir=%2Fartifacts&path=%E5%9B%BE+1.png&token=fixture-token",
+ );
expect(wire.miniappStandaloneUrl({ pluginId: "p" })).toBe(
"https://runtime.example:8443/miniapp?token=fixture-token&plugin=p",
);
diff --git a/packages/webui/test/unit/webui-w0-css-structure.test.ts b/packages/webui/test/unit/webui-w0-css-structure.test.ts
index 3ab0ed9e1..1ea913a65 100644
--- a/packages/webui/test/unit/webui-w0-css-structure.test.ts
+++ b/packages/webui/test/unit/webui-w0-css-structure.test.ts
@@ -642,10 +642,11 @@ describe("W0 · structural declarations W5 must preserve", () => {
const sectionTitleText = winning(".webui-workspace-section-title > [data-workspace-section-title]");
expect(declaration(sectionTitleText.body, "flex")).toBe("1 1 auto");
expect(declaration(sectionTitleText.body, "min-width")).toBe("0");
- expect(declaration(winning(".webui-progress-section").body, "height")).toBe("200px");
- expect(declaration(winning(".webui-progress-section.is-collapsed").body, "height")).toBe("28px");
- expect(declaration(winning(".webui-progress-section.webui-environment-panel").body, "height")).toBe("160px");
- expect(declaration(winning(".webui-progress-section.webui-environment-panel").body, "flex")).toBe("0 0 160px");
+ expect(declaration(winning(".webui-progress-section").body, "height")).toBe("auto");
+ expect(declaration(winning(".webui-progress-section").body, "max-height")).toBe("200px");
+ expect(declaration(winning(".webui-progress-section.is-collapsed").body, "height")).toBe("auto");
+ expect(declaration(winning(".webui-progress-section.webui-environment-panel").body, "height")).toBe("auto");
+ expect(declaration(winning(".webui-progress-section.webui-environment-panel").body, "flex")).toBe("0 0 auto");
const environmentLoading = winning(".webui-environment-loading");
expect(declaration(environmentLoading.body, "min-height")).toBe("132px");
expect(declaration(environmentLoading.body, "box-sizing")).toBe("border-box");
@@ -663,7 +664,7 @@ describe("W0 · structural declarations W5 must preserve", () => {
expect(declarationsForTargeting(".webui-subagents-scroll::-webkit-scrollbar")).toContain("display: none");
expect(
declaration(winning(".webui-progress-section.is-collapsed").body, "flex-basis"),
- ).toBe("28px");
+ ).toBe("auto");
const scroll = declarationsFor(
".webui-session-layout .webui-session-transcript-scroll",
diff --git a/packages/webui/test/unit/workspace-panel-state.test.ts b/packages/webui/test/unit/workspace-panel-state.test.ts
index 8168cca3a..ace1d8192 100644
--- a/packages/webui/test/unit/workspace-panel-state.test.ts
+++ b/packages/webui/test/unit/workspace-panel-state.test.ts
@@ -314,6 +314,11 @@ describe("right workspace panel navigation", () => {
expect(source).toContain('class="webui-file-code"');
});
+ it("opens an absolute file through its containing directory and preserves line selection", () => {
+ const state = reduceWorkspacePanelState(initialWorkspacePanelState, { type: "open-file", sessionId: "s1", workspaceDir: "/repo", path: "/artifacts/design.md", lineStart: 12 });
+ expect(state.tabs[0]?.content).toEqual({ kind: "file-preview", sessionId: "s1", workspaceDir: "/artifacts", path: "design.md", lineStart: 12 });
+ });
+
it("isolates identical relative files by session and workspace and rejects traversal", () => {
let state = initialWorkspacePanelState;
state = reduceWorkspacePanelState(state, { type: "open-file", sessionId: "s1", workspaceDir: "/a", path: "index.ts" });
@@ -334,6 +339,18 @@ describe("right workspace panel navigation", () => {
expect(state.tabs[1]?.content).toMatchObject({ source: "turn", messageId: "group-1", assistantMessageId: "assistant-9", turnId: "turn-3", changeSetId: "change-7" });
});
+ it("does not mislabel a failed source-file read as binary content", () => {
+ const content = { kind: "file-preview" as const, sessionId: "s1", workspaceDir: "/repo", path: "missing.ts" };
+ const markup = renderToStaticMarkup(createElement(WebuiFilePreview, { tabId: "missing", content, codeMode: true, result: { loading: false, content: { type: "binary", content: "", error: "Path traversal denied" } } }));
+ expect(markup).not.toContain("无法在文本预览中显示二进制文件");
+ });
+
+ it("uses the completed file path for media bytes", () => {
+ const content = { kind: "file-preview" as const, sessionId: "s1", workspaceDir: "/repo", path: "out/chart.png" };
+ const markup = renderToStaticMarkup(createElement(WebuiFilePreview, { tabId: "chart", content, codeMode: false, result: { loading: false, content: { type: "binary", content: "", mimeType: "image/png", resolvedPath: "packages/webui/out/chart.png" } }, workspaceFileUrl: ({ path }) => `/file?path=${path}` }));
+ expect(markup).toContain('src="/file?path=packages/webui/out/chart.png"');
+ });
+
it("parses only safe workspace file references and preserves line ranges", () => {
expect(parseWebuiMessageFileReference("src/index.ts:42-45")).toEqual({ path: "src/index.ts", lineStart: 42, lineEnd: 45 });
expect(parseWebuiMessageFileReference("index.html")).toEqual({ path: "index.html" });
diff --git a/packages/webui/test/unit/workspace-write-fence.test.ts b/packages/webui/test/unit/workspace-write-fence.test.ts
index 76854165a..71cbf2d48 100644
--- a/packages/webui/test/unit/workspace-write-fence.test.ts
+++ b/packages/webui/test/unit/workspace-write-fence.test.ts
@@ -34,11 +34,38 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { createHarnessPortFromHost } from "../../src/runtime/harness/adapter.js";
import { checkWorkspaceFence } from "../../src/runtime/workspace-real-path.js";
+import { createWorkspaceAdapter } from "../../src/runtime/harness/workspace.js";
/** Fixtures live in the OS temp dir; nothing is written into the repo tree. */
let root = "";
let workspace = "";
+describe("workspace file reference completion", () => {
+ function reader(matches: string[]) {
+ const readWorkspaceFile = vi.fn(async ({ path }: { path: string }) => path === "packages/webui/docs/design.md"
+ ? { type: "text", content: "# Architecture" }
+ : { type: "binary", content: "", error: "Path traversal denied" });
+ const searchWorkspaceFiles = vi.fn(async () => matches);
+ const adapter = createWorkspaceAdapter({ apiHost: { close: async () => {} }, cliService: { readWorkspaceFile, searchWorkspaceFiles } as never });
+ return { adapter, readWorkspaceFile, searchWorkspaceFiles };
+ }
+
+ it("completes a unique nested suffix and returns the actual path", async () => {
+ const { adapter, readWorkspaceFile } = reader(["other/design.md", "packages/webui/docs/design.md"]);
+ await expect(adapter.readWorkspaceFile({ workspaceDir: "/repo", path: "docs/design.md" })).resolves.toEqual({ type: "text", content: "# Architecture", resolvedPath: "packages/webui/docs/design.md" });
+ expect(readWorkspaceFile).toHaveBeenLastCalledWith({ workspaceDir: "/repo", path: "packages/webui/docs/design.md" });
+ });
+
+ it("does not guess among duplicate suffixes or search traversal paths", async () => {
+ const { adapter, readWorkspaceFile, searchWorkspaceFiles } = reader(["a/docs/design.md", "b/docs/design.md"]);
+ await expect(adapter.readWorkspaceFile({ workspaceDir: "/repo", path: "docs/design.md" })).resolves.toMatchObject({ error: "工作区中有多个匹配 docs/design.md 的文件,请使用完整相对路径。" });
+ expect(readWorkspaceFile).toHaveBeenCalledTimes(1);
+ searchWorkspaceFiles.mockClear();
+ await expect(adapter.readWorkspaceFile({ workspaceDir: "/repo", path: "../design.md" })).resolves.toMatchObject({ error: "Path traversal denied" });
+ expect(searchWorkspaceFiles).not.toHaveBeenCalled();
+ });
+});
+
const layout = { x: 0, y: 0, width: 1, height: 1, zIndex: 0 };
/**
diff --git a/playwright.config.mjs b/playwright.config.mjs
index 5a4c8abc1..4bf79529f 100644
--- a/playwright.config.mjs
+++ b/playwright.config.mjs
@@ -16,10 +16,12 @@ process.env.WEBUI_BROWSER_SERVER_ID = serverId;
// under a fontconfig that exposes only the font files committed to
// `packages/webui/test/browser/fonts/`, so the rendering depends on this
// repository and on nothing else.
+// Playwright clears outputDir before running. Runtime diagnostics and the
+// fontconfig prepared while loading this config must live outside that tree.
const outputDirectory =
- process.env.WEBUI_BROWSER_OUTPUT_DIR ?? "/tmp/minimax-webui-browser-results";
+ process.env.WEBUI_PLAYWRIGHT_OUTPUT_DIR ?? "/tmp/minimax-webui-playwright-results";
const fontEnvironment = prepareFontEnvironment(root, {
- cacheDirectory: outputDirectory,
+ cacheDirectory: process.env.WEBUI_BROWSER_FONT_CACHE_DIR ?? "/tmp/minimax-webui-browser-font-cache",
});
// `reuseExistingServer` is opt-in. Playwright only probes the url for
diff --git a/release/webui-npm/package.json b/release/webui-npm/package.json
index d27aae793..433cc002f 100644
--- a/release/webui-npm/package.json
+++ b/release/webui-npm/package.json
@@ -1,6 +1,6 @@
{
"name": "@minimax-webui/minimax-code",
- "version": "0.1.7",
+ "version": "0.1.8",
"description": "MiniMax Code WebUI with a local, process-based runtime.",
"license": "MIT",
"repository": {
diff --git a/scripts/lib/webui-presentation-inventory.json b/scripts/lib/webui-presentation-inventory.json
index cc996de00..984b35c02 100644
--- a/scripts/lib/webui-presentation-inventory.json
+++ b/scripts/lib/webui-presentation-inventory.json
@@ -244,7 +244,7 @@
"features/ssh/ui/ssh-project-form.tsx": "b59f1441d12996fe",
"features/ssh/ui/ssh-terminal-view.tsx": "4c0a7e868fbb2385",
"features/transcript/ui/ActivityIndicator.tsx": "62617f06c98fb736",
- "features/transcript/ui/AssistantBody.tsx": "1edefc4b411f88ce",
+ "features/transcript/ui/AssistantBody.tsx": "a1e529566f24253f",
"features/transcript/ui/DiffCard.tsx": "c709d0876003513b",
"features/transcript/ui/MessageActions.tsx": "1ba0f1ea77f9c3f2",
"features/transcript/ui/MessageAttachments.tsx": "e06226b8d10d3d3e",
diff --git a/scripts/run-webui-server.mjs b/scripts/run-webui-server.mjs
index 768863d76..b832c2001 100644
--- a/scripts/run-webui-server.mjs
+++ b/scripts/run-webui-server.mjs
@@ -214,14 +214,12 @@ const port = createHarnessPortFromHost(assembled.host, {
// The service closes every Mini App proxy on the way down; a leaked listener
// holds a plugin's derived port, which would make that Mini App unstartable.
miniappProxies,
+ // The standalone host owns the process; HTTP shutdown uses the same
+ // bounded cleanup as Ctrl+C. Repeated requests do not force a second exit.
+ requestShutdown: () => {
+ if (!stopping) shutdown();
+ },
});
- const info = await service.start();
- // The Mini App listener is bound before the first Mini App opens, so the
- // runtime cannot take this port for one of its own Mini Apps first.
- await miniappProxies.prepare();
- console.log(`[webui] server listening at ${info.boundUrl}`);
- console.log(`[webui] Mini App port: ${miniappPort}`);
- printAccess(info, bindHost);
const close = async () => {
await service.close();
await browser.close();
@@ -255,6 +253,15 @@ const port = createHarnessPortFromHost(assembled.host, {
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
+ // Register cleanup before accepting a shutdown request from the page.
+ const info = await service.start();
+ // The Mini App listener is bound before the first Mini App opens, so the
+ // runtime cannot take this port for one of its own Mini Apps first.
+ await miniappProxies.prepare();
+ console.log(`[webui] server listening at ${info.boundUrl}`);
+ console.log(`[webui] Mini App port: ${miniappPort}`);
+ printAccess(info, bindHost);
+
/**
* The path a person opens, with the per-start credential on it.
*
diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs
index 53ccbc7c7..4b324116e 100644
--- a/test/source-sync.test.mjs
+++ b/test/source-sync.test.mjs
@@ -43,7 +43,21 @@ test('public WebUI CI runs only WebUI checks and cannot publish packages', () =>
const directories = job.steps.find(step => step.name === 'Set isolated WebUI output directories');
assert.equal(directories.shell, 'bash');
assert.match(directories.run, /MCODE_WEBUI_VERIFY_DIR=\$RUNNER_TEMP\//);
- assert.match(directories.run, /WEBUI_BROWSER_OUTPUT_DIR=\$RUNNER_TEMP\//);
+ assert.match(directories.run, /WEBUI_PLAYWRIGHT_OUTPUT_DIR=\$RUNNER_TEMP\/webui-playwright-results/);
+ assert.match(directories.run, /WEBUI_BROWSER_FONT_CACHE_DIR=\$RUNNER_TEMP\/webui-browser-font-cache/);
+ assert.doesNotMatch(directories.run, /WEBUI_BROWSER_OUTPUT_DIR=/);
+ for (const [command, directory] of [
+ ['pnpm test:webui', 'units'],
+ ['pnpm test:webui-browser', 'browser'],
+ ['pnpm test:webui-package', 'package'],
+ ]) {
+ assert.equal(job.steps.find(step => step.run === command).env.WEBUI_BROWSER_OUTPUT_DIR,
+ '${{ runner.temp }}/webui-browser-diagnostics/' + directory);
+ }
+ const browserConfig = readFileSync(new URL('../playwright.config.mjs', import.meta.url), 'utf8');
+ assert.match(browserConfig, /process\.env\.WEBUI_PLAYWRIGHT_OUTPUT_DIR/);
+ assert.match(browserConfig, /cacheDirectory: process\.env\.WEBUI_BROWSER_FONT_CACHE_DIR/);
+ assert.doesNotMatch(browserConfig, /process\.env\.WEBUI_BROWSER_OUTPUT_DIR/);
const commands = job.steps.filter(step => step.run && step !== directories).map(step => step.run);
for (const command of commands) {
if (command === 'pnpm install --frozen-lockfile' || command === 'pnpm exec playwright install-deps chromium') continue;
@@ -70,6 +84,10 @@ test('public WebUI CI runs only WebUI checks and cannot publish packages', () =>
const diagnostics = job.steps.find(step => step.name === 'Upload WebUI browser diagnostics');
assert.equal(diagnostics.if, '${{ !cancelled() }}', 'Successful runs must retain raw performance samples too');
assert.equal(diagnostics.with['retention-days'], 7);
+ assert.deepEqual(diagnostics.with.path.trim().split(/\r?\n/), [
+ '${{ runner.temp }}/webui-browser-diagnostics/',
+ '${{ runner.temp }}/webui-playwright-results/',
+ ]);
});
test('coverage review distinguishes declared browser scenarios from executed evidence', () => {