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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions packages/webui/src/client/features/transcript/ui/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,6 +116,10 @@ export function MessageItem({
const [forkTitle, setForkTitle] = useState("");
const [forkOpen, setForkOpen] = useState(false);
const [forkOptions, setForkOptions] = useState<WebuiGetSessionForkOptionsResult>();
// 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);
Expand Down Expand Up @@ -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)));
};
Expand All @@ -179,7 +185,7 @@ export function MessageItem({
<div className="webui-message-dialog-surface">
<h3>复制为新会话</h3>
<p>{forkOptions?.unavailableReason ?? "保留当前上下文,在新会话中继续"}</p>
<input aria-label="会话名称" value={forkTitle} onChange={(event) => setForkTitle(event.target.value)} placeholder="使用简短且不同的名称,便于识别" disabled={forkOptions?.canFork === false} />
<input aria-label="会话名称" value={forkTitle} onChange={(event) => { forkTitleEdited.current = true; setForkTitle(event.target.value); }} placeholder="使用简短且不同的名称,便于识别" disabled={forkOptions?.canFork === false} />
<div className="webui-message-dialog-actions">
<button type="button" onClick={() => setForkOpen(false)} disabled={mutationBusy}>取消</button>
<button type="button" onClick={confirmFork} disabled={mutationBusy || forkOptions?.canFork === false}>复制并进入</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -95,7 +108,19 @@ export function attachWebuiTurn(deps: WebuiAttachTurnDeps): Promise<void> {
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,
Expand All @@ -112,7 +137,7 @@ export function attachWebuiTurn(deps: WebuiAttachTurnDeps): Promise<void> {
// 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);
});
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion packages/webui/test/browser/browser-workspace.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 55 additions & 21 deletions packages/webui/test/integration/agent-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,24 @@ function frame(delta: Record<string, unknown>, 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<void>, label: string): Promise<void> {
async function waitForSignal(signal: Promise<void>, label: string, timeoutMs = 20_000): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
signal,
new Promise<never>((_, 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 {
Expand Down Expand Up @@ -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<void> => {
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 ?? []))
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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: "停止" });
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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) }), {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading