diff --git a/package.json b/package.json index 5a6b19e17..77245023f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dev:webui": "pnpm --filter @mavis/webui dev", "check:webui-boundary": "node scripts/check-webui-boundary.mjs", "check:webui-dependency": "node scripts/check-webui-dependency.mjs", + "install:webui-browsers": "playwright install chromium", "test:webui": "node scripts/run-vitest-suite.mjs webui", "test:webui-package": "node scripts/verify-webui-package.mjs", "test:webui-browser": "playwright install --with-deps chromium && playwright test packages/webui/test/browser", diff --git a/packages/webui/src/client/features/transcript/ui/MessageItem.tsx b/packages/webui/src/client/features/transcript/ui/MessageItem.tsx index cdce75d0b..58bd363c6 100644 --- a/packages/webui/src/client/features/transcript/ui/MessageItem.tsx +++ b/packages/webui/src/client/features/transcript/ui/MessageItem.tsx @@ -10,7 +10,7 @@ // The shell still decides which source owns the turn (history after done, // composer while active), but the DOM for either role is produced here. -import { useState, type ReactElement } from "react"; +import { useRef, useState, type ReactElement } from "react"; import { createPortal } from "react-dom"; import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js"; import type { @@ -116,6 +116,10 @@ export function MessageItem({ const [forkTitle, setForkTitle] = useState(""); const [forkOpen, setForkOpen] = useState(false); const [forkOptions, setForkOptions] = useState(); + // The fork options arrive after the dialog is already open, so the suggested + // title is a late reply. Track whether the person has typed a name, so a + // late reply cannot overwrite what they wrote. + const forkTitleEdited = useRef(false); const openRewind = () => { setRewindOpen(true); setRewindPreview(undefined); @@ -156,11 +160,13 @@ export function MessageItem({ const openFork = () => { setForkOpen(true); setForkOptions(undefined); + forkTitleEdited.current = false; + setForkTitle(""); if (!sessionId || !transcriptCommands.getSessionForkOptions) return; void transcriptCommands.getSessionForkOptions({ id: sessionId, assistantMessageId: assistantMessageId ?? messageId }) .then((nextOptions) => { setForkOptions(nextOptions); - if (nextOptions.suggestedTitle) setForkTitle(nextOptions.suggestedTitle); + if (nextOptions.suggestedTitle && !forkTitleEdited.current) setForkTitle(nextOptions.suggestedTitle); }) .catch((error: unknown) => setMutationError(error instanceof Error ? error.message : String(error))); }; @@ -179,7 +185,7 @@ export function MessageItem({

复制为新会话

{forkOptions?.unavailableReason ?? "保留当前上下文,在新会话中继续"}

- setForkTitle(event.target.value)} placeholder="使用简短且不同的名称,便于识别" disabled={forkOptions?.canFork === false} /> + { forkTitleEdited.current = true; setForkTitle(event.target.value); }} placeholder="使用简短且不同的名称,便于识别" disabled={forkOptions?.canFork === false} />
diff --git a/packages/webui/src/client/features/turn/application/turn-commands.ts b/packages/webui/src/client/features/turn/application/turn-commands.ts index cb73467b7..2bbe04c1e 100644 --- a/packages/webui/src/client/features/turn/application/turn-commands.ts +++ b/packages/webui/src/client/features/turn/application/turn-commands.ts @@ -85,6 +85,19 @@ export interface WebuiAttachTurnDeps { readonly sink: WebuiDeliverySink; } +/** + * Whether the server's active-turn answer names the turn it is talking about. + * + * The contract says the answer carries a turn id, but the wire answer is read + * as it arrives: a runtime that reports a busy session without naming the turn + * is a real answer, and this client has to decide what it can do with it. An + * answer that names no turn cannot be followed — there is nothing to resume, + * and a lease opened for it would hold the next `session.start` off. + */ +function namesWebuiTurn(active: { readonly turnId?: unknown }): boolean { + return typeof active.turnId === "string" && active.turnId.length > 0; +} + /** * Follow a turn this client did not start, through the same stream loop a local * send uses — so history anchoring, cursor resume, `resume_overflow` resync, the @@ -95,7 +108,19 @@ export function attachWebuiTurn(deps: WebuiAttachTurnDeps): Promise { if (!sessionId || !resumeSession) return Promise.resolve(); const existing = readStream(); if (existing.subscription) return Promise.resolve(); - setSending(true); + // The "a turn is live" mark is a claim about a **named** turn. An attach that + // cannot name the turn it follows is following whatever the session happens + // to have, and the runtime answers such an attach with whatever is left of + // it — often nothing, because the turn it was about has already finished. + // + // Marking the session live on that guess is not a cosmetic slip: the + // submission admission reads this flag as "a turn is live", so the next + // message is queued instead of sent, and the composer's send button becomes + // the stop button, which swallows the click that was meant to send it. An + // unnamed attach therefore never marks the session live, and never clears a + // mark it did not raise. + const marksLive = turnId !== undefined; + if (marksLive) setSending(true); return runWebuiStreamLoop( { resumeSession, @@ -112,7 +137,7 @@ export function attachWebuiTurn(deps: WebuiAttachTurnDeps): Promise { // Only clear the indicator if this loop still owns the stream. A loop that // finished after a newer turn started would otherwise make the new turn // look idle while it is still streaming. - if (ownsWebuiStreamGeneration(readStream(), generation)) setSending(false); + if (marksLive && ownsWebuiStreamGeneration(readStream(), generation)) setSending(false); }); } @@ -169,7 +194,10 @@ export function recheckWebuiSubscription(deps: WebuiRecheckSubscriptionDeps): vo // Scoped to the generation we decided is stale: a newer loop may have // claimed while the probe was in flight, and that lease is the live one. releaseSubscription(owned.generation); - if (decision === "retarget" && active) attach(active.turnId); + // A retarget needs a turn to retarget to. An active turn the server + // names without an id cannot be followed, and the lease just released + // leaves the next `session.start` free to attach to the turn it names. + if (decision === "retarget" && active && namesWebuiTurn(active)) attach(active.turnId); else if (decision === "release" && !active && !readStream().subscription) attach(undefined); }) .catch(() => undefined); @@ -216,6 +244,11 @@ export function recoverMissedWebuiTurn(deps: WebuiRecoverMissedTurnDeps): void { // Read the lease at resolution time, not at call time: a local send that // started while the probe was in flight has already claimed it. if (readStream().subscription) return; + // The answer has to name the turn. An active turn without an id cannot be + // followed, and attaching to it would claim a lease with no turn in it — + // and a turn-less lease holds the next `session.start` off instead of + // letting this client follow it. + if (!namesWebuiTurn(active)) return; attach(active.turnId); }) .catch(() => undefined); diff --git a/packages/webui/test/browser/browser-workspace.spec.mjs b/packages/webui/test/browser/browser-workspace.spec.mjs index 3f97f12e5..b19096a6c 100644 --- a/packages/webui/test/browser/browser-workspace.spec.mjs +++ b/packages/webui/test/browser/browser-workspace.spec.mjs @@ -135,7 +135,12 @@ test("the browser page fills the workspace body and the workspace edge resizes h const tilesBox = await tiles.boundingBox(); expect(Math.abs(brandBox.x + brandBox.width / 2 - (omniboxBox.x + omniboxBox.width / 2))).toBeLessThanOrEqual(1); expect(brandBox.y + brandBox.height).toBeLessThan(omniboxBox.y); - expect((brandBox.y + tilesBox.y + tilesBox.height) / 2).toBeLessThan(launcherBox.y + launcherBox.height / 2); + // The launcher's content is centred in the panel body. The brand and the + // tiles are its first and last children, so the midpoint between them is + // the launcher's own vertical centre. The launcher's padding used to be + // heavier at the bottom, which put the group a little above the centre; + // the new tab page is symmetric, so the group sits on the centre line. + expect(Math.abs((brandBox.y + tilesBox.y + tilesBox.height) / 2 - (launcherBox.y + launcherBox.height / 2))).toBeLessThanOrEqual(1); await expect(brand).toHaveAttribute("aria-label", "MiniMax"); await expect(brand.locator(".webui-workspace-launcher-wordmark")).toHaveText("MiniMax"); expect(brandBox.width).toBeGreaterThan(0); diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png index ff9e0e471..4e8c5cd13 100644 Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-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 52017159e..ab3849876 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/integration/agent-transcript.test.ts b/packages/webui/test/integration/agent-transcript.test.ts index 67563d5dd..773464e6e 100644 --- a/packages/webui/test/integration/agent-transcript.test.ts +++ b/packages/webui/test/integration/agent-transcript.test.ts @@ -54,17 +54,24 @@ function frame(delta: Record, finishReason: string | null = nul function isWorkspaceChangedEvent(event: unknown, workspaceDir: string): boolean { if (!event || typeof event !== "object") return false; - const candidate = event as { type?: unknown; payload?: { workspace?: unknown } }; - return candidate.type === "workspace.git.changed" && candidate.payload?.workspace === workspaceDir; + const candidate = event as { type?: unknown; payload?: { workspace?: unknown; aliases?: unknown } }; + if (candidate.type !== "workspace.git.changed") return false; + // The Runtime owns the canonical realpath and reports the caller's own + // spelling alongside it as an alias. On macOS the two differ (`/var` is a + // symlink to `/private/var`), so the event names this workspace either by + // its canonical path or by one of its aliases. + if (candidate.payload?.workspace === workspaceDir) return true; + const aliases = candidate.payload?.aliases; + return Array.isArray(aliases) && aliases.includes(workspaceDir); } -async function waitForSignal(signal: Promise, label: string): Promise { +async function waitForSignal(signal: Promise, label: string, timeoutMs = 20_000): Promise { let timeout: ReturnType | undefined; try { await Promise.race([ signal, new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), 20_000); + timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs); }), ]); } finally { @@ -407,17 +414,33 @@ it("a real agent turn resumes after refresh and drains a queued message in order origin: new URL(url).origin, }); await browserExpect(page.locator("#webui-root")).toBeVisible(); - await browserExpect.poll(() => requests.some((request) => request.operation === "watchEvents")) + // The event channel opens as the client boots, so this wait is bounded by + // the boot budget the file already documents — not by the default 5s, which + // a loaded runner can pass without the client having started at all. + await browserExpect.poll(() => requests.some((request) => request.operation === "watchEvents"), + { timeout: FIRST_SCREEN_TIMEOUT_MS }) .toBe(true); const composer = page.getByPlaceholder("输入消息…(输入 / 唤起命令)"); + // The composer's own send button. + // + // `getByRole("button", { name: "发送" })` matches by substring, so it also + // matches the inline message editor's 发送 button and the plan card's 发送 + // button. While a turn is live the composer shows 停止 instead of 发送, so + // the role query can land on one of those other buttons and the message is + // never dispatched at all. The composer marks its own button, and that + // button is in the document exactly while the composer is ready to send, + // so clicking it waits for the composer to settle instead of racing it. + const sendComposerMessage = async (message: string): Promise => { + await composer.fill(message); + await page.locator("[data-webui-composer-submit]").click(); + }; // Wait for the product handshake deadline; a visible mount node alone does not prove React booted. await browserExpect(composer).toBeVisible({ timeout: FIRST_SCREEN_TIMEOUT_MS }); // Exercise the latest user-message controls on a newly opened session, // before this page has ever been refreshed. Both actions are canceled so // the probe does not mutate the transcript used by the remaining journey. - await composer.fill("Message actions before refresh."); - await page.getByRole("button", { name: "发送" }).click(); + await sendComposerMessage("Message actions before refresh."); await browserExpect(page.getByText("Unrefreshed action probe response.")).toBeVisible({ timeout: 30_000 }); await browserExpect.poll(() => port.getActiveTurn({ id: sessionId })).toBeUndefined(); await expect.poll(async () => JSON.stringify((await port.getMessages({ id: sessionId })).messages ?? [])) @@ -598,8 +621,7 @@ it("a real agent turn resumes after refresh and drains a queued message in order await postRewindEditor.getByRole("button", { name: "取消" }).click(); await browserExpect(postRewindEditor).toHaveCount(0); - await composer.fill("Please reason, then answer."); - await page.getByRole("button", { name: "发送" }).click(); + await sendComposerMessage("Please reason, then answer."); await waitForSignal(reasoningGate, "reasoning stream frame"); await browserExpect(page.getByText("Reasoning before refresh.")).toBeVisible(); @@ -699,6 +721,14 @@ it("a real agent turn resumes after refresh and drains a queued message in order createIsolatedWorktree: false, }); expect(messageForkRequest?.body?.assistantMessageId).toEqual(expect.any(String)); + // The reply travels on its own frame and can land after the request was + // observed, so wait for the forked session's id itself rather than + // reading the first response that happens to be there. + await browserExpect.poll(() => { + const response = received.find((candidate) => candidate.requestId === messageForkRequest?.requestId); + const sessionId = (response?.body as { session?: { sessionId?: unknown } } | undefined)?.session?.sessionId; + return typeof sessionId === "string"; + }).toBe(true); const messageForkResponse = received.find((response) => response.requestId === messageForkRequest?.requestId); const messageForkSessionId = (messageForkResponse?.body as { session?: { sessionId?: unknown } } | undefined) ?.session?.sessionId; @@ -908,7 +938,7 @@ it("a real agent turn resumes after refresh and drains a queued message in order await browserExpect(mention).toBeVisible(); await mention.click(); await browserExpect(composer).toHaveValue("Summarize the referenced project file. @reference-target.md "); - await page.getByRole("button", { name: "发送" }).click(); + await page.locator("[data-webui-composer-submit]").click(); await browserExpect(page.getByText("Reference and attachment reached the runtime.")).toBeVisible({ timeout: 30_000 }); await browserExpect.poll(async () => (await page.locator("body").textContent()) ?? "") .toContain("Summarize the referenced project file."); @@ -956,8 +986,13 @@ it("a real agent turn resumes after refresh and drains a queued message in order const planEntry = requests.find((request) => request.operation === "sendMessage" && request.body?.content === "Plan this synthetic task."); expect(planEntry?.body).toMatchObject({ clientIntent: "plan-entry" }); - expect((await port.getMessages({ id: sessionId })).messages?.some((message) => - message.role === "assistant" && message.msgContent === "Synthetic plan-entry turn completed.")).toBe(true); + // The rendered answer and the persisted history are two different writes: + // the transcript shows the frame as it arrives, while the history lands a + // beat later. Poll the history the way the rest of this case does instead + // of reading it once and racing the write. + await expect.poll(async () => (await port.getMessages({ id: sessionId })).messages?.some((message) => + message.role === "assistant" && message.msgContent === "Synthetic plan-entry turn completed.") === true) + .toBe(true); await page.getByRole("button", { name: "退出计划模式" }).click(); await browserExpect(composer).toBeVisible(); @@ -1163,8 +1198,10 @@ it("a real agent turn resumes after refresh and drains a queued message in order // Stop an actively streamed turn from the mounted composer, then send a // follow-up in the same persisted session to prove it recovered. - await composer.fill("Stop this turn, then recover."); - await page.getByRole("button", { name: "发送" }).click(); + await sendComposerMessage("Stop this turn, then recover."); + // The gate is the model server seeing the request that a cancelable local + // response is streaming. The wait is for that event, not for a fixed + // pause; the ceiling only decides how long the case may look for it. await waitForSignal(stopResponseStartedGate, "held cancellation response"); await browserExpect(page.getByText("Cancelable local response is active.")).toBeVisible(); const activeStopButton = page.getByRole("button", { name: "停止" }); @@ -1179,7 +1216,7 @@ it("a real agent turn resumes after refresh and drains a queued message in order response.requestId === abortRequest?.requestId && response.kind === "response" && JSON.stringify(response.body).includes('"success":true'), )).toBe(true); - await browserExpect(page.getByRole("button", { name: "发送" })).toBeVisible(); + await browserExpect(page.locator("[data-webui-composer-submit]")).toBeVisible(); await browserExpect(page.getByRole("button", { name: "停止" })).toHaveCount(0); await browserExpect.poll(() => port.getActiveTurn({ id: String(messageForkSessionId) })) .toBeUndefined(); @@ -1189,8 +1226,7 @@ it("a real agent turn resumes after refresh and drains a queued message in order expect(JSON.stringify(stoppedTurnHistory.messages)).toContain("Stop this turn, then recover."); expect(JSON.stringify(stoppedTurnHistory.messages)).not.toContain("Canceled response must not be rendered."); - await composer.fill("Continue after stopping the prior turn."); - await page.getByRole("button", { name: "发送" }).click(); + await sendComposerMessage("Continue after stopping the prior turn."); await browserExpect(page.getByText("The session recovered after stop.")).toBeVisible({ timeout: 30_000 }); await browserExpect.poll(() => port.getActiveTurn({ id: String(messageForkSessionId) })) .toBeUndefined(); @@ -1211,8 +1247,7 @@ it("a real agent turn resumes after refresh and drains a queued message in order } } })(); - await composer.fill("Retry this synthetic turn after a model failure."); - await page.getByRole("button", { name: "发送" }).click(); + await sendComposerMessage("Retry this synthetic turn after a model failure."); const retryError = page.getByTestId("output-error-surface"); await browserExpect.poll(() => retryModelAttempts, { timeout: 30_000 }).toBeGreaterThan(0); await browserExpect.poll(() => port.getActiveTurn({ id: String(messageForkSessionId) }), { @@ -1306,8 +1341,7 @@ it("a real agent turn resumes after refresh and drains a queued message in order const runtimeEventReader = (async () => { for await (const event of port.watchEvents(runtimeEventAbort?.signal)) runtimeEvents.push(event); })(); - await composer.fill("Write a synthetic workspace change."); - await page.getByRole("button", { name: "发送" }).click(); + await sendComposerMessage("Write a synthetic workspace change."); await browserExpect.poll(() => requests.filter((request) => request.operation === "sendMessage" && request.body?.id === messageForkSessionId && request.body?.content === "Write a synthetic workspace change.", ).length).toBeGreaterThan(0); 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 8c8b0e01a..ccdcc26d5 100644 --- a/packages/webui/test/unit/webui-w0-css-structure.test.ts +++ b/packages/webui/test/unit/webui-w0-css-structure.test.ts @@ -721,9 +721,12 @@ describe("W0 · structural declarations W5 must preserve", () => { expect(declaration(panel.body, "flex")).toBe("0 1 50%"); expect(declaration(panel.body, "width")).toBe("50%"); expect(declaration(panel.body, "min-width")).toBe("0"); - const expanded = winning(".webui-workspace-panel.is-expanded"); - expect(declaration(expanded.body, "position")).toBe("fixed"); - expect(declaration(expanded.body, "inset")).toBe("0"); + // The expanded panel is declared together with the covering one, so the + // cascade lookup is the one that reads a grouped selector. + // The panel covers its own container, which the container already pins to + // the viewport — it is no longer pinned to the viewport itself. + expect(winningDeclaration(".webui-workspace-panel.is-expanded", "position")).toBe("absolute"); + expect(winningDeclaration(".webui-workspace-panel.is-expanded", "inset")).toBe("0"); expect(declaration(winning(".webui-workspace-panel-body").body, "display")).toBe("flex"); expect(declaration(winning(".webui-workspace-file-tree-panel").body, "border-left")).toBe("1px solid var(--border_default)"); }); @@ -963,6 +966,11 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => { "webui-settings-content-in", "webui-signin-claim-spin", "webui-user-menu-usage-pulse", + // The workspace panel's own reveal: it grows from the right edge, and + // the panel that covers it slides the other way. + "webui-workspace-panel-collapse", + "webui-workspace-panel-exit", + "webui-workspace-panel-expand", ]); expect(transcriptNames).toEqual(["transcript-shimmer"]); }); diff --git a/scripts/run-vitest-suite.mjs b/scripts/run-vitest-suite.mjs index 5eb11b102..4d4fc06d1 100644 --- a/scripts/run-vitest-suite.mjs +++ b/scripts/run-vitest-suite.mjs @@ -36,8 +36,14 @@ const result = spawnSync( "vitest.oss.config.mjs", // These suites create many SQLite databases and watched directories. On // Windows, concurrent files can exhaust the 5s test budget through I/O - // contention. Serialize files without relaxing individual test deadlines. - ...(process.platform === "win32" ? ["--maxWorkers", "1"] : []), + // contention. The WebUI group drives real browsers as well: two of its + // files launch a browser of their own, and two browsers on one runner + // take the machine from each other. The case that drives the browser + // panel then runs past its own deadline — a failure that says nothing + // about the code under test. Serialize the files rather than relax any + // deadline: every file gets the machine to itself, and the deadlines stay + // the ones the cases were written against. + ...(process.platform === "win32" || name === "webui" ? ["--maxWorkers", "1"] : []), ...files, ], { stdio: "inherit", cwd: repositoryRoot, env: environment }, diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 9bee87314..dbb94d7a3 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -71,6 +71,14 @@ const steps = [ { name: "build:webui", script: "build:webui" }, { name: "check:webui-boundary", script: "check:webui-boundary" }, { name: "test:webui-package", script: "test:webui-package", platforms: ["linux", "darwin"] }, + // The WebUI integration suites drive a real Chromium: the loopback host + // test opens the built client in one and submits through it. The browser + // used to arrive with the `test:webui-browser` gate, which runs *after* + // this one, so a suite that needs a browser depended on a later gate + // having run first. Install it before, so a gate never depends on a later + // gate. The Linux browser gate still installs with its system dependencies + // and the CJK fonts, because that is what the visual baselines need. + { name: "install:webui-browsers", script: "install:webui-browsers" }, { name: "test:webui", script: "test:webui" }, { name: "test:webui-browser", script: "test:webui-browser", platforms: ["linux"] }, { name: "test:artifact", script: "test:artifact", windows: true },