diff --git a/packages/local-runtime/src/api/host.ts b/packages/local-runtime/src/api/host.ts
index fdcb31692..8527e09e5 100644
--- a/packages/local-runtime/src/api/host.ts
+++ b/packages/local-runtime/src/api/host.ts
@@ -96,6 +96,7 @@ import type {
LocalTokenUsageStore,
LocalTurnDiffStore,
} from "../persistence/ports.js";
+import { closeLocalRuntimeDb } from "../persistence/db.js";
import {
SqliteLocalCommunicationMessageStore,
SqliteLocalTokenUsageStore,
@@ -436,6 +437,14 @@ export class LocalRuntimeApiHost {
private readonly metricsReporter: LocalRuntimeApiHostOptions["metricsReporter"];
private readonly mavisCronAdapterProvider: LocalRuntimeApiHostOptions["mavisCronAdapterProvider"];
private readonly deleteAgentCronTasksOverride: LocalRuntimeApiHostOptions["deleteAgentCronTasks"];
+ /**
+ * The data directory this host's stores opened against, captured at
+ * construction. `configGetter()` stays live — a process may boot a second
+ * host (and move `MINIMAX_DATA_DIR`) before the first one closes — but the
+ * cached V1 SQLite connection this host owns is keyed by the construction
+ * time path, so that is the path close must release.
+ */
+ private readonly persistenceDataDir: string;
private readonly fileGitChangesCoordinator =
createLocalFileApiGitChangesCoordinator();
constructor(options: LocalRuntimeApiHostOptions = {}) {
@@ -507,6 +516,7 @@ export class LocalRuntimeApiHost {
);
}
const runtimeDataDir = this.configGetter().dataDir;
+ this.persistenceDataDir = runtimeDataDir;
try {
ensureTrashScript(runtimeDataDir);
// Seeded alongside the trash script: the shim is the execution-layer
@@ -1477,6 +1487,16 @@ export class LocalRuntimeApiHost {
() => this.globalEvents.close(),
() => drainBackgroundTasks(this),
() => this.errorReporter?.close(),
+ // The V1 stores share one process-wide cached connection per data
+ // directory (`withLocalRuntimeDb`), and nothing else owns its lifetime.
+ // Closing it last — after the background tasks that may still write
+ // through it have drained — releases the SQLite file handles; on
+ // Windows an open handle keeps the data directory undeletable
+ // (`EBUSY`), which every isolated-data-dir consumer inherits. The
+ // construction-time path is the key: `configGetter()` reads the live
+ // environment, which a second host in the same process has already
+ // moved.
+ () => closeLocalRuntimeDb(this.persistenceDataDir),
];
for (const close of cleanups) {
try {
diff --git a/packages/webui/src/client/features/composer/ui/slash-palette.ts b/packages/webui/src/client/features/composer/ui/slash-palette.ts
index ad5449e75..245f515b3 100644
--- a/packages/webui/src/client/features/composer/ui/slash-palette.ts
+++ b/packages/webui/src/client/features/composer/ui/slash-palette.ts
@@ -2,8 +2,8 @@
//
// Uses three data pools (built-in commands, the plugin
// registry, and the skills resolver), sectioning, and four-rank filtering.
-// The built-in list is intentionally scoped to the composer modes supported
-// here; behavior fields keep the same record shape.
+// The built-in list covers the composer modes plus every name the harness
+// port's `runCommand` accepts; behavior fields keep the same record shape.
//
// See `packages/webui/docs/design-language.md` for the visual language and
// ADR 0009 for the shared vocabulary.
@@ -19,7 +19,12 @@ import {
import {
WebuiIconCommandCompact,
WebuiIconCommandGoal,
+ WebuiIconCommandHelp,
WebuiIconCommandPlan,
+ WebuiIconCommandStatus,
+ WebuiIconCommandUsage,
+ WebuiIconModel,
+ WebuiIconNewTask,
WebuiIconSites,
WebuiIconSkillAskMatt,
WebuiIconSkillCodebaseDesign,
@@ -83,17 +88,17 @@ export interface SlashCommandEntry extends WebuiSlashPaletteFields {
*
* Capability gating today is the static `supported` flag. Goal is backed by
* the goal operations, plan entry is backed by the send-message
- * `plan-entry` intent, and compact is backed by the harness port's
- * `runCommand` — `runtime/commands/runner.ts` routes `command === "compact"`
- * into `requestCompaction`, and the operation is registered in
- * `server/operation/operations.ts`. Commands without a WebUI path remain
- * inert until their transport is wired.
+ * `plan-entry` intent, and every name in `WEBUI_RUN_COMMAND_NAMES` is backed
+ * by the harness port's `runCommand` — `runtime/commands/runner.ts` routes
+ * `help` to the descriptor list, `new` to `createSession`, `compact` to
+ * `requestCompaction`, `status` to `getAccountStatus`/`getSession`, `usage`
+ * to `getSessionUsage`, and `model` to `listModels`/`selectModel`; the
+ * operation is registered in `server/operation/operations.ts`. Commands
+ * without a WebUI path remain inert until their transport is wired.
*
- * `compact` is the one run-command built-in exposed so far. The remaining
- * names in `WEBUI_RUN_COMMAND_NAMES` (`help`, `new`, `status`, `usage`,
- * `model`) are reachable at the transport layer but have no palette row, so
- * a user cannot discover them; they need their own entries before they can
- * be claimed as shipped.
+ * The run-command rows sit after the composer modes and keep the `/help`
+ * order of `WEBUI_RUN_COMMAND_NAMES`, so the palette and `/help` list the
+ * same vocabulary in the same order.
*/
export const WEBUI_BUILTIN_COMMANDS: readonly SlashCommandEntry[] = [
{
@@ -116,6 +121,24 @@ export const WEBUI_BUILTIN_COMMANDS: readonly SlashCommandEntry[] = [
icon: WebuiIconCommandPlan,
supported: true,
},
+ {
+ name: "help",
+ displayName: "help",
+ label: "帮助",
+ description: "查看可用斜杠命令列表",
+ source_type: -1,
+ icon: WebuiIconCommandHelp,
+ supported: true,
+ },
+ {
+ name: "new",
+ displayName: "new",
+ label: "新会话",
+ description: "在当前工作区新建一个会话",
+ source_type: -1,
+ icon: WebuiIconNewTask,
+ supported: true,
+ },
{
name: "compact",
displayName: "compact",
@@ -125,6 +148,33 @@ export const WEBUI_BUILTIN_COMMANDS: readonly SlashCommandEntry[] = [
icon: WebuiIconCommandCompact,
supported: true,
},
+ {
+ name: "status",
+ displayName: "status",
+ label: "状态",
+ description: "查看当前会话的账号、模型与工作区状态",
+ source_type: -1,
+ icon: WebuiIconCommandStatus,
+ supported: true,
+ },
+ {
+ name: "usage",
+ displayName: "usage",
+ label: "用量",
+ description: "查看当前会话的用量统计",
+ source_type: -1,
+ icon: WebuiIconCommandUsage,
+ supported: true,
+ },
+ {
+ name: "model",
+ displayName: "model",
+ label: "模型",
+ description: "查看模型列表;可附带名称切换模型,如 /model provider/model",
+ source_type: -1,
+ icon: WebuiIconModel,
+ supported: true,
+ },
];
/**
diff --git a/packages/webui/src/client/features/models/ui/ModelPanel.tsx b/packages/webui/src/client/features/models/ui/ModelPanel.tsx
index 22b58d617..e4abda7b5 100644
--- a/packages/webui/src/client/features/models/ui/ModelPanel.tsx
+++ b/packages/webui/src/client/features/models/ui/ModelPanel.tsx
@@ -121,6 +121,13 @@ export function WebuiModelPanel({
const active =
typeof recordedContext === "number" &&
recordedContext === value;
+ // The catalog's word for a window that bills as the heavier
+ // choice. The hint is keyed by the option's own value, so it can
+ // only ever annotate a size the row already shows — and a model
+ // whose catalog states none renders the size alone.
+ const higherUsage =
+ model?.contextWindowOptionHints?.[String(value)] ===
+ "higher_usage";
return (
);
})}
diff --git a/packages/webui/src/client/features/models/ui/models.css b/packages/webui/src/client/features/models/ui/models.css
index c71f0c4bc..b0167cd87 100644
--- a/packages/webui/src/client/features/models/ui/models.css
+++ b/packages/webui/src/client/features/models/ui/models.css
@@ -306,6 +306,7 @@
display: inline-flex;
height: var(--spacing_24);
align-items: center;
+ gap: var(--spacing_4);
border: 1px solid var(--border_default);
border-radius: var(--radius_8);
background: transparent;
@@ -322,6 +323,14 @@
border-color: var(--border_accent);
color: var(--text_default_primary);
}
+ /* The catalog's word for the window that bills as the heavier choice. It is
+ * metadata about the size, not the size itself, so it is quieter than the
+ * number it rides with — and quieter in both themes, being a semantic role
+ * rather than a color. */
+ .webui-model-panel-context-option-hint {
+ color: var(--text_default_tertiary);
+ font-variant-numeric: normal;
+ }
/* ---- the thinking scale ----
*
* A blue rail up to the thumb, a white thumb ON it, and the stops as small
diff --git a/packages/webui/src/client/features/transcript/ui/SessionTranscript.tsx b/packages/webui/src/client/features/transcript/ui/SessionTranscript.tsx
index 7926a4d46..a8da22420 100644
--- a/packages/webui/src/client/features/transcript/ui/SessionTranscript.tsx
+++ b/packages/webui/src/client/features/transcript/ui/SessionTranscript.tsx
@@ -2,11 +2,11 @@
// flattening, history paging, streaming loader, and the embedded
// WebuiQuestionnaireResponse renderer for historical questionnaire answers.
-import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, type ReactElement } from "react";
+import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { IconChevronUp } from "@tabler/icons-react";
import { ChatSkeleton } from "./TranscriptSkeletons.js";
import { ActivityIndicator, MessageAfterQueryStreamingPlaceholder, MessagePassiveLoadingPlaceholder } from "./ActivityIndicator.js";
-import { TurnNavigator, type TurnSummary } from "./TurnNavigator.js";
+import { TurnNavigator, type TurnNavigatorPreview, type TurnSummary } from "./TurnNavigator.js";
import { MessageItem } from "./MessageItem.js";
import { formatWebuiMessageTimestamp } from "../projection/message-timestamp.js";
import type { WebuiMessageActionCapabilities } from "../contracts/transcript-view.js";
@@ -29,6 +29,7 @@ import {
projectWebuiTranscriptMessages,
projectWebuiQueryDurations,
projectWebuiProcessSegments,
+ type WebuiTranscriptGroup,
} from "../projection/transcript-projection.js";
import { projectWebuiTranscriptMessage } from "../projection/message-projection.js";
import { projectWebuiTranscriptView } from "../projection/transcript-view-model.js";
@@ -45,6 +46,107 @@ import {
const EMPTY_TRANSCRIPT_PAGE: WebuiClientMessagePage = {};
+// --- TurnNavigator integration ------------------------------------------------
+//
+// `TurnNavigator` is the presentational seam and defers hover, scroll and jump
+// to this integrator. The helpers below are that integration's pure halves,
+// exported so the node-environment suite can assert them without a DOM: which
+// message a tick anchors to, what one turn's hover preview shows, and what a
+// reveal does to its target. The mouse wiring itself (setState on hover,
+// querySelector on activate) stays in the component and is the boundary SSR
+// cannot reach.
+
+/** Every rendered message root carries `data-message-id` (MessageItem), so a
+ * tick reuses that attribute as its anchor instead of a second anchor
+ * system. Quotes and backslashes are escaped so a message id cannot close
+ * the attribute selector. */
+export function turnNavigatorAnchorSelector(turnId: string): string {
+ return `[data-message-id="${turnId.replace(/["\\]/g, "\\$&")}"]`;
+}
+
+export const TURN_NAVIGATOR_FLASH_CLASS = "webui-turn-jump-flash";
+const TURN_NAVIGATOR_FLASH_MS = 1200;
+
+export interface WebuiTurnRevealTarget {
+ scrollIntoView(options?: ScrollIntoViewOptions): void;
+ readonly classList: { add(token: string): void; remove(token: string): void };
+}
+
+/** Reveal one turn after a tick activation: center it in the transcript
+ * viewport and flash it so the destination is findable. The class is removed
+ * after `flashMs` so the highlight stays transient. */
+export function revealTurnNavigatorAnchor(
+ target: WebuiTurnRevealTarget,
+ flashMs = TURN_NAVIGATOR_FLASH_MS,
+): void {
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
+ target.classList.add(TURN_NAVIGATOR_FLASH_CLASS);
+ setTimeout(() => target.classList.remove(TURN_NAVIGATOR_FLASH_CLASS), flashMs);
+}
+
+const TURN_NAVIGATOR_RESPONSE_MAX_CHARS = 120;
+
+/** The preview's one-line answer summary: the first non-empty line, capped. */
+function firstTurnSummaryLine(text: string): string {
+ const line = text.split("\n").find((candidate) => candidate.trim().length > 0) ?? "";
+ return line.length > TURN_NAVIGATOR_RESPONSE_MAX_CHARS
+ ? `${line.slice(0, TURN_NAVIGATOR_RESPONSE_MAX_CHARS - 1)}…`
+ : line;
+}
+
+function hasAssistantItems(group: WebuiTranscriptGroup): boolean {
+ return group.items.some((item) => item.kind === "assistant");
+}
+
+function turnUserQuery(
+ groups: readonly WebuiTranscriptGroup[],
+ fromIndex: number,
+ step: 1 | -1,
+): string | undefined {
+ for (let index = fromIndex; index >= 0 && index < groups.length; index += step) {
+ const userItem = groups[index]?.items.find(
+ (item): item is Extract => item.kind === "user",
+ );
+ if (userItem) return userItem.text;
+ }
+ return undefined;
+}
+
+/** The hover preview for one tick: the turn's 1-based number among the
+ * assistant blocks the strip lists, its first answer line as the summary, the
+ * group's own timestamp, framed by the user queries on both sides. Returns
+ * null when the id is not one of the assistant turns the strip lists — the
+ * numbering and membership mirror `projectTurnRows`. */
+export function projectTurnNavigatorPreview(input: {
+ readonly turnId: string;
+ readonly groups: readonly WebuiTranscriptGroup[];
+}): TurnNavigatorPreview | null {
+ const assistantGroups = input.groups.filter(hasAssistantItems);
+ const tickIndex = assistantGroups.findIndex((group) => group.messageId === input.turnId);
+ if (tickIndex < 0) return null;
+ const group = assistantGroups[tickIndex]!;
+ const groupIndex = input.groups.indexOf(group);
+ const answer = group.items.find(
+ (item): item is Extract =>
+ item.kind === "assistant" && item.text.trim().length > 0,
+ );
+ const time = formatWebuiMessageTimestamp(
+ group.items.find((item) => typeof item.timestamp === "number")?.timestamp,
+ );
+ const previousQuery = groupIndex > 0 ? turnUserQuery(input.groups, groupIndex - 1, -1) : undefined;
+ const nextQuery =
+ groupIndex >= 0 && groupIndex < input.groups.length - 1
+ ? turnUserQuery(input.groups, groupIndex + 1, 1)
+ : undefined;
+ return {
+ title: `第 ${tickIndex + 1} 轮`,
+ ...(time ? { time } : {}),
+ ...(previousQuery ? { previousQuery } : {}),
+ ...(nextQuery ? { nextQuery } : {}),
+ ...(answer ? { response: firstTurnSummaryLine(answer.text) } : {}),
+ };
+}
+
export function WebuiSessionTranscript({
sessionId,
initialMessages,
@@ -158,6 +260,27 @@ export function WebuiSessionTranscript({
turnLive,
contentKey: items,
});
+ // The navigator's tick interactions: hover decides which turn the preview
+ // card describes, activation jumps the viewport to that turn's message root.
+ // `TurnNavigator` renders; this integrator owns the state and the DOM.
+ const [hoveredTurnId, setHoveredTurnId] = useState();
+ const navigatorPreview = useMemo(
+ () =>
+ hoveredTurnId
+ ? projectTurnNavigatorPreview({ turnId: hoveredTurnId, groups })
+ : null,
+ [groups, hoveredTurnId],
+ );
+ const activateNavigatorTick = (turnId: string) => {
+ const anchor = transcriptRef.current?.querySelector(
+ turnNavigatorAnchorSelector(turnId),
+ );
+ if (!anchor) return;
+ // 跳转是读者主动离开贴底跟随:先交还控制权,否则后续内容增长会把
+ // 视口从落点拽回底部(loadOlder 出于同一原因先释放)。
+ releaseAutoFollow();
+ revealTurnNavigatorAnchor(anchor);
+ };
const loadOlder = visiblePage.hasMore && visiblePage.nextCursor
? () => {
if (loading) return;
@@ -433,7 +556,15 @@ export function WebuiSessionTranscript({
) : null}
-
+ setHoveredTurnId(turnId)}
+ onTickLeave={(turnId) =>
+ setHoveredTurnId((current) => (current === turnId ? undefined : current))
+ }
+ onTickActivate={(turnId) => activateNavigatorTick(turnId)}
+ />
);
}
diff --git a/packages/webui/src/client/features/transcript/ui/transcript.css b/packages/webui/src/client/features/transcript/ui/transcript.css
index 18382395e..79557e4a5 100644
--- a/packages/webui/src/client/features/transcript/ui/transcript.css
+++ b/packages/webui/src/client/features/transcript/ui/transcript.css
@@ -125,6 +125,25 @@
.message-animate-in { animation: none; }
}
+ /* Turn-navigator jump highlight: the message a tick jumped to flashes once
+ * so the destination is findable. The surface token is the accent
+ * focus-highlight the composer's selection already uses, so both themes
+ * rebind it through tokens.css and nothing here is theme-specific. */
+ .webui-turn-jump-flash {
+ border-radius: var(--radius_12);
+ animation: webui-turn-jump-flash 1.2s ease-out both;
+ }
+ @keyframes webui-turn-jump-flash {
+ 0% { background-color: var(--bg_interaction_accent_focus_highlight); }
+ 100% { background-color: transparent; }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .webui-turn-jump-flash {
+ animation: none;
+ background-color: var(--bg_interaction_accent_focus_highlight);
+ }
+ }
+
/* A session is a two-zone surface: one message viewport owns the scroll
* position, while the composer stays in the bottom zone. The DOM keeps the
* composer close to the home surface markup, so the explicit order here is
diff --git a/packages/webui/src/client/features/workspace/ui/WorkspaceProgressPanel.tsx b/packages/webui/src/client/features/workspace/ui/WorkspaceProgressPanel.tsx
index 54b528ce9..57ebf602f 100644
--- a/packages/webui/src/client/features/workspace/ui/WorkspaceProgressPanel.tsx
+++ b/packages/webui/src/client/features/workspace/ui/WorkspaceProgressPanel.tsx
@@ -20,10 +20,12 @@ const PROGRESS_COPY = {
close: "关闭",
} as const;
-function WorkspaceProgressSection({ kind, className, title, collapsed, onToggle, children, busy }: {
+function WorkspaceProgressSection({ kind, className, title, titleMeta, collapsed, onToggle, children, busy }: {
readonly kind: "environment" | "progress" | "subagents";
readonly className: string;
readonly title: string;
+ /** Quiet metadata shown in the header next to the title (completion summary). */
+ readonly titleMeta?: ReactElement;
readonly collapsed: boolean;
readonly onToggle?: () => void;
readonly children: ReactElement | null;
@@ -32,6 +34,7 @@ function WorkspaceProgressSection({ kind, className, title, collapsed, onToggle,
return
element?.toggleAttribute("inert", collapsed)}>
@@ -48,7 +51,13 @@ export function WebuiProgressPanel({ todos, showProgress = true, showEmptyState
readonly onToggle?: () => void;
}): ReactElement | null {
if (!showProgress || (todos.length === 0 && !showEmptyState)) return null;
- return
+ // Completion summary for the section header: completed count over the whole
+ // list. An empty list renders no summary (the empty state explains itself).
+ const completedCount = todos.filter((todo) => todo.status === "completed").length;
+ const completionSummary = todos.length > 0
+ ? `${completedCount}/${todos.length} 已完成 · ${Math.round((completedCount / todos.length) * 100)}%`
+ : null;
+ return {completionSummary}} collapsed={collapsed} onToggle={onToggle}>
{todos.length === 0 ?
{PROGRESS_COPY.progressEmpty}
:
{todos.map((todo, index) =>
{todo.status === "completed" ? : index + 1}
diff --git a/packages/webui/src/client/shell/icons.tsx b/packages/webui/src/client/shell/icons.tsx
index cf2338de9..6341e2118 100644
--- a/packages/webui/src/client/shell/icons.tsx
+++ b/packages/webui/src/client/shell/icons.tsx
@@ -6,7 +6,7 @@ import {
IconCalendarEvent, IconCaretRightFilled, IconChartBar, IconCheck, IconChevronDown, IconChevronLeft,
IconChevronRight, IconClipboard, IconCode, IconCopy, IconDeviceMobile, IconDots,
IconDownload, IconEdit, IconFile, IconFileCode, IconFolder, IconFolderPlus,
- IconGitBranch, IconGitCompare, IconGitFork, IconHierarchy, IconHome, IconLayoutDashboard, IconMaximize,
+ IconGitBranch, IconGitCompare, IconGitFork, IconHierarchy, IconHome, IconInfoCircle, IconLayoutDashboard, IconMaximize,
IconLayoutSidebar, IconMapPin, IconMessage, IconMessageCircle, IconMessageQuestion,
IconMinimize, IconPaperclip, IconPin, IconPinFilled, IconPuzzle, IconRefresh, IconSearch,
IconShieldCheck, IconShieldLock, IconShieldX, IconSparkles,
@@ -96,6 +96,8 @@ export function WebuiIconWorkspaceTerminal({ className }: WebuiIconProps): React
export function WebuiIconWorkspaceCanvas({ className }: WebuiIconProps): ReactElement { return glyph(IconHierarchy, className); }
export function WebuiIconWorkspaceExpand({ className, expanded = false }: WebuiIconProps & { readonly expanded?: boolean }): ReactElement { return glyph(expanded ? IconMinimize : IconMaximize, className); }
export function WebuiIconCommandCompact({ className }: WebuiIconProps): ReactElement { return glyph(IconMinimize, className); }
+export function WebuiIconCommandHelp({ className }: WebuiIconProps): ReactElement { return glyph(IconMessageQuestion, className); }
+export function WebuiIconCommandStatus({ className }: WebuiIconProps): ReactElement { return glyph(IconInfoCircle, className); }
export function WebuiIconCommandUsage({ className }: WebuiIconProps): ReactElement { return glyph(IconChartBar, className); }
export function WebuiIconCommandGoal({ className }: WebuiIconProps): ReactElement { return glyph(IconTarget, className); }
export function WebuiIconCommandPlan({ className }: WebuiIconProps): ReactElement { return glyph(IconCalendarEvent, className); }
diff --git a/packages/webui/src/runtime/profile-files.ts b/packages/webui/src/runtime/profile-files.ts
index 31e165c4f..2b1a565cc 100644
--- a/packages/webui/src/runtime/profile-files.ts
+++ b/packages/webui/src/runtime/profile-files.ts
@@ -51,7 +51,7 @@
import { MEMORY_TAIL_INJECTION_CAP_CHARS } from "@mavis/shared/memory-limits";
import { randomBytes } from "node:crypto";
import { mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
-import { dirname, join, resolve } from "node:path";
+import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import type { WebuiAgentMemoryView, WebuiGlobalInstructionsView, WebuiUserProfileFields, WebuiUserProfileView } from "../shared/contracts/personalization.js";
@@ -513,9 +513,12 @@ export async function writeUserProfile(
/** Guards against a path resolving outside the profile root. */
export function isInsideDataDir(dataDir: string, filePath: string): boolean {
- const root = resolve(dataDir);
- const target = resolve(filePath);
- return target === root || target.startsWith(`${root}/`);
+ // `resolve` keeps the platform separator, so a `${root}/` prefix only works
+ // on POSIX. Compare through `relative` instead: it is separator-agnostic and
+ // still rejects sibling roots such as `-other` (their relative path
+ // climbs out with a leading `..`).
+ const rel = relative(resolve(dataDir), resolve(filePath));
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
/**
diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/composer-command-overlay-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/composer-command-overlay-linux.png
index cca587e9d..44e734a05 100644
Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/composer-command-overlay-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/composer-command-overlay-linux.png differ
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 710aa4360..a95e7634d 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/workspace-progress-live.spec.mjs b/packages/webui/test/browser/workspace-progress-live.spec.mjs
index b6bb22586..5b788e785 100644
--- a/packages/webui/test/browser/workspace-progress-live.spec.mjs
+++ b/packages/webui/test/browser/workspace-progress-live.spec.mjs
@@ -218,6 +218,9 @@ test("a todo update inside a stream message reaches the progress panel live", as
}),
});
await expect.poll(() => panelText(page)).toContain("第一步:调研");
+ // One in-progress todo, nothing completed yet: the header summary tracks the
+ // live list, not the snapshot from session open.
+ await expect.poll(() => panelText(page)).toContain("0/1 已完成 · 0%");
// A `todowrite` call in the same live turn carries the full list too, in the
// raw wire shape where the name and arguments sit under `function`.
@@ -258,7 +261,11 @@ test("collapsing Todo releases the overview space and moves Subagents upward", a
}),
});
- const todoHeading = page.getByRole("button", { name: "进度", exact: true });
+ // The Todo section's accessible name carries the completion summary
+ // ("进度 0/1 已完成 · 0%"), so match on the title prefix rather than the
+ // exact name. The panel toggle control is labelled "环境信息与进度" and does
+ // not start with "进度", so the anchored pattern stays unambiguous.
+ const todoHeading = page.getByRole("button", { name: /^进度/ });
await expect(todoHeading).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("button", { name: "layout-worker running" })).toBeVisible();
const expanded = await page.evaluate(() => {
diff --git a/packages/webui/test/gates/webui-boundary-check.test.ts b/packages/webui/test/gates/webui-boundary-check.test.ts
index c1cf3d7f5..4185fe62a 100644
--- a/packages/webui/test/gates/webui-boundary-check.test.ts
+++ b/packages/webui/test/gates/webui-boundary-check.test.ts
@@ -231,7 +231,11 @@ describe("webui boundary rules", () => {
});
it("script lives at the repo-root scripts directory", () => {
- expect(path.relative(repoRoot, here)).toMatch(/^packages\/webui\/test/);
+ // `path.relative` speaks the platform separator; the repo's path dialect
+ // is `/`, the same one `rewriteInputs` normalizes to a few cases below.
+ expect(path.relative(repoRoot, here).split(path.sep).join("/")).toMatch(
+ /^packages\/webui\/test/,
+ );
});
});
diff --git a/packages/webui/test/gates/webui-dependency-check.test.ts b/packages/webui/test/gates/webui-dependency-check.test.ts
index d28e7b81c..6fae23932 100644
--- a/packages/webui/test/gates/webui-dependency-check.test.ts
+++ b/packages/webui/test/gates/webui-dependency-check.test.ts
@@ -75,7 +75,7 @@ describe("V42 frozen pre-refactor metadata and permissions", () => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) walk(filename);
- else if (/\.(?:ts|tsx|css)$/u.test(filename)) paths.push(path.relative(sourceRoot, filename));
+ else if (/\.(?:ts|tsx|css)$/u.test(filename)) paths.push(path.relative(sourceRoot, filename).split(path.sep).join("/"));
}
};
walk(sourceRoot);
@@ -570,7 +570,10 @@ describe("direction fixtures", () => {
});
it("keeps a React host dependency when TypeScript resolves it outside the WebUI source tree", () => {
- const reactTypes = path.join(repoRoot, "node_modules/@types/react/index.d.ts");
+ // TypeScript resolves path-mapped targets in the `/` dialect (the graph
+ // records `resolvedFileName` verbatim), so the expectation speaks it too
+ // rather than the host separator `path.join` produced.
+ const reactTypes = path.join(repoRoot, "node_modules/@types/react/index.d.ts").split(path.sep).join("/");
expect(readFileSync(reactTypes, "utf8")).toContain("export = React");
const { graph, result } = evaluateFixture({
"client/application/app.ts": 'import { useState } from "react";\nexport const use = useState;\n',
@@ -939,7 +942,9 @@ describe("the ambient-member rule", () => {
// one, even when the specifier it was authored with says nothing of the sort.
describe("the resolved-host rule", () => {
it("rejects a React dependency that arrives through a path alias", () => {
- const reactTypes = path.join(repoRoot, "node_modules/@types/react/index.d.ts");
+ // Same dialect note as the direction fixture above: `resolvedFileName`
+ // for a path-mapped target arrives with `/` separators.
+ const reactTypes = path.join(repoRoot, "node_modules/@types/react/index.d.ts").split(path.sep).join("/");
const { graph, result } = evaluateFixture(
{
"client/application/app.ts":
diff --git a/packages/webui/test/integration/adapter-conversions.test.ts b/packages/webui/test/integration/adapter-conversions.test.ts
index 58dd8b56a..b42c7fe9b 100644
--- a/packages/webui/test/integration/adapter-conversions.test.ts
+++ b/packages/webui/test/integration/adapter-conversions.test.ts
@@ -45,6 +45,55 @@ import type {
} from "../../src/shared/contracts/goal.js";
import { removeRealHostDataDir } from "./remove-real-host-data-dir.js";
+/** Whether the Info-ZIP CLI is on PATH (`test/unit/webui-workspace-archive.test.ts` asks the same). */
+const HAS_ZIP = (() => {
+ try {
+ execFileSync("zip", ["--help"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+})();
+
+/**
+ * A one-entry stored zip, byte by byte.
+ *
+ * The `zip` CLI is a fixture builder, not the thing under test — the port's
+ * archive reader reads whatever bytes are in the file — and Windows hosts ship
+ * without Info-ZIP. Rather than lose the fallback assertion on those hosts,
+ * the same archive is written by hand, exactly the way the archive unit suite
+ * substitutes a crafted zip when it cannot call the CLI.
+ */
+function craftSingleEntryZip(name: string, content: string): Buffer {
+ const nameBytes = Buffer.from(name, "utf8");
+ const data = Buffer.from(content, "utf8");
+ const local = Buffer.alloc(30);
+ local.writeUInt32LE(0x04034b50, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(0x800, 6); // UTF-8 names
+ local.writeUInt16LE(0, 8); // stored
+ local.writeUInt32LE(data.length, 18);
+ local.writeUInt32LE(data.length, 22);
+ local.writeUInt16LE(nameBytes.length, 26);
+ const central = Buffer.alloc(46);
+ central.writeUInt32LE(0x02014b50, 0);
+ central.writeUInt16LE((3 << 8) | 20, 4); // unix host, spec 2.0
+ central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(0x800, 8);
+ central.writeUInt16LE(0, 10); // stored
+ central.writeUInt32LE(data.length, 20);
+ central.writeUInt32LE(data.length, 24);
+ central.writeUInt16LE(nameBytes.length, 28);
+ central.writeUInt32LE((0o100644 & 0xffff) * 0x10000, 38);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(1, 8);
+ end.writeUInt16LE(1, 10);
+ end.writeUInt32LE(central.length, 12);
+ end.writeUInt32LE(local.length + nameBytes.length + data.length, 16);
+ return Buffer.concat([local, nameBytes, data, central, nameBytes, end]);
+}
+
interface Booted {
readonly dataDir: string;
readonly workspaceDir: string;
@@ -225,7 +274,12 @@ describe("adapter translations against the real host", () => {
// The v2 host exposes no archive reader, so this exercises the fallback to
// `runtime/workspace-archive.ts` through the real port.
const archivePath = join(workspaceDir, "bundle.zip");
- execFileSync("zip", ["-q", "-r", archivePath, "tracked.txt"], { cwd: workspaceDir });
+ if (HAS_ZIP) {
+ execFileSync("zip", ["-q", "-r", archivePath, "tracked.txt"], { cwd: workspaceDir });
+ } else {
+ // No Info-ZIP on this host: the same archive, written byte by byte.
+ await writeFile(archivePath, craftSingleEntryZip("tracked.txt", "tracked\n"));
+ }
const listing = await port.readWorkspaceArchive({ workspaceDir, path: "bundle.zip" });
expect(listing.archivePath).toBe("bundle.zip");
expect(listing.entries.map((entry) => entry.name)).toContain("tracked.txt");
diff --git a/packages/webui/test/integration/agent-transcript.test.ts b/packages/webui/test/integration/agent-transcript.test.ts
index af76b009e..68b60b0ac 100644
--- a/packages/webui/test/integration/agent-transcript.test.ts
+++ b/packages/webui/test/integration/agent-transcript.test.ts
@@ -603,25 +603,35 @@ it("a real agent turn resumes after refresh and drains a queued message in order
messages?: { msgId?: string; role?: string; msgContent?: string }[];
} | undefined)?.messages;
expect(refreshMessages?.some((message) => message.msgId === persistedSourceUserMessageId)).toBe(false);
- expect(await persistedSourceUserMessage.count(), JSON.stringify({
- refreshMessagesRequest: refreshMessagesRequest && {
- requestId: refreshMessagesRequest.requestId,
- operation: refreshMessagesRequest.operation,
- body: refreshMessagesRequest.body,
- },
- refreshMessages: refreshMessages?.map((message) => ({
- id: message.msgId,
- role: message.role,
- content: message.msgContent,
- })),
- visibleMessageIds: await page.locator("[data-webui-message-role]").evaluateAll((nodes) =>
- nodes.map((node) => ({
- role: node.getAttribute("data-webui-message-role"),
- id: node.getAttribute("data-message-id"),
- text: node.textContent,
+ // The refreshed history response arriving on the socket (observed above
+ // through `received`) and React committing the row's removal are two
+ // separate steps; poll for the DOM catch-up rather than asserting the
+ // count in the same tick the frame landed, and keep the diagnostic dump
+ // for the case where it never does.
+ try {
+ await browserExpect.poll(() => persistedSourceUserMessage.count(), { timeout: 10_000 })
+ .toBe(0);
+ } catch (error) {
+ throw new Error(`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify({
+ refreshMessagesRequest: refreshMessagesRequest && {
+ requestId: refreshMessagesRequest.requestId,
+ operation: refreshMessagesRequest.operation,
+ body: refreshMessagesRequest.body,
+ },
+ refreshMessages: refreshMessages?.map((message) => ({
+ id: message.msgId,
+ role: message.role,
+ content: message.msgContent,
})),
- ),
- })).toBe(0);
+ visibleMessageIds: await page.locator("[data-webui-message-role]").evaluateAll((nodes) =>
+ nodes.map((node) => ({
+ role: node.getAttribute("data-webui-message-role"),
+ id: node.getAttribute("data-message-id"),
+ text: node.textContent,
+ })),
+ ),
+ })}`);
+ }
const mountedTranscript = page.locator(`[data-webui-transcript="${sessionId}"]`);
await browserExpect(mountedTranscript.getByText("Message actions before refresh.", { exact: true }))
.toBeVisible();
@@ -833,7 +843,12 @@ it("a real agent turn resumes after refresh and drains a queued message in order
await composer.press("Enter");
await browserExpect(page.getByTestId("progress-overview-card").getByText("Visible task from runtime tool"))
.toBeVisible({ timeout: 30_000 });
- const todoPanel = page.getByRole("button", { name: "进度", exact: true });
+ // The Todo section's accessible name carries the completion summary
+ // ("进度 0/1 已完成 · 0%"), so match on the title prefix rather than the
+ // exact name — the same anchored pattern the live browser spec uses. The
+ // panel toggle control is labelled "环境信息与进度" and does not start
+ // with "进度", so the pattern stays unambiguous.
+ const todoPanel = page.getByRole("button", { name: /^进度/ });
await browserExpect(todoPanel).toBeVisible();
await browserExpect(page.locator("span.webui-tool-label").filter({ hasText: "todowrite" }))
.toHaveText("todowrite");
@@ -993,8 +1008,15 @@ it("a real agent turn resumes after refresh and drains a queued message in order
const referencePrompt = (referencePayload.messages ?? [])
.map((message) => typeof message.content === "string" ? message.content : JSON.stringify(message.content))
.join("\n");
- const runtimeAttachmentPath = /]* path=\\?"([^"\\]+)\\?"/u
- .exec(referencePrompt)?.[1];
+ // The attachment path is read out of the prompt the model stub received.
+ // Non-string message content is JSON.stringify'd above, so on Windows the
+ // path arrives escaped (`path=\"C:\\Users\\…\"`). The opening quote's
+ // escape (if any) is captured and must close the value too, so the JSON
+ // escape before the closing quote never lands in the path on any platform;
+ // the doubled separators then fold back to the real path (a no-op for
+ // POSIX separators and for unescaped string content).
+ const runtimeAttachmentPath = /]* path=(\\?)"(.+?)\1"/u
+ .exec(referencePrompt)?.[2]?.replaceAll("\\\\", "\\");
expect(runtimeAttachmentPath).toBeDefined();
expect(await readFile(runtimeAttachmentPath!, "utf8")).toBe("Synthetic attachment body.");
await expect.poll(async () => {
diff --git a/packages/webui/test/unit/model-panel-context-hints.test.tsx b/packages/webui/test/unit/model-panel-context-hints.test.tsx
new file mode 100644
index 000000000..bec384aea
--- /dev/null
+++ b/packages/webui/test/unit/model-panel-context-hints.test.tsx
@@ -0,0 +1,94 @@
+/**
+ * `WebuiModelPanel` — the context window row's per-option usage hint.
+ *
+ * The catalog states, for a model whose windows are not billed equally, which
+ * window is the heavier choice (`contextWindowOptionHints: { "1000000":
+ * "higher_usage" }`), and the server projection passes the fact through. The
+ * panel is where the fact becomes a word the person choosing can read: without
+ * it, a 1M window that bills differently reads exactly like a 200K one.
+ *
+ * No DOM framework is allowed here, so the branches are asserted through
+ * `renderToStaticMarkup` — the hint is pure markup, so SSR sees all of it.
+ */
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { createElement } from "react";
+
+import { WebuiModelPanel } from "../../src/client/features/models/ui/ModelPanel.js";
+import type { WebuiModelPickerEntry } from "../../src/client/features/models/contracts/model-view.js";
+
+const HINT = "较高用量";
+
+function renderPanel(model: WebuiModelPickerEntry | undefined): string {
+ return renderToStaticMarkup(
+ createElement(WebuiModelPanel, {
+ model,
+ draft: {},
+ onOpenList: () => undefined,
+ onEffortChange: () => undefined,
+ onResetEffort: () => undefined,
+ onContextLimitChange: () => undefined,
+ }),
+ );
+}
+
+/** The one context-option button for `value`, as markup. */
+function option(html: string, value: number): string {
+ const match = html.match(
+ new RegExp(
+ ``,
+ ),
+ );
+ // Not a manufactured testid read: the failure message names the option that
+ // did not render, which is the fact under test when it is missing.
+ expect(match, `context option ${value} rendered`).toBeTruthy();
+ return match![0];
+}
+
+const WINDOWS: WebuiModelPickerEntry = {
+ providerId: "minimax_api",
+ modelId: "MiniMax-M3",
+ contextWindowOptions: [128_000, 1_000_000],
+};
+
+describe("model panel — the context window's usage hint", () => {
+ it("marks the window the catalog calls the heavier choice, and only that one", () => {
+ const html = renderPanel({
+ ...WINDOWS,
+ contextWindowOptionHints: { "1000000": "higher_usage" },
+ });
+
+ // The hint rides inside its option, as the class that styles it.
+ expect(option(html, 1_000_000)).toContain(
+ '较高用量',
+ );
+ // The ordinary window keeps stating only its size.
+ expect(option(html, 128_000)).not.toContain(HINT);
+ // Exactly one hint in the whole panel, whatever the row holds.
+ expect(html.match(new RegExp(HINT, "gu"))).toHaveLength(1);
+ });
+
+ it("renders no hint for a model whose catalog carries none", () => {
+ const html = renderPanel(WINDOWS);
+
+ expect(html).toContain('data-webui-model-context-option="128000"');
+ expect(html).toContain('data-webui-model-context-option="1000000"');
+ expect(html).not.toContain(HINT);
+ });
+
+ it("ignores hint values other than the one the wire contract names", () => {
+ // The server projection keeps only `higher_usage` entries; the view type
+ // is looser than that, so the panel answers the same filter itself rather
+ // than printing a value it has no wording for.
+ const html = renderPanel({
+ ...WINDOWS,
+ contextWindowOptionHints: {
+ "128000": "lower_usage",
+ "1000000": "higher_usage",
+ },
+ });
+
+ expect(option(html, 128_000)).not.toContain(HINT);
+ expect(option(html, 1_000_000)).toContain(HINT);
+ });
+});
diff --git a/packages/webui/test/unit/plugin-action-inventory.test.ts b/packages/webui/test/unit/plugin-action-inventory.test.ts
index c22d2ceb8..9311499c4 100644
--- a/packages/webui/test/unit/plugin-action-inventory.test.ts
+++ b/packages/webui/test/unit/plugin-action-inventory.test.ts
@@ -10,7 +10,7 @@
// to any carrier without the document, or a name changed in one place, fails here.
import { readFileSync, readdirSync, statSync } from "node:fs";
-import { join } from "node:path";
+import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
@@ -118,9 +118,13 @@ describe("each action's business is the one the inventory records", () => {
return listFiles(dir).filter((file) => /pluginManagement/u.test(stripComments(readSafe(file))));
});
expect(reach.length).toBeGreaterThan(0);
- const base = fileURLToPath(CLIENT).replace(/\/$/u, "") + "/";
+ // `fileURLToPath` ends in the platform separator, and `join`-built paths
+ // carry it throughout on Windows, so slicing off a `/`-anchored base both
+ // clips a character and leaves `\` separators that match no allowance.
+ // Compare through `relative` and speak the allowance's `/` dialect.
+ const base = fileURLToPath(CLIENT).replace(/[\\/]+$/u, "");
for (const file of reach) {
- const rel = file.slice(base.length);
+ const rel = relative(base, file).split(sep).join("/");
expect(allowed, `${rel} reaches the plugin port`).toContain(rel);
}
});
diff --git a/packages/webui/test/unit/slash-palette.test.ts b/packages/webui/test/unit/slash-palette.test.ts
index a174a120e..1b8117b8d 100644
--- a/packages/webui/test/unit/slash-palette.test.ts
+++ b/packages/webui/test/unit/slash-palette.test.ts
@@ -171,30 +171,47 @@ describe("WebUI slash palette — runtime narrowing", () => {
// These rows carry no composerMode, so the lite filter drops them — but
// they ARE runnable: `classifyWebuiSlashCommand` returns "runnable" and
// `resolveWebuiSubmissionIntent` path 3 dispatches them to the host's
- // `runCommand`. An earlier version of this test asserted `[]` with the
- // comment "those commands are hidden from this palette" — that pinned the
- // missing `compact` entry in place and let the palette stay green while
- // `/compact` was unreachable from the UI.
- expect(names).toEqual(["compact"]);
+ // `runCommand`. Every name in WEBUI_RUN_COMMAND_NAMES must carry a row
+ // here: a missing row is a command the transport accepts but the user
+ // cannot discover or invoke. An earlier version asserted `["compact"]`
+ // while the other five names had no row — that pinned the gap; this
+ // pins the closure of the list.
+ expect(names).toEqual([...WEBUI_RUN_COMMAND_NAMES].sort());
});
- it("exposes the run-command the server transport actually routes", () => {
- // The cross-module contract, stated so that deleting the entry fails it.
+ it("exposes every run-command the server transport actually routes", () => {
+ // The cross-module contract, stated so that deleting any entry fails it.
//
- // `runtime/commands/runner.ts` routes `command === "compact"` into
- // `port.requestCompaction`, and the operation is registered in
- // `server/operation/operations.ts`. A palette that omits the row leaves
- // `resolveWebuiSubmissionIntent` unable to find the name, so the
- // submit-turn path swallows `/compact` and sends it as a plain chat
- // message. The other names in WEBUI_RUN_COMMAND_NAMES (`help`, `new`,
- // `status`, `usage`, `model`) have no palette row yet on purpose — they
- // need their own scope, so this test pins the one command that is wired
- // end to end rather than asserting the whole whitelist.
+ // `runtime/commands/runner.ts` routes each name in
+ // WEBUI_RUN_COMMAND_NAMES to a port capability — `help` to the
+ // descriptor list, `new` to `createSession`, `compact` to
+ // `requestCompaction`, `status` to `getAccountStatus`/`getSession`,
+ // `usage` to `getSessionUsage`, `model` to `listModels`/`selectModel` —
+ // and the operation is registered in `server/operation/operations.ts`.
+ // A palette that omits a row leaves `resolveWebuiSubmissionIntent`
+ // unable to find the name, so the submit-turn path swallows the slash
+ // and sends it as a plain chat message.
const palette = buildWebuiSlashPalette({
skills: Object.values(WEBUI_PLUGIN_REGISTRY),
});
const runnableNames = palette.filter(isWebuiRunnableCommand).map((entry) => entry.name);
- expect(runnableNames).toContain("compact");
+ for (const name of WEBUI_RUN_COMMAND_NAMES) {
+ expect(runnableNames, `palette row for /${name}`).toContain(name);
+ }
+ });
+
+ it("registers every run-command with a non-empty label and description", () => {
+ // Discoverability is the row itself: name aligned with the contract
+ // list, a label the popover shows, a description that states what the
+ // command does, and the capability flag on. The descriptions follow the
+ // runtime's actual behaviour in `runtime/commands/runner.ts`.
+ for (const name of WEBUI_RUN_COMMAND_NAMES) {
+ const row = WEBUI_BUILTIN_COMMANDS.find((entry) => entry.name === name);
+ expect(row, `built-in row for /${name}`).toBeDefined();
+ expect(row?.label.trim(), `label for /${name}`).not.toBe("");
+ expect(row?.description.trim(), `description for /${name}`).not.toBe("");
+ expect(row?.supported, `supported for /${name}`).toBe(true);
+ }
});
it("does not leave a run-command row marked unsupported", () => {
diff --git a/packages/webui/test/unit/turn-navigator.test.tsx b/packages/webui/test/unit/turn-navigator.test.tsx
index 84a2d5dd6..df36ae8b7 100644
--- a/packages/webui/test/unit/turn-navigator.test.tsx
+++ b/packages/webui/test/unit/turn-navigator.test.tsx
@@ -4,12 +4,26 @@
// `data-turn-id` / `data-state` attributes the component emits, and the
// `data-mode` / `data-hidden` flags the parent uses to drive the strip
// visibility. The brief lists 17 testids; this file covers them all.
+//
+// The second half covers the integrator helpers `SessionTranscript` exports:
+// the hover-preview projection, the tick→message anchor selector, and the
+// reveal effect. The projection is fed groups built by the REAL message
+// projection, so the shapes are the ones the transcript renders, not hand
+// copies that could drift.
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { TurnNavigator } from "../../src/client/features/transcript/ui/TurnNavigator.js";
+import {
+ projectTurnNavigatorPreview,
+ revealTurnNavigatorAnchor,
+ turnNavigatorAnchorSelector,
+ TURN_NAVIGATOR_FLASH_CLASS,
+} from "../../src/client/features/transcript/ui/SessionTranscript.js";
+import { projectWebuiMessage } from "../../src/client/features/transcript/projection/message-projection.js";
+import { groupWebuiTranscriptItems } from "../../src/client/features/transcript/projection/transcript-projection.js";
const baseTurns = [
{ id: "turn-1", state: "default" as const },
@@ -18,6 +32,36 @@ const baseTurns = [
{ id: "turn-4", state: "error" as const },
];
+/** Two full turns through the real projection: user question, then the
+ * assistant block (thinking + answer) the strip lists a tick for. */
+const navigatorGroups = groupWebuiTranscriptItems([
+ ...projectWebuiMessage({
+ msgId: "nav-user-1",
+ role: "user",
+ msgContent: "第一问",
+ timestamp: new Date(2026, 9, 26, 12, 30).getTime(),
+ }),
+ ...projectWebuiMessage({
+ msgId: "nav-assistant-1",
+ role: "assistant",
+ thinkingContent: "先推理",
+ msgContent: "第一答",
+ timestamp: new Date(2026, 9, 26, 12, 34).getTime(),
+ }),
+ ...projectWebuiMessage({
+ msgId: "nav-user-2",
+ role: "user",
+ msgContent: "第二问",
+ timestamp: new Date(2026, 9, 26, 12, 40).getTime(),
+ }),
+ ...projectWebuiMessage({
+ msgId: "nav-assistant-2",
+ role: "assistant",
+ msgContent: " \n第二答第一行\n后面几行不进摘要",
+ timestamp: new Date(2026, 9, 26, 12, 44).getTime(),
+ }),
+]);
+
describe("TurnNavigator", () => {
it("emits the root, strip, and tick testids", () => {
const html = renderToStaticMarkup(
@@ -96,3 +140,112 @@ describe("TurnNavigator", () => {
expect(html).toContain('data-hidden="true"');
});
});
+
+describe("TurnNavigator integration helpers (SessionTranscript)", () => {
+ it("builds the hover preview from the real groups: index, time, both neighbouring queries, first answer line", () => {
+ const preview = projectTurnNavigatorPreview({
+ turnId: "nav-assistant-1",
+ groups: navigatorGroups,
+ });
+ // The timestamp is built from a local-time Date, so the formatted value is
+ // stable on every timezone the suite runs on.
+ expect(preview).toEqual({
+ title: "第 1 轮",
+ time: "10月26日, 12:34",
+ previousQuery: "第一问",
+ nextQuery: "第二问",
+ response: "第一答",
+ });
+ });
+
+ it("numbers the second assistant turn like the strip does and omits a missing next query", () => {
+ const preview = projectTurnNavigatorPreview({
+ turnId: "nav-assistant-2",
+ groups: navigatorGroups,
+ });
+ expect(preview?.title).toBe("第 2 轮");
+ expect(preview?.previousQuery).toBe("第二问");
+ expect(preview?.nextQuery).toBeUndefined();
+ // Leading blank lines are skipped: the summary is the first non-empty line.
+ expect(preview?.response).toBe("第二答第一行");
+ });
+
+ it("returns null for ids the strip lists no tick for", () => {
+ // A user block is not a tick, and an unknown id is not a turn at all.
+ expect(
+ projectTurnNavigatorPreview({ turnId: "nav-user-1", groups: navigatorGroups }),
+ ).toBeNull();
+ expect(
+ projectTurnNavigatorPreview({ turnId: "missing", groups: navigatorGroups }),
+ ).toBeNull();
+ });
+
+ it("caps a long first answer line for the preview card", () => {
+ const groups = groupWebuiTranscriptItems([
+ ...projectWebuiMessage({ msgId: "u", role: "user", msgContent: "问" }),
+ ...projectWebuiMessage({
+ msgId: "a",
+ role: "assistant",
+ msgContent: "长".repeat(300),
+ }),
+ ]);
+ const response = projectTurnNavigatorPreview({ turnId: "a", groups })?.response;
+ expect(response).toBeDefined();
+ expect(response!.length).toBe(120);
+ expect(response!.endsWith("…")).toBe(true);
+ });
+
+ it("feeds a projection the component's preview slot renders as-is", () => {
+ const html = renderToStaticMarkup(
+ createElement(TurnNavigator, {
+ turns: baseTurns,
+ preview: projectTurnNavigatorPreview({
+ turnId: "nav-assistant-1",
+ groups: navigatorGroups,
+ }),
+ }),
+ );
+ expect(html).toContain("第 1 轮");
+ expect(html).toContain("10月26日, 12:34");
+ expect(html).toContain("第一问");
+ expect(html).toContain("第二问");
+ expect(html).toContain("第一答");
+ });
+
+ it("anchors a tick to its message root's data-message-id, with quotes escaped", () => {
+ expect(turnNavigatorAnchorSelector("nav-assistant-1")).toBe(
+ '[data-message-id="nav-assistant-1"]',
+ );
+ // A message id carrying a quote must not close the attribute selector.
+ expect(turnNavigatorAnchorSelector('msg"1\\2')).toBe(
+ '[data-message-id="msg\\"1\\\\2"]',
+ );
+ });
+
+ it("centers the jumped-to turn and flashes it transiently", () => {
+ vi.useFakeTimers();
+ try {
+ const scrolls: unknown[] = [];
+ const classLog: string[] = [];
+ const target = {
+ scrollIntoView: (options?: ScrollIntoViewOptions) => scrolls.push(options),
+ classList: {
+ add: (token: string) => classLog.push(`+${token}`),
+ remove: (token: string) => classLog.push(`-${token}`),
+ },
+ };
+ revealTurnNavigatorAnchor(target, 1200);
+ expect(scrolls).toEqual([{ block: "center", behavior: "smooth" }]);
+ expect(classLog).toEqual([`+${TURN_NAVIGATOR_FLASH_CLASS}`]);
+ vi.advanceTimersByTime(1199);
+ expect(classLog).toEqual([`+${TURN_NAVIGATOR_FLASH_CLASS}`]);
+ vi.advanceTimersByTime(1);
+ expect(classLog).toEqual([
+ `+${TURN_NAVIGATOR_FLASH_CLASS}`,
+ `-${TURN_NAVIGATOR_FLASH_CLASS}`,
+ ]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/packages/webui/test/unit/webui-design-tokens.test.ts b/packages/webui/test/unit/webui-design-tokens.test.ts
index 98735ef55..15f710d63 100644
--- a/packages/webui/test/unit/webui-design-tokens.test.ts
+++ b/packages/webui/test/unit/webui-design-tokens.test.ts
@@ -114,8 +114,10 @@ function semanticNames(): string[] {
describe("WebUI design tokens", () => {
it("lives at packages/webui/src/client/styles/tokens.css", () => {
- // Sanity check that the file is being read from the right path.
- expect(tokensPath.endsWith("src/client/styles/tokens.css")).toBe(true);
+ // Sanity check that the file is being read from the right path. Spoken in
+ // the `/` dialect because `path` helpers keep the platform separator.
+ const rel = path.relative(packageDir, tokensPath).split(path.sep).join("/");
+ expect(rel).toBe("src/client/styles/tokens.css");
});
it("carries the 211 semantic tokens that packages/webui/docs/design-language.md names", () => {
diff --git a/packages/webui/test/unit/webui-host-event-generation.test.ts b/packages/webui/test/unit/webui-host-event-generation.test.ts
index 6968383af..f211df688 100644
--- a/packages/webui/test/unit/webui-host-event-generation.test.ts
+++ b/packages/webui/test/unit/webui-host-event-generation.test.ts
@@ -93,8 +93,12 @@ describe("host-derived event types", () => {
const configPath = path.join(root, "packages/webui/tsconfig.client.json");
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath));
- const generated = path.join(root, "packages/webui/src/shared/contracts/host-event-types.generated.ts");
- const reader = path.join(root, "packages/webui/src/client/features/turn/application/session-fact.ts");
+ // TypeScript addresses program files in the `/` dialect even on Windows
+ // (`getSourceFiles()` names prove it), so the mutation hook below — which
+ // compares by strict equality — must speak it too, or the rewrite never
+ // lands and the case asserts against an unmutated program.
+ const generated = path.join(root, "packages/webui/src/shared/contracts/host-event-types.generated.ts").split(path.sep).join("/");
+ const reader = path.join(root, "packages/webui/src/client/features/turn/application/session-fact.ts").split(path.sep).join("/");
function diagnostics(mutate: boolean) {
const host = ts.createCompilerHost(parsed.options);
const read = host.readFile.bind(host);
diff --git a/packages/webui/test/unit/webui-service.test.ts b/packages/webui/test/unit/webui-service.test.ts
index 2cd8ae6b2..cafa0e3dd 100644
--- a/packages/webui/test/unit/webui-service.test.ts
+++ b/packages/webui/test/unit/webui-service.test.ts
@@ -2679,11 +2679,15 @@ describe("WebUI service", () => {
it("does not accept requests after shutdown has been requested", async () => {
const { url } = await bootService();
- const { ws, upgrade } = openClient(url);
+ // `closed` is attached up-front by `openClient`: `service.close()` may
+ // finish the socket teardown while it is being awaited, and a listener
+ // attached afterwards would miss the client's `close` event and hang —
+ // the same race the helper's own comment warns about.
+ const { ws, upgrade, closed } = openClient(url);
await upgrade;
// Drive the shutdown and assert the in-flight connection is closed.
await service.close();
- const closeEvent = await awaitClose(ws);
+ const closeEvent = await closed;
expect(closeEvent.code).toBeGreaterThanOrEqual(1000);
expect(port.closed).toBe(true);
});
diff --git a/packages/webui/test/unit/webui-shell.test.ts b/packages/webui/test/unit/webui-shell.test.ts
index 024094c7b..cc2c7120f 100644
--- a/packages/webui/test/unit/webui-shell.test.ts
+++ b/packages/webui/test/unit/webui-shell.test.ts
@@ -395,6 +395,37 @@ describe("WebUI shell", () => {
expect(collapsed).toContain('aria-expanded="false"');
expect(collapsed).toContain('aria-hidden="true"');
});
+
+ it("summarizes todo completion in the section header and hides the summary for an empty list", () => {
+ // 3 completed of 7 total; `cancelled` still counts towards the total the
+ // roadmap's completion percentage is computed over.
+ const todos = [
+ { content: "已完成步骤 1", status: "completed" as const },
+ { content: "已完成步骤 2", status: "completed" as const },
+ { content: "已完成步骤 3", status: "completed" as const },
+ { content: "已取消步骤", status: "cancelled" as const },
+ { content: "待处理步骤", status: "pending" as const },
+ { content: "当前步骤", status: "in_progress" as const },
+ { content: "另一待处理", status: "pending" as const },
+ ];
+ const markup = renderToStaticMarkup(createElement(WebuiProgressPanel, { todos }));
+ expect(markup).toContain("3/7 已完成 · 43%");
+ // The summary rides in the header button, so collapsing the section hides
+ // the rows while the summary stays readable.
+ const collapsed = renderToStaticMarkup(createElement(WebuiProgressPanel, { todos, collapsed: true }));
+ expect(collapsed).toContain("3/7 已完成 · 43%");
+ expect(collapsed).toContain("webui-progress-section webui-progress-panel is-collapsed");
+ // The 0% and 100% edges stay whole numbers, not fractions or NaN.
+ expect(renderToStaticMarkup(createElement(WebuiProgressPanel, { todos: [{ content: "未开始", status: "pending" }] }))).toContain("0/1 已完成 · 0%");
+ expect(renderToStaticMarkup(createElement(WebuiProgressPanel, { todos: [{ content: "收尾", status: "completed" }] }))).toContain("1/1 已完成 · 100%");
+ // An empty list renders the plain empty state: no summary line appears.
+ const empty = renderToStaticMarkup(createElement(WebuiProgressPanel, { todos: [], showEmptyState: true }));
+ expect(empty).toContain("跟踪较长任务的进度");
+ expect(empty).not.toContain("已完成 ·");
+ // The overview panel carries the same summary through its progress section.
+ const overview = renderToStaticMarkup(createElement(WebuiProgressOverviewPanel, { todos, open: true }));
+ expect(overview).toContain("3/7 已完成 · 43%");
+ });
it("preserves an explicit empty thinking variant in model selection requests", () => {
expect(
buildWebuiModelSelectionRequest(
diff --git a/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts b/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts
index 19df65f2c..49fad748b 100644
--- a/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts
+++ b/packages/webui/test/unit/webui-transcript-widgets-integration.test.ts
@@ -167,6 +167,29 @@ describe("WebUI transcript widget wiring", () => {
expect(html).toContain('data-testid="message-turn-navigator"');
});
+ it("anchors every navigator tick to a rendered message root", () => {
+ // The tick→jump wiring's static half: each tick the strip lists must have
+ // a message root carrying the same `data-message-id`, which is what the
+ // activation handler queries for before scrolling. A tick without an
+ // anchor would be a dead button.
+ const html = sessionShell({
+ messages: [
+ { msgId: "nav-user-1", role: "user", msgContent: "第一问", timestamp: 1 },
+ { msgId: "nav-assistant-1", role: "assistant", msgContent: "第一答", timestamp: 2 },
+ { msgId: "nav-user-2", role: "user", msgContent: "第二问", timestamp: 3 },
+ { msgId: "nav-assistant-2", role: "assistant", msgContent: "第二答", timestamp: 4 },
+ ],
+ });
+ const tickIds = [
+ ...html.matchAll(/data-testid="message-turn-navigator-tick"[^>]*data-turn-id="([^"]+)"/gu),
+ ].map((match) => match[1]);
+ // Only assistant blocks are ticks, in transcript order.
+ expect(tickIds).toEqual(["nav-assistant-1", "nav-assistant-2"]);
+ for (const id of tickIds) {
+ expect(html).toContain(`data-message-id="${id}"`);
+ }
+ });
+
it("replaces the bare alert with OutputError when the stream refuses", () => {
const html = sessionShell({
streamingPhase: "refused",
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 337540211..44444e36f 100644
--- a/packages/webui/test/unit/webui-w0-css-structure.test.ts
+++ b/packages/webui/test/unit/webui-w0-css-structure.test.ts
@@ -863,12 +863,12 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => {
rule.context.some((entry) => entry.includes("prefers-reduced-motion")),
);
- // Sixteen rules across the stylesheets the shell imports. Deleting one of
+ // Seventeen rules across the stylesheets the shell imports. Deleting one of
// these is the failure mode this assertion exists for: the base rule would
// keep animating for a user who asked for reduced motion. The exact list
// is the point -- adding an animated rule without registering its override
// here is the same defect, in the other direction.
- expect(reducedMotion).toHaveLength(16);
+ expect(reducedMotion).toHaveLength(17);
for (const rule of reducedMotion)
expect(rule.body).toMatch(/(?:animation|transition):\s*none/u);
@@ -915,6 +915,9 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => {
// Renamed from `.webui-settings-toggle span` when the toggle became the
// shared `.webui-toggle-switch` control.
".webui-toggle-switch, .webui-toggle-switch > span",
+ // Added with the TurnNavigator jump: the destination message flashes
+ // once, so reduced motion replaces the flash with a steady highlight.
+ ".webui-turn-jump-flash",
// Added with the account menu's daily check-in fly-out, which now enters
// with the shared surface's motion. It is rendered through a portal, so
// the shell-wide override in `index.css` — which is scoped to
@@ -1002,6 +1005,9 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => {
"webui-progress-skeleton",
"webui-settings-content-in",
"webui-signin-claim-spin",
+ // The TurnNavigator jump flash: the message a tick jumped to flashes
+ // once so the destination is findable without following the scroll.
+ "webui-turn-jump-flash",
"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.
diff --git a/packages/webui/test/unit/webui-workspace-archive.test.ts b/packages/webui/test/unit/webui-workspace-archive.test.ts
index 9538cf239..9a0f1c153 100644
--- a/packages/webui/test/unit/webui-workspace-archive.test.ts
+++ b/packages/webui/test/unit/webui-workspace-archive.test.ts
@@ -179,15 +179,22 @@ function realZip(
return artifactPath(name);
}
-/** A real tar, or a gzipped one when the name ends in `.tgz`. */
+/** A real tar, or a gzipped one when the name ends in `.tgz`.
+ *
+ * The archive argument is sibling-relative (`../` from the source
+ * directory), not absolute: GNU tar reads the drive-letter colon in an
+ * absolute Windows path as a `host:file` remote spec, and Windows's bsdtar
+ * does not accept the `--force-local` escape hatch. A relative path carries
+ * no colon, so every tar flavour (GNU and bsdtar, all three platforms) takes
+ * it as a plain local file. */
function realTar(
name: string,
members: Readonly>,
): string {
const source = writeTree(`${name}-src`, members);
const args = name.endsWith(".tgz")
- ? ["czf", artifactPath(name)]
- : ["cf", artifactPath(name)];
+ ? ["czf", `../${name}`]
+ : ["cf", `../${name}`];
execFileSync("tar", [...args, "."], { cwd: source, env: FIXTURE_ENV });
return artifactPath(name);
}
@@ -766,7 +773,9 @@ describe("workspace archive extraction", () => {
/** A tar containing a symlink, via the CLI (`tar` records typeflag `2`). */
function realTarLink(name: string, source: string, member: string): string {
- execFileSync("tar", ["cf", artifactPath(name), member], {
+ // Sibling-relative for the same reason as `realTar`: no drive-letter colon
+ // for GNU tar to mistake for a host, no flag for bsdtar to reject.
+ execFileSync("tar", ["cf", `../${name}`, member], {
cwd: source,
env: FIXTURE_ENV,
});
diff --git a/release/public-source.json b/release/public-source.json
index 1de27d55d..205499071 100644
--- a/release/public-source.json
+++ b/release/public-source.json
@@ -4432,6 +4432,7 @@
"packages/webui/test/unit/miniapp-standalone-route.test.ts",
"packages/webui/test/unit/miniapp-surfaces.test.tsx",
"packages/webui/test/unit/model-effort-scale.test.ts",
+ "packages/webui/test/unit/model-panel-context-hints.test.tsx",
"packages/webui/test/unit/model-picker-effort-options.test.ts",
"packages/webui/test/unit/model-picker-groups.test.tsx",
"packages/webui/test/unit/model-provider-projection.test.ts",
diff --git a/test/vitest-suites.json b/test/vitest-suites.json
index 96e088acf..439906ad0 100644
--- a/test/vitest-suites.json
+++ b/test/vitest-suites.json
@@ -330,6 +330,7 @@
"packages/webui/test/unit/model-picker-effort-options.test.ts",
"packages/webui/test/unit/model-effort-scale.test.ts",
"packages/webui/test/unit/model-picker-groups.test.tsx",
+ "packages/webui/test/unit/model-panel-context-hints.test.tsx",
"packages/webui/test/unit/model-provider-projection.test.ts",
"packages/webui/test/unit/models-workflows.test.ts",
"packages/webui/test/unit/output-error.test.tsx",