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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/webui/src/client/shell/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import type { ReactElement } from "react";
import {
IconArchive, IconArrowBackUp, IconArrowUp, IconBell, IconBug, IconCirclePlus,
IconCalendarEvent, IconChartBar, IconCheck, IconChevronDown, IconChevronLeft,
IconCalendarEvent, IconCaretRightFilled, IconChartBar, IconCheck, IconChevronDown, IconChevronLeft,
IconChevronRight, IconClipboard, IconCode, IconCopy, IconDeviceMobile, IconDots,
IconDownload, IconEdit, IconFile, IconFileCode, IconFolder, IconFolderPlus,
IconGitBranch, IconGitCompare, IconGitFork, IconHierarchy, IconLayoutDashboard,
IconLayoutSidebar, IconMapPin, IconMessage, IconMessageCircle, IconMessageQuestion,
IconMinimize, IconPaperclip, IconPin, IconPuzzle, IconRefresh, IconSearch,
IconMinimize, IconPaperclip, IconPin, IconPinFilled, IconPuzzle, IconRefresh, IconSearch,
IconShieldCheck, IconShieldLock, IconShieldX, IconSparkles,
IconTarget, IconTerminal2, IconThumbDown, IconThumbUp, IconTrash, IconWorld,
IconX,
Expand Down Expand Up @@ -50,7 +50,8 @@ export function WebuiIconBell({ className }: WebuiIconProps): ReactElement { ret
export function WebuiIconAttach({ className }: WebuiIconProps): ReactElement { return glyph(IconPaperclip, className); }
export function WebuiIconChevronDown({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronDown, className, 16); }
export function WebuiIconSessionDisclosure({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronRight, className, 14); }
export function WebuiIconSessionTreeDisclosure({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronRight, className, 14); }
/** Filled disclosure arrow retained for expandable parent-session rows. */
export function WebuiIconSessionTreeDisclosure({ className }: WebuiIconProps): ReactElement { return glyph(IconCaretRightFilled, className, 14); }
export function WebuiIconPermissionRequest({ className }: WebuiIconProps): ReactElement { return glyph(IconShieldLock, className); }
export function WebuiIconPermissionAuto({ className }: WebuiIconProps): ReactElement { return glyph(IconShieldCheck, className); }
export function WebuiIconPermissionFull({ className }: WebuiIconProps): ReactElement { return glyph(IconShieldX, className); }
Expand All @@ -64,7 +65,13 @@ export function WebuiIconMore({ className }: WebuiIconProps): ReactElement { ret
export function WebuiIconProjectAdd({ className }: WebuiIconProps): ReactElement { return glyph(IconFolderPlus, className); }
export function WebuiIconSessionPinMark({ className }: WebuiIconProps): ReactElement { return glyph(IconPin, className, 14); }
export function WebuiIconSessionPin({ className }: WebuiIconProps): ReactElement { return glyph(IconPin, className); }
export function WebuiIconContextPin({ className, pinned = false }: WebuiIconProps & { pinned?: boolean }): ReactElement { return glyph(IconPin, className); }
export function WebuiIconContextPin({ className, pinned = false }: WebuiIconProps & { pinned?: boolean }): ReactElement {
// The pinned state draws the filled pin and the unpinned one the outline, so
// a pinned row reads as pinned without the label having to say it. The glyph
// used to say it through a hand-drawn slashed variant; the tabler pair says
// it the same way.
return glyph(pinned ? IconPinFilled : IconPin, className);
}
export function WebuiIconContextRename({ className }: WebuiIconProps): ReactElement { return glyph(IconEdit, className); }
export function WebuiIconContextArchive({ className }: WebuiIconProps): ReactElement { return glyph(IconArchive, className); }
export function WebuiIconContextFork({ className }: WebuiIconProps): ReactElement { return glyph(IconGitFork, className); }
Expand All @@ -73,7 +80,11 @@ export function WebuiIconContextFeedback({ className }: WebuiIconProps): ReactEl
export function WebuiIconContextExport({ className }: WebuiIconProps): ReactElement { return glyph(IconDownload, className); }
export function WebuiIconSessionImport({ className }: WebuiIconProps): ReactElement { return glyph(IconArrowBackUp, className); }
export function WebuiIconContextTrash({ className }: WebuiIconProps): ReactElement { return glyph(IconTrash, className); }
export function WebuiIconContextChevron({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronRight, className); }
export function WebuiIconContextChevron({ className }: WebuiIconProps): ReactElement {
// The submenu chevron sits in a 16px slot (`.webui-context-menu-chevron`),
// so it draws at 16 — the same size the other chevrons draw at.
return glyph(IconChevronRight, className, 16);
}
export function WebuiIconRunLocation({ className }: WebuiIconProps): ReactElement { return glyph(IconMapPin, className); }
export function WebuiIconSidebarToggle({ className }: WebuiIconProps): ReactElement { return glyph(IconLayoutSidebar, className); }
export function WebuiIconSearch({ className }: WebuiIconProps): ReactElement { return glyph(IconSearch, className); }
Expand Down
11 changes: 7 additions & 4 deletions packages/webui/test/browser/questionnaire.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ test("steps forward and back, gating every required step", async ({ page }) => {
await openApp(page, "#session=A");

await expect(card(page)).toBeVisible();
await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/3›");
// The progress reads 1/3 between the two step affordances. The next one is
// an icon now, so it contributes no text to the indicator.
await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("1/3");
await expect(page.locator('[data-testid="questionnaire-progress-next"] svg')).toBeVisible();

// Required, unanswered: both ways forward are locked. This is the gate the
// roadmap's 必填校验 row claimed — asserted on the real buttons.
Expand All @@ -86,7 +89,7 @@ test("steps forward and back, gating every required step", async ({ page }) => {
await page.locator('[data-webui-questionnaire-option="o1a"]').click();
await expect(next).toBeEnabled();
await next.click();
await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹2/3›");
await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("2/3");

// Multi-select: the hint is the only signal the row offers that Space-like
// toggling, not replacement, is the semantics.
Expand All @@ -98,7 +101,7 @@ test("steps forward and back, gating every required step", async ({ page }) => {

// Back: the first step's answer survives the round trip.
await page.locator('[data-testid="questionnaire-progress-prev"]').click();
await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/3›");
await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("1/3");
await expect(page.locator('[data-webui-questionnaire-option="o1a"]')).toHaveAttribute("data-selected", "true");
await page.locator('[data-testid="questionnaire-next"]').click();

Expand Down Expand Up @@ -216,5 +219,5 @@ test("a multi-step request with no presentation block renders instead of throwin
// DEFAULT_PRESENTATION: progress shown, back navigation allowed.
await expect(page.locator('[data-testid="webui-error-boundary"]')).toHaveCount(0);
await expect(card(page)).toBeVisible();
await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/2›");
await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("1/2");
});
58 changes: 26 additions & 32 deletions packages/webui/test/browser/rail-pin.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,22 @@ function clipRightInset(clipPath) {
}

/**
* The state of the pin glyph in a row's action button, read from the clip path
* the glyph actually references.
* The state of the pin glyph in a row's action button, read from the glyph's
* own class.
*
* Off the `<g clip-path>` reference rather than off a `clipPath` type selector:
* the question is whether the slash's clip resolves to *this* row's own path
* definition, and the reference is what the browser resolves.
* The filled pin is the pinned state and the outline the unpinned one, and
* the class is what says which: the hand-drawn glyph said it through the id
* of the clip path it carried, and the tabler glyph says it through its
* class.
*/
async function pinGlyphState(page, sessionId) {
const url = await rowOf(page, sessionId)
.locator(".webui-rail-action g[clip-path]")
const className = await rowOf(page, sessionId)
.locator(".webui-rail-action svg")
.first()
.getAttribute("clip-path");
const id = url?.match(/#(.+)\)/u)?.[1] ?? "";
return id.includes("unpinned") ? "unpinned" : id.includes("pinned") ? "pinned" : "none";
.getAttribute("class");
if (!className) return "none";
if (className.includes("tabler-icon-pin-filled")) return "pinned";
return className.includes("tabler-icon-pin") ? "unpinned" : "none";
}

async function pinButtonLabel(page, sessionId) {
Expand Down Expand Up @@ -186,31 +188,19 @@ test("the pin button offers the pin glyph on a row that is not pinned", async ({
expect(await pinButtonLabel(page, "B")).toBe("置顶:灵动岛卡住问题");
});

test("two pinned rows each get their own clip path", async ({ page }) => {
test("two pinned rows each draw their own pin glyph", async ({ page }) => {
// A and the child C. A shared clipPath id would make the second row's slash
// clip against the first row's element -- visually identical here because the
// two definitions match, which is exactly why it needs asserting on ids.
// two definitions match, which is exactly why it needs asserting on ids. The
// tabler glyph carries no such id, so the rule that is left is the one that
// still holds: each row has an element of its own.
await seedPins(page, { sessions: { A: true, B: true } });
await openApp(page, "#session=A");

// `url(#...)` reduced to the bare id on both sides: the attribute carries the
// function wrapper, the definition does not.
const ids = await page
.locator(".webui-rail-action g[clip-path]")
.evaluateAll((nodes) =>
nodes.map((node) => (node.getAttribute("clip-path") ?? "").replace(/^url\(#(.*)\)$/u, "$1")),
);
expect(ids).toHaveLength(2);
expect(new Set(ids).size).toBe(2);
// And each reference resolves to a path definition that actually exists in
// the document. Two identical ids would satisfy the uniqueness check above
// while both glyphs clip against the first row's element.
const defined = await page.evaluate(() =>
[...document.querySelectorAll("clipPath")].map((node) => node.id),
);
for (const id of ids) {
expect(defined, `no clipPath defines ${id}`).toContain(id);
}
const glyphs = page.locator('.webui-rail-action svg[class*="tabler-icon-pin"]');
await expect(glyphs).toHaveCount(2);
// Two separate elements, not one element counted twice.
expect(await glyphs.evaluateAll((nodes) => new Set(nodes).size)).toBe(2);
});

test("a pinned project row keeps the folder icon without an extra pin glyph", async ({ page }) => {
Expand Down Expand Up @@ -340,8 +330,12 @@ test("left rail expanders share the pinned animation and parent rows keep their
return [style.transitionProperty, style.transitionDuration, style.transitionTimingFunction];
})).toEqual(pinnedTransition);

const disclosurePath = await childDisclosure.locator("path").getAttribute("d");
expect(disclosurePath).toBe("M4.25 2.75 8 6l-3.75 3.25v-6.5Z");
// The parent row's disclosure is the filled caret; the project row's is the
// outline chevron. The hand-drawn glyph said so through the path it drew,
// and the tabler glyph says it through its class and its fill.
const disclosure = childDisclosure.locator("svg").first();
expect(await disclosure.getAttribute("class")).toContain("tabler-icon-caret-right-filled");
expect(await disclosure.getAttribute("fill")).toBe("currentColor");

await childDisclosure.click();
await expect(childContent).toHaveAttribute("aria-hidden", "false");
Expand Down
14 changes: 13 additions & 1 deletion packages/webui/test/gates/webui-dependency-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// The fixtures live under the OS temp directory, never in the repository, so
// `scripts/source-inventory.mjs` never sees them.

import { afterAll, describe, expect, it } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -51,6 +51,18 @@ const realBaselinePath = path.join(
"scripts/lib/webui-dependency-baseline.json",
);

/**
* The budget every case in this file runs under.
*
* Each fixture builds a TypeScript program, so what a case costs is the
* machine's speed rather than a fact about the rule it checks. Under
* vitest's five-second default a loaded machine turns that into a failure
* that says nothing about the gate — the same case passes on an idle one,
* and a different case fails each time. The budget is written down here so
* the gate reports the rule rather than the machine it ran on.
*/
vi.setConfig({ testTimeout: 30_000 });

describe("V42 frozen pre-refactor metadata and permissions", () => {
const oracle = JSON.parse(readFileSync(path.join(here, "./fixtures/path-metadata-f00.json"), "utf8")) as {
cases: { path: string; layers: string[]; role: string; business: string | null; home: string }[];
Expand Down
23 changes: 21 additions & 2 deletions packages/webui/test/integration/agent-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createServer } from "node:http";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { chromium, expect as browserExpect } from "@playwright/test";
import { chromium, expect as browserExpect, type Browser } from "@playwright/test";
import { expect, it } from "vitest";

const MODEL_ID = "stub-model";
Expand All @@ -23,6 +23,25 @@ function frame(delta: Record<string, unknown>, finishReason: string | null = nul
})}\n\n`;
}

/**
* The browser this case drives.
*
* This file belongs to the `test:webui` Vitest gate, which runs before the
* `test:webui-browser` gate installs playwright's own chromium — so on CI the
* pinned browser is not on disk yet. The runner's own Chrome is, and playwright
* drives it through the `chrome` channel. A launch that fails for any other
* reason is the failure it looks like, and is not retried.
*/
async function launchBrowser(): Promise<Browser> {
try {
return await chromium.launch({ headless: true });
} catch (error) {
const missingBrowser = error instanceof Error && error.message.includes("Executable doesn't exist");
if (!missingBrowser) throw error;
return await chromium.launch({ headless: true, channel: "chrome" });
}
}

it("a real agent turn keeps thinking and order across browser refresh and resume", async () => {
let releaseAnswer: () => void = () => undefined;
const answerGate = new Promise<void>((resolve) => { releaseAnswer = resolve; });
Expand Down Expand Up @@ -114,7 +133,7 @@ it("a real agent turn keeps thinking and order across browser refresh and resume
});
const info = await service.start();

browser = await chromium.launch({ headless: true });
browser = await launchBrowser();
const page = await browser.newPage({ locale: "zh-CN", timezoneId: "Asia/Shanghai" });
const requests: {
requestId?: string;
Expand Down
32 changes: 24 additions & 8 deletions packages/webui/test/unit/rail-pin-affordance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,21 @@ function pinButton(html: string, sessionId: string): string | undefined {
.match(/<button[^>]*class="webui-rail-action"[^>]*>/u)?.[0];
}

/**
* The pin states a row's glyph draws.
*
* The glyph's own class says which state it is: the filled pin is the pinned
* state and the outline pin is the unpinned one. The hand-drawn glyph used to
* say it through the id of a clip path it carried; the tabler glyph says it
* through its class.
*/
function pinGlyph(html: string, sessionId: string): string[] {
const ids = [
const classes = [
...rowSlice(html, sessionId).matchAll(
/<clipPath id="webui-context-pin-(un)?pinned-[^"]*"/gu,
/class="[^"]*\btabler-icon-pin(-filled)?\b[^"]*"/gu,
),
];
return ids.map((match) => (match[1] ? "unpinned" : "pinned"));
return classes.map((match) => (match[1] ? "pinned" : "unpinned"));
}

describe("the rail pin action", () => {
Expand All @@ -93,11 +101,16 @@ describe("the rail pin action", () => {

it("renders a distinct glyph instance for each session row", () => {
const html = renderProjectList({ pinnedSessions: { mvs_pinned: true } });
const ids = [...html.matchAll(/<clipPath id="(webui-context-pin-[^"]+)"/gu)].map(
(match) => match[1],
);
expect(ids).toHaveLength(2);
expect(new Set(ids).size).toBe(2);
// Each row draws its own pin element. The hand-drawn glyph carried a clip
// path whose id had to be unique per row (a shared id would have made the
// second row resolve its clip against the first row's element); the
// tabler glyph carries no such id, so the substance of the rule is that
// each row has an element of its own.
const glyphs = [
...html.matchAll(/<svg[^>]*\btabler-icon-pin\b[^>]*>/gu),
];
expect(glyphs).toHaveLength(2);
expect(glyphs[0]?.[0]).not.toBe(glyphs[1]?.[0]);
});

it("does not add a pin glyph before a project name", () => {
Expand All @@ -111,6 +124,9 @@ describe("the pin action glyph", () => {
it("draws different marks for pin and unpin states", () => {
const pinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: true }));
const unpinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: false }));
// The filled pin is the pinned state and the outline the unpinned one.
expect(pinned).toContain('class="tabler-icon tabler-icon-pin-filled"');
expect(unpinned).toContain('class="tabler-icon tabler-icon-pin"');
expect(pinned).not.toBe(unpinned);
});
});
16 changes: 11 additions & 5 deletions packages/webui/test/unit/webui-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,18 +855,24 @@ describe("WebUI shell", () => {
];
for (const Glyph of glyphs) {
const markup = renderToStaticMarkup(createElement(Glyph));
expect(markup).toContain('width="18" height="18" viewBox="0 0 20 20"');
expect(markup).toContain('fill="currentColor"');
// Desktop-sized, and drawn in the current colour so the glyph follows
// the text. The tabler glyphs draw with a stroke rather than a fill, so
// the colour is on the stroke.
expect(markup).toContain('width="18" height="18"');
expect(markup).toContain('stroke="currentColor"');
}

const pinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: true }));
const unpinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: false }));
expect(pinned).toContain('width="18" height="18" viewBox="0 0 20 20"');
expect(unpinned).toContain('width="18" height="18" viewBox="0 0 20 20"');
expect(pinned).toContain('width="18" height="18"');
expect(unpinned).toContain('width="18" height="18"');
expect(pinned).not.toBe(unpinned);

const submenuChevron = renderToStaticMarkup(createElement(WebuiIconContextChevron));
expect(submenuChevron).toContain('width="16" height="16" viewBox="0 0 16 16"');
// The chevron draws at 16 because its slot is 16 wide, not 18 like the
// row's other glyphs.
expect(submenuChevron).toContain('width="16" height="16"');
expect(submenuChevron).toContain("tabler-icon-chevron-right");
});

it("moves a pinned session ahead of newer unpinned sessions", () => {
Expand Down
Loading