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( + `]*data-webui-model-context-option="${value}"[\\s\\S]*?`, + ), + ); + // 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",