refactor(ui): replace Web Awesome chat pickers (#171301)

* refactor(ui): use native overlays for chat pickers

* fix(ui): preserve native picker focus and shortcuts

* fix(ui): preserve nested native picker shortcuts

* fix(ui): preserve native picker activation contracts

* fix(ui): retain native picker dismissal and geometry

* fix(ui): settle native picker placement and dismissal

* fix(ui): clear nested checks when details is dismissed

* test(ui): align refresh failure fixtures with lifecycle owners

* test(ui): synchronize preview and recovery assertions

Flush Solid before inspecting invalid preview metadata errors and hold successful recovery probes until the manual Retry click. Preserve upstream tooltip visibility coverage without a duplicate poll.

* test(ui): settle debug history polling with the fake clock

* test(ui): settle preview and sign-in fixture boundaries

Wait for rendered auth readiness before focus and prove the credential-swap
probe is in flight. Restore per-sample debug clock progression after the
bulk asynchronous advance timed out on pinned Bun.

* test(ui): scope debug history cap coverage to its content owner

Keep the full overlay assertions focused on rendering and lifecycle, and
exercise the 90-sample retention contract through the existing minimized
content bridge with CPU-only status responses.

* test(ui): scope board fetches and diagnose picker focus

* test(ui): wait for observable diagnostic and overlay readiness

* test(ui): restore baseline hovercard focus coverage

* fix(ui): declare native chat picker dialogs
This commit is contained in:
Peter Steinberger 2026-10-14 20:18:18 -07:00 • committed by GitHub
parent de70e8372f
commit 58ea8e5e6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1180 additions and 601 deletions

View file

@ -126,7 +126,9 @@ describe("browser sign-in recovery", () => {
await waitForSolid(() => expect(button("Check again").disabled).toBe(false));
expect(button("Sign in")).toBe(signIn);
button("Check again").click();
await expect.poll(() => document.body.textContent).toContain("Sign-in is still required");
await waitForSolid(() => {
expect(document.body.textContent).toContain("Sign-in is still required");
});
}
expect(restored).not.toHaveBeenCalled();
@ -202,7 +204,10 @@ describe("browser sign-in recovery", () => {
fetchControlUiResource("/nested/__openclaw__/assistant-media"),
).rejects.toThrow();
await finishRenewal();
await getRenderedModalDialog(document.body);
const { dialog: pendingDialog } = await getRenderedModalDialog(document.body);
await waitForSolid(() =>
expect(pendingDialog.querySelector('[role="status"]')?.textContent).toBe(""),
);
signedIn = true;
window.dispatchEvent(new Event("focus"));
try {
@ -270,12 +275,14 @@ describe("browser sign-in recovery", () => {
onTestFinished(subscribeBrowserAuthRestored(restored));
await expect(fetchControlUiResource("/nested/__openclaw__/assistant-media")).rejects.toThrow();
await finishRenewal();
await getRenderedModalDialog(document.body);
const { dialog } = await getRenderedModalDialog(document.body);
await waitForSolid(() => expect(dialog.querySelector('[role="status"]')?.textContent).toBe(""));
button("Sign in").click();
window.dispatchEvent(new Event("focus"));
await waitForSolid(() => expect(probes).toBe(3));
token = "replacement-token";
probe.resolve(success());
await expect.poll(() => button("Check again").disabled).toBe(false);
await waitForSolid(() => expect(button("Check again").disabled).toBe(false));
expect(restored).not.toHaveBeenCalled();
expect(document.querySelector("openclaw-modal-dialog")).not.toBeNull();
button("Check again").click();

View file

@ -129,6 +129,7 @@ describe("file path tooltip", () => {
} else {
await userEvent.keyboard("{Escape}");
}
await expect.poll(card).toBeNull();
if (dismissal === "reopen") {
const reopened = waitForReopen(tooltip);
await page.elementLocator(anchor()).hover();

View file

@ -62,6 +62,7 @@ export type MenuNavigationOptions = {
surface?: "menu" | "listbox";
/** Searchable lists keep focus in their input while Zag moves the highlight. */
focus?: "item" | "retain";
pointerFocus?: () => "menu" | "trigger";
items?: () => readonly { value: string; disabled?: boolean }[];
value?: () => string | null;
};
@ -408,14 +409,20 @@ export function useMenuMachine(
return {
...result,
onClick: (event: MouseEvent) => {
if (event.currentTarget instanceof HTMLAnchorElement) {
const anchor = event.currentTarget;
if (anchor instanceof HTMLAnchorElement) {
if (disabled) {
event.preventDefault();
return;
}
if (!shouldHandleNavigationClick(event)) {
if (
!shouldHandleNavigationClick(event) ||
(anchor.target !== "" && anchor.target !== "_self") ||
anchor.hasAttribute("download")
) {
return;
}
// Selection is queued; prevent navigation now so its cancelable event can veto it.
event.preventDefault();
}
result.onClick?.(event);
@ -453,7 +460,10 @@ export function useMenuMachine(
if (disabled) {
return;
}
focusFromKeyboard = true;
focusFromKeyboard = event.detail === 0 || options.pointerFocus?.() !== "trigger";
if (!focusFromKeyboard) {
overlay.trigger?.focus({ preventScroll: true });
}
result.onClick?.(event);
},
onKeyDown: (e: KeyboardEvent) => {

View file

@ -28,6 +28,99 @@ afterEach(async () => {
await page.viewport(originalViewport.width, originalViewport.height);
});
it.each([false, true])("preserves native link intent (routing=%s)", async (routing) => {
let cancel = false;
let selections = 0;
const navigate = vi.fn();
const view = mountMenu({
items: [{ id: "settings", label: "Settings", href: "#settings" }],
onNavigate: routing ? navigate : undefined,
onSelect: (_item, event) => {
selections += 1;
if (cancel) {
event.preventDefault();
}
},
});
const prevented: boolean[] = [];
let suppressNative = true;
const initialHash = window.location.hash;
view.container.addEventListener("click", (event) => {
if (event.target instanceof Element && event.target.closest("a")) {
prevented.push(event.defaultPrevented);
// Modified gestures stay native; same-tab clicks below exercise the real cancellation path.
if (suppressNative) {
event.preventDefault();
}
}
});
await openMenu();
const link = surface().querySelector<HTMLAnchorElement>("a")!;
expect(getComputedStyle(link).cursor).toBe("pointer");
for (const options of [
{ metaKey: true },
{ ctrlKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...options }));
}
for (const target of ["_blank", "named-window"]) {
link.target = target;
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
link.target = "";
link.download = "settings.txt";
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
link.removeAttribute("download");
expect(prevented).toEqual(Array.from({ length: 8 }, () => false));
const canceledClick = new MouseEvent("click", { bubbles: true, cancelable: true });
canceledClick.preventDefault();
link.dispatchEvent(canceledClick);
expect(prevented.at(-1)).toBe(true);
suppressNative = false;
await userEvent.click(link);
await expect.poll(() => selections).toBe(1);
expect(navigate.mock.calls).toEqual(routing ? [[{ href: "#settings", value: "settings" }]] : []);
expect(prevented.at(-1)).toBe(true);
await phase(surface(), "hidden");
await openMenu();
cancel = true;
await userEvent.click(link);
await expect.poll(() => selections).toBe(2);
expect(navigate).toHaveBeenCalledTimes(routing ? 1 : 0);
expect(view.handle.overlay.open).toBe(true);
expect(window.location.hash).toBe(initialHash);
cancel = false;
link.focus();
await userEvent.keyboard("{Enter}");
await expect.poll(() => selections).toBe(3);
expect(navigate).toHaveBeenCalledTimes(routing ? 2 : 0);
await phase(surface(), "hidden");
});
it("forwards link navigation from nested menus and closes the root", async () => {
const navigate = vi.fn();
mountMenu({
items: [
{
id: "manage",
label: "Manage",
children: [{ id: "models", label: "Models", href: "#models" }],
},
],
onNavigate: navigate,
});
await openMenu();
await openMenu("people-manage");
await userEvent.click(surface("people-manage").querySelector<HTMLAnchorElement>("a")!);
await expect.poll(() => navigate).toHaveBeenCalledWith({ href: "#models", value: "models" });
await phase(surface(), "hidden");
});
it.each([false, true])(
"rolls rejected controlled visibility back to accepted state (initial=%s)",
async (initial) => {

View file

@ -98,6 +98,8 @@ export type MenuProps = {
/** Override the destination when Tab exits the menu; dismissal stays owned here. */
tabFocusTarget?: (backward: boolean) => HTMLElement | undefined;
onSelect?: (item: MenuItem, event: CustomEvent<MenuItem>) => void;
onNavigate?: (link: { href: string; value: string }) => void;
pointerFocus?: "menu" | "trigger";
onOpenChange?(open: boolean): void;
onBeforeShow?(event: Event): void;
onAfterShow?(event: Event): void;
@ -196,6 +198,9 @@ export function Menu(props: BranchProps): JSX.Element {
if (!overlay.surface.dispatchEvent(event)) {
return;
}
if (item.href) {
props.onNavigate?.({ href: item.href, value: item.id });
}
if (item.closeOnSelect !== false) {
menuRoot.request(false, "return");
}
@ -203,6 +208,7 @@ export function Menu(props: BranchProps): JSX.Element {
onSettled(() => overlay.bindSurface(surface));
const binding = useMenuMachine(overlay, direction, activate, identity.binding?.service, {
ids: { trigger: identity.triggerId },
pointerFocus: () => props.pointerFocus ?? "menu",
});
const [opened, setOpened] = createSignal(false);
const connectControl = useOverlayControl(overlay, props);
@ -402,6 +408,7 @@ export function Menu(props: BranchProps): JSX.Element {
title: item().attributes?.title,
}}
onSelect={props.onSelect}
onNavigate={props.onNavigate}
>
{item().content}
</Menu>

View file

@ -224,6 +224,10 @@
color var(--control-ui-transition-fast) ease;
}
a.oc-menu-item[href] {
cursor: pointer;
}
.oc-menu-item:is(:hover, :focus-visible, [aria-expanded="true"]):not(:disabled):not(
[aria-disabled="true"]
) {

View file

@ -15,7 +15,7 @@ const suite = createControlUiE2eSuite({
name: "Control UI chat composer redesign",
});
const pickerPopup = (kind: "model" | "effort") =>
`.chat-controls__${kind}-picker > wa-popup[data-anchored-overlay] > [part="popup"]`;
`.chat-controls__${kind}-picker > [data-anchored-overlay]`;
// Browser contexts preserve test isolation; keep one process warm for this file.
suite.define(() => {
@ -259,8 +259,22 @@ suite.define(() => {
.toBe(true);
await page.keyboard.press("Escape");
await expect
.poll(() => effort.evaluate((node) => node === document.activeElement))
.toBe(true);
.poll(() =>
effort.evaluate((node) =>
node === document.activeElement
? "effort trigger"
: JSON.stringify({
activeTag: document.activeElement?.tagName ?? null,
activeClass: document.activeElement?.getAttribute("class") ?? null,
activeRole: document.activeElement?.getAttribute("role") ?? null,
openPickers: [
...document.querySelectorAll("details[data-chat-native-picker][open]"),
].map((picker) => picker.className),
openTooltips: document.querySelectorAll("openclaw-tooltip[open]").length,
}),
),
)
.toBe("effort trigger");
});
});
@ -860,7 +874,7 @@ suite.define(() => {
.toBe(true);
await captureMobileState(
"mobile-composer-permissions-open.png",
composer.locator('.chat-controls__permission-picker [part="menu"]'),
composer.locator(".chat-controls__permission-menu"),
[composer.locator(".chat-controls__permission-option").first()],
);
await page.keyboard.press("Escape");

View file

@ -634,7 +634,9 @@ suite.define(() => {
const main = page.getByRole("main");
const activePane = main.locator('openclaw-chat-pane[aria-hidden="false"]');
const modelSelect = activePane.locator('[data-chat-model-select="true"]');
const modelPopup = activePane.locator('.chat-controls__model-picker wa-popup [part="popup"]');
const modelPopup = activePane.locator(
".chat-controls__model-picker > [data-anchored-overlay]",
);
const modelOption = activePane.locator('[data-chat-model-option="openai/gpt-5.6-sol"]');
const effortSelect = activePane.locator('[data-chat-thinking-select="true"]');
const thinkingSlider = activePane.locator('[data-chat-thinking-slider="true"]');
@ -732,7 +734,7 @@ suite.define(() => {
await thinkingSlider.waitFor({ state: "visible" });
await capture(
"codex-luna-reasoning",
pane.locator('.chat-controls__effort-picker wa-popup [part="popup"]'),
pane.locator(".chat-controls__effort-picker > [data-anchored-overlay]"),
thinkingSlider,
);

View file

@ -88,8 +88,7 @@ suite.define(() => {
await refreshDetails.waitFor({ state: "visible" });
// The popup's opening scale animation can move this small target away
// from a stationary pointer before the tooltip's hover delay completes.
await picker.locator("wa-popup[data-anchored-overlay]").evaluate(async (popup) => {
const surface = popup.shadowRoot!.querySelector<HTMLElement>('[part="popup"]')!;
await picker.locator("[data-anchored-overlay]").evaluate(async (surface) => {
await Promise.all(surface.getAnimations().map((animation) => animation.finished));
});
await refreshDetails.hover();
@ -549,11 +548,11 @@ suite.define(() => {
it.each(
["chat", "new"].flatMap((route) =>
[false, true].map((tooltipOpen) => ({ route, tooltipOpen })),
[false, true].map((hoverEffort) => ({ route, hoverEffort })),
),
)(
"keeps independent model and effort controls within the $route composer (tooltip open: $tooltipOpen)",
async ({ route, tooltipOpen }) => {
"keeps independent model and effort controls within the $route composer (hover effort: $hoverEffort)",
async ({ route, hoverEffort }) => {
await suite.withPage({ viewport: { width: 393, height: 852 } }, async ({ page }) => {
const longName =
"Long catalog display name for a model with a very large context window and detailed reasoning capabilities";
@ -684,7 +683,7 @@ suite.define(() => {
: undefined;
if (artifactDir && [320, 393, 560, 1280].includes(width)) {
await page.screenshot({
path: `${artifactDir}/${route}-model-effort-${width}-tooltip-${tooltipOpen}.png`,
path: `${artifactDir}/${route}-model-effort-${width}-tooltip-${hoverEffort}.png`,
animations: "disabled",
});
}
@ -725,19 +724,13 @@ suite.define(() => {
await slider.press("End");
await expect.poll(() => effort.getAttribute("data-chat-thinking-value")).toBe("high");
expect(await needleAngle()).toBe(120);
// The pointer can remain over the changing effort label during slider input.
// Establish whether the hover hint or the picker owns this Escape.
// The expanded picker suppresses its trigger hint while owning Escape.
await slider.hover();
const openTooltips = page.locator("openclaw-tooltip[open]");
await expect.poll(() => openTooltips.count()).toBe(0);
expect(await slider.evaluate((node) => node === document.activeElement)).toBe(true);
if (tooltipOpen) {
if (hoverEffort) {
await effort.hover();
await expect.poll(() => openTooltips.count()).toBe(1);
await expect
.poll(() => openTooltips.locator(".tooltip-content").textContent())
.toBe("High");
await page.keyboard.press("Escape");
await expect.poll(() => openTooltips.count()).toBe(0);
expect(await slider.isVisible()).toBe(true);
expect(await slider.inputValue()).toBe("1");
@ -787,7 +780,7 @@ suite.define(() => {
: undefined;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/chat-model-effort-split-tooltip-${tooltipOpen}.png`,
path: `${artifactDir}/chat-model-effort-split-tooltip-${hoverEffort}.png`,
animations: "disabled",
});
}

View file

@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession } from "playwright";
import { expect as browserExpect } from "playwright/test";
import { expect, it } from "vitest";
import {
captureUiProof,
@ -36,7 +37,7 @@ async function focusListenerCounts(protocol: CDPSession, selector: string) {
suite.define(() => {
it.each(["blur", "keydown"] as const)(
"keeps retained trigger listeners bounded when %s completes pointer focus",
"keeps pointer-opening focus and retained trigger listeners bounded after %s",
async (completion) => {
await suite.withPage(
{
@ -52,19 +53,15 @@ suite.define(() => {
const selector =
'openclaw-chat-pane[aria-hidden="false"] [data-chat-permission-select="true"]';
const trigger = page.locator(selector);
const picker = page.locator(
'openclaw-chat-pane[aria-hidden="false"] .chat-controls__permission-picker',
);
const composer = page.locator(
'openclaw-chat-pane[aria-hidden="false"] .agent-chat__composer-combobox textarea',
);
const retainedTrigger = expectDefined(await trigger.elementHandle(), "picker trigger");
const protocol = await context.newCDPSession(page);
const marker = "data-chat-pointer-restored-focus";
try {
const cycle = async () => {
await trigger.click();
await expect.poll(() => trigger.getAttribute(marker)).toBe("");
await browserExpect(trigger).toHaveAttribute("aria-expanded", "true");
expect(
await retainedTrigger.evaluate(
(element, triggerSelector) => element === document.querySelector(triggerSelector),
@ -79,14 +76,13 @@ suite.define(() => {
} else {
await trigger.press("Shift");
}
await expect.poll(() => trigger.getAttribute(marker)).toBeNull();
if (completion === "keydown") {
// Close on the same focused trigger; blur would consume the companion listener.
await trigger.click();
}
await expect.poll(() => picker.getAttribute("open")).toBeNull();
await browserExpect(trigger).toHaveAttribute("aria-expanded", "false");
};
// Warm Web Awesome's own bindings, then compare the same completed phase.
// Compare the same completed phase after the first open binds the menu.
await cycle();
const before = await focusListenerCounts(protocol, selector);
for (let index = 0; index < 20; index += 1) {
@ -101,10 +97,11 @@ suite.define(() => {
}
await captureUiProof(suite, page, "picker-focus", `${completion}-completed.png`);
await trigger.click();
await expect.poll(() => trigger.getAttribute(marker)).toBe("");
await browserExpect(trigger).toHaveAttribute("aria-expanded", "true");
await browserExpect(trigger).toBeFocused();
await captureUiProof(suite, page, "picker-focus", `${completion}-open.png`);
await trigger.press("Shift");
await expect.poll(() => trigger.getAttribute(marker)).toBeNull();
await browserExpect(trigger).toBeFocused();
expect(after).toEqual(before);
} finally {
await protocol.detach();
@ -115,7 +112,7 @@ suite.define(() => {
},
);
it("shows the selected permission and disables dropdown motion when requested", async () => {
it("preserves permission selection, keyboard shortcuts, and reduced motion", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
reducedMotion: "reduce",
@ -130,7 +127,7 @@ suite.define(() => {
permissionMode: "guarded",
updatedAt: 2,
};
await installMockGateway(page, {
const gateway = await installMockGateway(page, {
methodResponses: { "sessions.list": chatSessionListResponse([session]) },
sessionKey: session.key,
});
@ -138,34 +135,38 @@ suite.define(() => {
try {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, session.key));
const pane = page.locator('openclaw-chat-pane[aria-hidden="false"]');
const picker = pane.locator(".chat-controls__permission-picker");
await pane.locator('[data-chat-permission-select="true"]').click();
const menu = pane.locator(".chat-controls__permission-menu");
const trigger = pane.locator('[data-chat-permission-select="true"]');
await trigger.click();
await pane.locator('[data-chat-permission-option="default"]').waitFor({ state: "visible" });
const [selectedBackground, unselectedBackground, showDuration, hideDuration] =
await Promise.all([
pane
.locator('[data-chat-permission-option="guarded"]')
.evaluate((element) => getComputedStyle(element).backgroundColor),
pane
.locator('[data-chat-permission-option="workspace"]')
.evaluate((element) => getComputedStyle(element).backgroundColor),
picker.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).getPropertyValue("--show-duration")),
),
picker.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hide-duration")),
),
]);
const [selectedBackground, unselectedBackground, transitionDuration] = await Promise.all([
pane
.locator('[data-chat-permission-option="guarded"]')
.evaluate((element) => getComputedStyle(element).backgroundColor),
pane
.locator('[data-chat-permission-option="workspace"]')
.evaluate((element) => getComputedStyle(element).backgroundColor),
menu.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)),
]);
expect(selectedBackground).not.toBe(unselectedBackground);
expect(showDuration).toBe(0);
expect(hideDuration).toBe(0);
expect(transitionDuration).toBe(0);
await trigger.press("4");
const patch = await gateway.waitForRequest("sessions.patch");
expect(patch.params).toMatchObject({ permissionMode: "workspace" });
await browserExpect(trigger).toHaveAttribute("aria-expanded", "false");
await trigger.click();
await browserExpect(trigger).toHaveAttribute("aria-expanded", "true");
await trigger.press("q");
await expect
.poll(() => pane.locator(".agent-chat__composer-combobox textarea").inputValue())
.toBe("q");
} finally {
await suite.closeBrowserContext(context);
}
});
it("animates inline pickers from their placed edge and honors reduced motion", async () => {
it("animates inline pickers, honors reduced motion, and keeps flipped panels visible", async () => {
const context = await suite.newBrowserContext(createControlUiE2eContextOptions());
const page = await context.newPage();
const gateway = await installMockGateway(page, {
@ -183,11 +184,11 @@ suite.define(() => {
for (const picker of [
{
popup: ".chat-controls__model-picker > wa-popup",
popup: ".chat-controls__model-picker > [data-anchored-overlay]",
trigger: '[data-chat-model-select="true"]',
},
{
popup: ".chat-controls__effort-picker > wa-popup",
popup: ".chat-controls__effort-picker > [data-anchored-overlay]",
trigger: '[data-chat-thinking-select="true"]',
},
]) {
@ -203,17 +204,19 @@ suite.define(() => {
const trigger = control.locator(picker.trigger);
const popup = control.locator(picker.popup);
await trigger.click();
await expect.poll(() => popup.getAttribute("data-current-placement")).toMatch(/^top/u);
await expect
.poll(async () => {
const panel = expectDefined(await popup.boundingBox(), "picker bounds");
const button = expectDefined(await trigger.boundingBox(), "trigger bounds");
return panel.y + panel.height <= button.y;
})
.toBe(true);
const topMotion = await popup.evaluate((element) => {
const surface = element.shadowRoot?.querySelector<HTMLElement>('[part~="popup"]');
if (!surface) {
return null;
}
const style = getComputedStyle(surface);
const style = getComputedStyle(element);
return {
animationName: style.animationName,
height: surface.offsetHeight,
height: Number.parseFloat(style.height),
originY: Number.parseFloat(style.transformOrigin.split(" ")[1] ?? ""),
};
});
@ -221,26 +224,21 @@ suite.define(() => {
expect(topMotion?.originY).toBeCloseTo(topMotion?.height ?? -1, 0);
await page.emulateMedia({ reducedMotion: "reduce" });
expect(
await popup.evaluate((element) => {
const surface = element.shadowRoot?.querySelector<HTMLElement>('[part~="popup"]');
return surface ? getComputedStyle(surface).animationName : null;
}),
).toBe("none");
expect(await popup.evaluate((element) => getComputedStyle(element).animationName)).toBe(
"none",
);
await page.setViewportSize({ height: 320, width: 1280 });
await control.evaluate((element) => {
(element as HTMLElement).style.top = "24px";
});
await expect.poll(() => popup.getAttribute("data-current-placement")).toMatch(/^bottom/u);
expect(
await popup.evaluate((element) => {
const surface = element.shadowRoot?.querySelector<HTMLElement>('[part~="popup"]');
return surface
? Number.parseFloat(getComputedStyle(surface).transformOrigin.split(" ")[1] ?? "")
: null;
}),
).toBeCloseTo(0, 0);
await expect
.poll(async () => {
const panel = expectDefined(await popup.boundingBox(), "flipped picker bounds");
const button = expectDefined(await trigger.boundingBox(), "trigger bounds");
return panel.y >= button.y + button.height && panel.y + panel.height <= 320;
})
.toBe(true);
await trigger.click();
}
} finally {

View file

@ -578,12 +578,21 @@ suite.define(() => {
await expect.poll(() => sessionMenu.count()).toBe(0);
await expect.poll(() => dialog.isVisible()).toBe(true);
const pageDetails = page.locator(".chat-controls__model-picker").first();
await pageDetails.evaluate((element) => ((element as HTMLDetailsElement).open = true));
// A native picker refuses to open in inert content; use an ordinary disclosure
// to verify that the modal drawer owns Escape before the pane's legacy handler.
await page.evaluate(() => {
const details = document.createElement("details");
details.id = "background-navigation-disclosure";
details.className = "chat-controls__inline-select";
details.open = true;
document.querySelector(".chat-controls__model-picker")!.parentElement!.append(details);
});
const pageDetails = page.locator("#background-navigation-disclosure");
await expect.poll(() => pageDetails.getAttribute("open")).toBe("");
await page.keyboard.press("Escape");
await expect.poll(() => pageDetails.getAttribute("open")).toBe("");
await expect.poll(() => dialog.isVisible()).toBe(false);
await pageDetails.evaluate((element) => element.remove());
await trigger.click();
await expect.poll(() => dialog.isVisible()).toBe(true);

View file

@ -196,7 +196,7 @@ suite.define(() => {
path.join(suite.artifactDir, "cli-agents-picker", "picker-group.png"),
await takeControlUiViewportScreenshot(
page,
page.locator('.chat-controls__model-picker wa-popup [part="popup"]'),
page.locator(".chat-controls__model-picker > [data-anchored-overlay]"),
[cliGroup],
),
);

View file

@ -59,9 +59,7 @@ suite.define(() => {
await expect.poll(() => secondShortcut.count()).toBe(1);
// Finish the picker's opening scale before recording its baseline. The top
// transform origin keeps the anchor gap stable while box geometry still grows.
await picker
.locator(':scope > wa-popup[data-anchored-overlay] > [part~="popup"]')
.evaluate(finishElementAnimations);
await picker.locator(":scope > [data-anchored-overlay]").evaluate(finishElementAnimations);
const menuGeometry = () =>
page.evaluate(() => {
const anchor = document.querySelector('[data-chat-model-select="true"]');
@ -827,7 +825,7 @@ suite.define(() => {
path.join(suite.artifactDir, "new-session-catalog-retry", "01-cli-agents-retry.png"),
await takeControlUiViewportScreenshot(
page,
page.locator('.chat-controls__model-picker wa-popup [part="popup"]'),
page.locator(".chat-controls__model-picker > [data-anchored-overlay]"),
[errorState],
),
);
@ -884,7 +882,7 @@ suite.define(() => {
),
await takeControlUiViewportScreenshot(
page,
page.locator('.chat-controls__model-picker wa-popup [part="popup"]'),
page.locator(".chat-controls__model-picker > [data-anchored-overlay]"),
[page.locator('[data-chat-model-target="anthropic"]')],
),
);

View file

@ -361,7 +361,7 @@ suite.define(() => {
.poll(() => page.locator(".chat-controls__permission-option").first().isVisible())
.toBe(true);
await captureProjectUiProof(suite, page, "mobile-new-session-permissions-open.png", {
surface: page.locator('.chat-controls__permission-picker [part="menu"]'),
surface: page.locator(".chat-controls__permission-menu"),
content: [page.locator(".chat-controls__permission-option").first()],
});
await page.keyboard.press("Escape");
@ -374,7 +374,7 @@ suite.define(() => {
await revealChatModelOption(capturedModelOption);
}
await captureProjectUiProof(suite, page, "mobile-new-session-model-open.png", {
surface: page.locator('.chat-controls__model-picker wa-popup [part="popup"]'),
surface: page.locator(".chat-controls__model-picker > [data-anchored-overlay]"),
content: [capturedModelOption],
});
expect(
@ -387,7 +387,7 @@ suite.define(() => {
await page.locator('[data-chat-thinking-select="true"]').click();
await expect.poll(() => page.locator(".chat-controls__effort-menu").isVisible()).toBe(true);
await captureProjectUiProof(suite, page, "mobile-new-session-effort-open.png", {
surface: page.locator('.chat-controls__effort-picker wa-popup [part="popup"]'),
surface: page.locator(".chat-controls__effort-picker > [data-anchored-overlay]"),
content: [fastMode],
});
await page.keyboard.press("Escape");

View file

@ -248,7 +248,7 @@ export function defineSessionPlacementModuleBoundaryTests(
await thinkingSlider.press("End");
await expect.poll(() => effortSelect.getAttribute("data-chat-thinking-value")).toBe("high");
await captureUiProof(suite, page, "01-cloud-thinking-level.png", {
surface: page.locator('.chat-controls__effort-picker wa-popup [part="popup"]'),
surface: page.locator(".chat-controls__effort-picker > [data-anchored-overlay]"),
content: [thinkingSlider],
});
await effortSelect.click();

View file

@ -48,10 +48,12 @@ it("updates composer text on a live locale change without disturbing an IME draf
onDraftChange,
});
const view = mountComposer(props);
view.container
.querySelector(".chat-controls__permission-picker")!
.dispatchEvent(new Event("wa-show"));
flush();
view.container.querySelector<HTMLButtonElement>("[data-chat-permission-select]")!.click();
await waitForSolid(() =>
expect(
view.container.querySelector(".chat-controls__permission-menu")?.matches(":popover-open"),
).toBe(true),
);
const textarea = view.container.querySelector("textarea")!;
const heading = view.container.querySelector(".chat-controls__permission-heading")!;
expect(heading.textContent).toBe("Execution permissions");
@ -220,13 +222,14 @@ it("ends the old IME scope without writing into a newly selected session", () =>
expect(onNewDraftChange).toHaveBeenCalledWith("second draft edited", undefined);
});
it("keeps the open permission picker through parent updates", () => {
it("keeps the open permission picker through parent updates", async () => {
const permissionPicker = { canSelectFull: true, onSelect: vi.fn() };
const view = mountComposer(createComposerProps({ permissionPicker }));
const picker = view.container.querySelector<HTMLElement & { open: boolean }>(
".chat-controls__permission-picker",
)!;
picker.open = true;
const picker = view.container.querySelector(".chat-controls__permission-picker")!;
const trigger = picker.querySelector<HTMLButtonElement>("[data-chat-permission-select]")!;
trigger.click();
const surface = picker.querySelector<HTMLElement>('[role="menu"]')!;
await waitForSolid(() => expect(surface.matches(":popover-open")).toBe(true));
view.update((previous) => ({
...previous,
stream: "More response",
@ -234,7 +237,9 @@ it("keeps the open permission picker through parent updates", () => {
}));
flush();
expect(view.container.querySelector(".chat-controls__permission-picker")).toBe(picker);
expect(picker.open).toBe(true);
expect(picker.querySelector('[role="menu"]')).toBe(surface);
expect(surface.matches(":popover-open")).toBe(true);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
});
it("keeps a pending dictation send bound to the action that started it", async () => {

View file

@ -24,7 +24,7 @@ import {
type GatewayRequestHandler,
} from "../../test-helpers/gateway-client.ts";
import { sessionMutationGatewayHello } from "../../test-helpers/gateway-methods.ts";
import { flush } from "../../test-helpers/solid-settle.ts";
import { flush, waitForSolid } from "../../test-helpers/solid-settle.ts";
import { makeChatHost } from "./chat-host.test-support.ts";
import { admitQueuedMessageForSession } from "./chat-outbox-admission.test-support.ts";
import { renderChatPaneComposerControls } from "./chat-pane-session-controls.ts";
@ -347,6 +347,11 @@ describe("chat pane composer controls", () => {
it("patches a rootless session, clears to default, and locks full access", async () => {
const container = document.createElement("div");
document.body.append(container);
onTestFinished(() => {
render(undefined, container);
container.remove();
});
const selectedSession: GatewaySessionRow = {
key: "agent:main:permission-test",
kind: "direct",
@ -372,16 +377,16 @@ describe("chat pane composer controls", () => {
canSelectFull: false,
});
render(solidTemplate(ChatPermissionPicker, controls.permissionPicker), container);
expect(container.querySelector("wa-dropdown-item")).toBeNull();
container.querySelector("wa-dropdown")!.dispatchEvent(new Event("wa-show"));
flush();
const trigger = container.querySelector<HTMLButtonElement>("[data-chat-permission-select]")!;
trigger.click();
const menu = container.querySelector<HTMLElement>('[role="menu"]')!;
await waitForSolid(() => expect(menu.matches(":popover-open")).toBe(true));
const heading = container.querySelector<HTMLElement>(".chat-controls__permission-heading");
expect(heading?.textContent?.trim()).toBe("Execution permissions");
expect(heading?.closest("wa-dropdown-item")).toBeNull();
const docsLink = container.querySelector<HTMLElement>(
"wa-dropdown > wa-dropdown-item.chat-controls__permission-learn-more",
);
expect(heading?.closest('[role="menuitem"]')).toBeNull();
const docsLink = container.querySelector<HTMLElement>("a.chat-controls__permission-learn-more");
expect(docsLink?.textContent?.trim()).toBe("Learn more");
expect(docsLink?.getAttribute("href")).toBe(
"https://docs.openclaw.ai/gateway/permission-modes",
@ -390,8 +395,6 @@ describe("chat pane composer controls", () => {
const rel = docsLink?.getAttribute("rel")?.split(/\s+/).toSorted();
expect(rel).toEqual(["noopener", "noreferrer"]);
const dropdown = container.querySelector<HTMLElement>(".chat-controls__permission-picker");
dropdown?.setAttribute("open", "");
const full = container.querySelector<HTMLElement>('[data-chat-permission-option="full"]');
const defaultOption = container.querySelector<HTMLElement>(
'[data-chat-permission-option="default"]',
@ -410,7 +413,7 @@ describe("chat pane composer controls", () => {
expect(full?.querySelector(".chat-controls__inline-select-check")).toBeNull();
expect(full?.getAttribute("aria-label")).toContain("operator.admin");
dropdown?.dispatchEvent(new KeyboardEvent("keydown", { key: "3", bubbles: true }));
menu.dispatchEvent(new KeyboardEvent("keydown", { key: "3", bubbles: true }));
await vi.waitFor(() =>
expect(getPendingChatPickerPatch(state, state.sessionKey)).toBeUndefined(),
);
@ -420,8 +423,9 @@ describe("chat pane composer controls", () => {
expect.objectContaining({ agentId: undefined, expectedSessionId: "permission-test-session" }),
);
dropdown?.setAttribute("open", "");
dropdown?.dispatchEvent(new KeyboardEvent("keydown", { key: "1", bubbles: true }));
trigger.click();
await waitForSolid(() => expect(menu.matches(":popover-open")).toBe(true));
menu.dispatchEvent(new KeyboardEvent("keydown", { key: "1", bubbles: true }));
await Promise.resolve();
expect(patch).toHaveBeenLastCalledWith(
selectedSession.key,

View file

@ -153,7 +153,7 @@ export type ChatPaneConnectionScope = {
sessions: ApplicationContext["sessions"];
};
const CHAT_OPEN_DETAILS_SELECTOR =
".chat-controls__inline-select[open], .context-usage details[open], .agent-chat__attach-menu[open], .chat-pr__checks[open]";
".chat-controls__inline-select[open]:not([data-chat-native-picker]), .context-usage details[open], .agent-chat__attach-menu[open]";
export const CHAT_COMPOSER_TEXTAREA_SELECTOR = ".agent-chat__composer-combobox > textarea";
// Menus without typeahead own activation/navigation, not printable input.
// Keeping those key classes separate prevents an open menu from silently dropping a letter.
@ -176,7 +176,8 @@ const CHAT_DROPDOWN_KEYS = new Set([
function keyboardShortcutTargetOwnsKey(target: HTMLElement, key: string): boolean {
return (
/^[a-z0-9]$/iu.test(key) &&
target.matches("wa-dropdown, [data-chat-autotype-shortcuts][open]") &&
(target.matches("wa-dropdown, [data-chat-autotype-shortcuts][open]") ||
(target.matches('[role="menu"][popover]') && target.matches(":popover-open"))) &&
target.querySelector(`[data-shortcut="${key.toLowerCase()}"]:not([disabled])`) !== null
);
}
@ -202,9 +203,14 @@ function keyboardEventPathHasInteractiveTarget(event: KeyboardEvent): boolean {
function openDropdownOwnsKey(root: ParentNode, key: string): boolean {
const surface = root instanceof Element ? (root.closest("openclaw-app") ?? root) : root;
return [...surface.querySelectorAll<HTMLElement & { open?: boolean }>("wa-dropdown")].some(
return [
...surface.querySelectorAll<HTMLElement & { open?: boolean }>(
'wa-dropdown, [role="menu"][popover]',
),
].some(
(dropdown) =>
dropdown.open === true &&
(dropdown.open === true ||
(dropdown.hasAttribute("popover") && dropdown.matches(":popover-open"))) &&
!dropdown.closest("[inert]") &&
(CHAT_DROPDOWN_KEYS.has(key) || keyboardShortcutTargetOwnsKey(dropdown, key)),
);

View file

@ -4102,7 +4102,9 @@ describe("chat model controls", () => {
expect(onModelSelect).not.toHaveBeenCalled();
expect(search!.value).toBe("claude");
const nestedPicker = document.createElement("wa-dropdown");
const nestedPicker = document.createElement("div");
nestedPicker.setAttribute("role", "menu");
nestedPicker.setAttribute("popover", "manual");
const nestedTrigger = document.createElement("button");
nestedPicker.append(nestedTrigger);
details!.append(nestedPicker);

View file

@ -15,7 +15,7 @@ import { renderChatPullRequests } from "./chat-pull-requests.ts";
import "./chat-subagent-activity-live.ts";
import { ChatSummaryAutomations } from "./chat-summary-automations.tsx";
type Props = { props?: ChatDetailsProps; presented: boolean };
type Props = { props?: ChatDetailsProps; presented: boolean; dismissed?: boolean };
export type ChatDetailsSession = SolidBridgeElement<Props>;
export const ChatDetailsSession = defineSolidBridge<Props>(
@ -56,6 +56,7 @@ export const ChatDetailsSession = defineSolidBridge<Props>(
snapshot.currentAgentId ?? undefined,
),
presented: active() && pullRequestsOpen(),
dismissed: props.dismissed,
branch: snapshot.pullRequestsBranch,
branchDismissed: snapshot.pullRequestsBranchDismissed,
status: snapshot.pullRequestsStatus ?? "ready",
@ -212,6 +213,7 @@ export const ChatDetailsSession = defineSolidBridge<Props>(
properties: {
props: { default: undefined, attribute: false },
presented: { default: false, type: Boolean },
dismissed: { default: false, type: Boolean },
},
},
);

View file

@ -267,7 +267,11 @@ export const ChatDetails = defineSolidBridge<Props, Methods>(
<Show when={scope()} keyed>
{(_scope) => (
<>
<ChatDetailsSession props={props.props} presented={props.presented && opened()} />
<ChatDetailsSession
props={props.props}
presented={props.presented && opened()}
dismissed={intent() === "closed"}
/>
<Show when={props.props?.progressCard || props.props?.progressCardInitialLoading}>
<ChatDetailsProgress
onHideProgress={() => {

View file

@ -1,6 +1,7 @@
import { createSignal } from "solid-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { installTitleTooltips } from "../../../components/tooltip-title.ts";
import {
resolveChatFastModeSelectState,
type ChatFastModeSelectState,
@ -10,7 +11,10 @@ import { mountSolid } from "../../../test-helpers/mount-solid.ts";
import { flush } from "../../../test-helpers/solid-settle.ts";
import "../../../styles/base.css";
import "../../../styles/chat/composer.css";
import { closeChatPaneDetails } from "../chat-pane-shared.ts";
import { focusComposerFromChrome } from "./chat-composer-dom.ts";
import { ChatEffortPicker } from "./chat-effort-picker.tsx";
import { installChatComposerPickerDismissal } from "./chat-picker-overlay.ts";
let host: HTMLDivElement | undefined;
let updatePicker: ((props: Parameters<typeof ChatEffortPicker>[0]) => void) | undefined;
@ -29,6 +33,7 @@ async function fixture(
value: string,
inherited = false,
fastMode?: ChatFastModeSelectState,
open = true,
) {
host ??= document.body.appendChild(document.createElement("div"));
const onThinkingSelect = vi.fn(async () => undefined);
@ -75,17 +80,16 @@ async function fixture(
}
flush();
const details = host.querySelector("details")!;
if (!details.open) {
if (open && !details.open) {
const opened = new Promise<void>((resolve) => {
details.addEventListener("toggle", () => resolve(), { once: true });
});
details.open = true;
// The toggle handler activates the popup; hidden descendants have stale
// computed styles in WebKit even after their Lit update completes.
// The details toggle commits its native top-layer surface.
await opened;
}
await host.querySelector("wa-popup")!.updateComplete;
expect(host.querySelector<HTMLInputElement>("input[type=range]")!.checkVisibility()).toBe(true);
expect(host.querySelector("[data-anchored-overlay]")!.matches(":popover-open")).toBe(open);
expect(host.querySelector<HTMLInputElement>("input[type=range]")!.checkVisibility()).toBe(open);
return {
input: host.querySelector<HTMLInputElement>("input[type=range]")!,
onThinkingSelect,
@ -101,6 +105,60 @@ function appearance(input: HTMLInputElement) {
}
describe("effort bar colour and flow", () => {
it("returns Escape to its trigger after pointer and keyboard reopening with title tooltips", async () => {
const { input } = await fixture(["low", "medium", "high"], "high", false, undefined, false);
const composer = host!;
composer.classList.add("agent-chat__input");
const details = composer.querySelector("details")!;
const trigger = details.querySelector("summary")!;
const surface = details.querySelector<HTMLElement>("[data-anchored-overlay]")!;
const inputWrapper = document.createElement("div");
inputWrapper.className = "agent-chat__composer-combobox";
const textarea = document.createElement("textarea");
inputWrapper.append(textarea);
composer.prepend(inputWrapper);
onTestFinished(installChatComposerPickerDismissal(document));
onTestFinished(installTitleTooltips(document));
const focusComposer = (event: MouseEvent | PointerEvent) =>
focusComposerFromChrome(event, true);
composer.addEventListener("pointerdown", focusComposer);
composer.addEventListener("click", focusComposer);
onTestFinished(() => {
composer.removeEventListener("pointerdown", focusComposer);
composer.removeEventListener("click", focusComposer);
});
const dismissLegacyDetails = (event: KeyboardEvent) => {
if (
!event.defaultPrevented &&
!event.isComposing &&
event.keyCode !== 229 &&
event.key === "Escape" &&
closeChatPaneDetails(composer)
) {
event.preventDefault();
}
};
document.addEventListener("keydown", dismissLegacyDetails, true);
onTestFinished(() => document.removeEventListener("keydown", dismissLegacyDetails, true));
textarea.focus();
await page.elementLocator(trigger).click();
await expect.poll(() => surface.matches(":popover-open")).toBe(true);
await expect
.poll(() => surface.getAnimations().every((animation) => animation.playState === "finished"))
.toBe(true);
await userEvent.keyboard("{Escape}");
await expect.poll(() => details.open).toBe(false);
await userEvent.keyboard("{Enter}");
await expect.poll(() => surface.matches(":popover-open")).toBe(true);
await userEvent.keyboard("{Tab}");
await expect.poll(() => document.activeElement).toBe(input);
await userEvent.keyboard("{Escape}");
await expect.poll(() => details.open).toBe(false);
await expect.poll(() => surface.matches(":popover-open")).toBe(false);
await expect.poll(() => document.activeElement).toBe(trigger);
});
it("keeps inherited speed selectable", async () => {
document.documentElement.dataset.themeMode = "dark";
const fastMode = resolveChatFastModeSelectState({
@ -126,8 +184,6 @@ describe("effort bar colour and flow", () => {
],
});
const { onFastModeSelect } = await fixture(["low", "medium", "high"], "high", false, fastMode);
host!.style.cssText = "padding: 180px; width: 640px; height: 360px";
await page.screenshot({ path: "../../../../.vitest/attachments/speed-active.png" });
for (const value of ["off", "on", "ultrafast"]) {
const option = host!.querySelector<HTMLButtonElement>(
'[data-chat-speed-option="' + value + '"]',

View file

@ -1,4 +1,4 @@
import { For, createMemo } from "solid-js";
import { For, Show, createMemo } from "solid-js";
import { Icon } from "../../../components/solid/icon.tsx";
import "../../../components/tooltip.ts";
import { registerModelControlsEnglish } from "../../../i18n/locales/en-model-controls.ts";
@ -9,7 +9,7 @@ import type {
import type { ChatThinkingSelectState } from "../../../lib/chat/thinking.ts";
import { registerEnglishCatalog, t } from "../../../lib/reactive/i18n.ts";
import { liveValue } from "../../../lib/reactive/live-value.ts";
import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-picker-overlay.ts";
import { createChatPickerOverlay } from "./chat-picker-overlay.ts";
registerEnglishCatalog(registerModelControlsEnglish);
@ -216,267 +216,277 @@ export function ChatEffortPicker(props: ChatEffortPickerParams) {
);
};
return (
<>
{visible() && (
<details
class={[
"chat-controls__inline-select chat-controls__effort-picker",
{
"chat-controls__effort-picker--reserved": props.reserved,
},
]}
aria-hidden={props.reserved === true ? "true" : "false"}
inert={props.reserved === true}
onToggle={(event: Event) => {
// SAFETY: This toggle handler belongs to the surrounding native details.
const details = event.currentTarget as HTMLDetailsElement;
handleChatComposerDetailsToggle(event);
syncChatPickerOverlay(details);
}}
>
<summary
<Show when={visible()}>
{(_visible) => {
const pickerOverlay = createChatPickerOverlay();
return (
<details
ref={pickerOverlay}
class={[
"chat-controls__inline-select-trigger chat-controls__effort-trigger",
"chat-controls__inline-select chat-controls__effort-picker",
{
"chat-controls__effort-trigger--ultrafast": view().ultrafast,
"chat-controls__inline-select-trigger--disabled": props.disabled,
"chat-controls__effort-picker--reserved": props.reserved,
},
]}
data-chat-thinking-select="true"
data-chat-thinking-value={view().selectedThinkingValue}
data-chat-thinking-disabled={props.thinkingDisabled ? "true" : "false"}
data-chat-fast-mode={props.fastMode.active ? "true" : "false"}
aria-label={
view().showReasoning
? `${t("chat.selectors.thinkingLevel")}: ${view().triggerTitle}`
: view().triggerTitle
}
aria-disabled={props.disabled ? "true" : "false"}
title={props.disabledReason ?? view().triggerTitle}
onClick={(event: MouseEvent) => {
if (props.disabled) {
event.preventDefault();
}
}}
aria-hidden={props.reserved === true ? "true" : "false"}
inert={props.reserved === true}
>
{view().showReasoning ? (
<span
class={[
"chat-controls__effort-gauge",
{ "chat-controls__effort-gauge--off": view().effortIsOff },
]}
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path class="chat-controls__effort-gauge-dial" d="M3.34 17a10 10 0 1 1 17.32 0" />
<path
class="chat-controls__effort-gauge-needle"
d="M12 12V6"
style={{ transform: `rotate(${view().effortAngle}deg)` }}
/>
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
</svg>
{props.fastMode.active && (
<span class="chat-controls__effort-fast-badge">
<Icon name="zap" />
</span>
)}
</span>
) : (
<span class="chat-controls__effort-speed" aria-hidden="true">
<Icon name="zap" />
</span>
)}
{props.fastMode.active && (
<span
class={[
"chat-controls__effort-zap",
{ "chat-controls__effort-zap--ultrafast": view().ultrafast },
]}
aria-hidden="true"
>
{view().ultrafast && <Icon name="zap" />}
<Icon name="zap" />
</span>
)}
<span class="chat-controls__inline-select-label">{view().triggerLabel}</span>
<span class="chat-controls__inline-select-chevron" aria-hidden="true">
<Icon name="chevronUp" />
</span>
</summary>
<wa-popup data-anchored-overlay>
<div
class="chat-controls__inline-select-menu chat-controls__effort-menu"
aria-label={t(
view().showReasoning ? "chat.modelControls.effort" : "chat.modelControls.fastMode",
)}
<summary
class={[
"chat-controls__inline-select-trigger chat-controls__effort-trigger",
{
"chat-controls__effort-trigger--ultrafast": view().ultrafast,
"chat-controls__inline-select-trigger--disabled": props.disabled,
},
]}
data-chat-thinking-select="true"
data-chat-thinking-value={view().selectedThinkingValue}
data-chat-thinking-disabled={props.thinkingDisabled ? "true" : "false"}
data-chat-fast-mode={props.fastMode.active ? "true" : "false"}
aria-label={
view().showReasoning
? `${t("chat.selectors.thinkingLevel")}: ${view().triggerTitle}`
: view().triggerTitle
}
aria-disabled={props.disabled ? "true" : "false"}
title={props.disabledReason ?? view().triggerTitle}
onClick={(event: MouseEvent) => {
if (props.disabled) {
event.preventDefault();
}
}}
>
{view().showReasoning && (
<div class="chat-controls__reasoning-panel">
<div class="chat-controls__reasoning-head">
<span class="chat-controls__effort-heading">
{t("chat.modelControls.effort")}
{view().showReasoning ? (
<span
class={[
"chat-controls__effort-gauge",
{ "chat-controls__effort-gauge--off": view().effortIsOff },
]}
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
class="chat-controls__effort-gauge-dial"
d="M3.34 17a10 10 0 1 1 17.32 0"
/>
<path
class="chat-controls__effort-gauge-needle"
d="M12 12V6"
style={{ transform: `rotate(${view().effortAngle}deg)` }}
/>
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
</svg>
{props.fastMode.active && (
<span class="chat-controls__effort-fast-badge">
<Icon name="zap" />
</span>
<span class="chat-controls__effort-value" aria-hidden="true">
<span data-chat-thinking-preview-committed>{view().reasoningValueText}</span>
<For each={view().sliderStops} keyed={(stop) => stop.value}>
{(stop, index) => (
<span data-chat-thinking-preview-index={index()} hidden>
{formatEffortLabel(stop().label)}
</span>
)}
</For>
</span>
</div>
{view().sliderStops.length > 1 ? (
<div class="chat-controls__reasoning-slider">
<div class="chat-controls__reasoning-dots" aria-hidden="true">
)}
</span>
) : (
<span class="chat-controls__effort-speed" aria-hidden="true">
<Icon name="zap" />
</span>
)}
{props.fastMode.active && (
<span
class={[
"chat-controls__effort-zap",
{ "chat-controls__effort-zap--ultrafast": view().ultrafast },
]}
aria-hidden="true"
>
{view().ultrafast && <Icon name="zap" />}
<Icon name="zap" />
</span>
)}
<span class="chat-controls__inline-select-label">{view().triggerLabel}</span>
<span class="chat-controls__inline-select-chevron" aria-hidden="true">
<Icon name="chevronUp" />
</span>
</summary>
<div class="oc-overlay chat-picker-overlay" data-anchored-overlay popover="manual">
<div
class="chat-controls__inline-select-menu chat-controls__effort-menu"
aria-label={t(
view().showReasoning
? "chat.modelControls.effort"
: "chat.modelControls.fastMode",
)}
>
{view().showReasoning && (
<div class="chat-controls__reasoning-panel">
<div class="chat-controls__reasoning-head">
<span class="chat-controls__effort-heading">
{t("chat.modelControls.effort")}
</span>
<span class="chat-controls__effort-value" aria-hidden="true">
<span data-chat-thinking-preview-committed>
{view().reasoningValueText}
</span>
<For each={view().sliderStops} keyed={(stop) => stop.value}>
{(stop) => (
<span class="chat-controls__reasoning-dot" data-stop={stop().value} />
{(stop, index) => (
<span data-chat-thinking-preview-index={index()} hidden>
{formatEffortLabel(stop().label)}
</span>
)}
</For>
</div>
<input
class={[
"chat-controls__reasoning-range",
{
"chat-controls__reasoning-range--inherit": !view().hasThinkingOverride,
"chat-controls__reasoning-range--unanchored": view().sliderUnanchored,
},
]}
type="range"
min="0"
max={view().sliderStops.length - 1}
step="1"
ref={liveValue(committedSliderValue)}
style={{
"--reasoning-fill": `${sliderFillPercent(view().sliderIndex, view().sliderStops.length)}%`,
}}
data-chat-thinking-slider="true"
data-effort-boost={view().committedBoost}
data-chat-thinking-values={view()
.sliderStops.map((stop) => stop.value)
.join(",")}
aria-label={t("chat.selectors.thinkingLevel")}
aria-valuetext={view().reasoningValueLabel}
disabled={props.thinkingDisabled}
onInput={onSliderDrag}
onChange={onSliderCommit}
onClick={(event: MouseEvent) => {
if (
view().sliderUnanchored &&
// SAFETY: This click handler belongs to the range input.
Number((event.currentTarget as HTMLInputElement).value) ===
view().sliderIndex
) {
onSliderCommit(event);
}
}}
onKeyDown={(event: KeyboardEvent) => {
if (
view().sliderUnanchored &&
["Home", "ArrowLeft", "ArrowDown", "PageDown"].includes(event.key)
) {
onSliderCommit(event);
}
}}
onPointerCancel={(event: PointerEvent) =>
// SAFETY: This cancellation handler belongs to the range input.
resetSliderPreview(event.currentTarget as HTMLInputElement, true)
}
onBlur={(event: FocusEvent) =>
// SAFETY: This blur handler belongs to the range input.
resetSliderPreview(event.currentTarget as HTMLInputElement, true)
}
/>
</span>
</div>
) : view().onlyStop ? (
<button
class={[
"chat-controls__reasoning-option",
{ "chat-controls__reasoning-option--selected": view().onlyStopSelected },
]}
data-chat-thinking-option={view().onlyStop?.value}
type="button"
aria-pressed={view().onlyStopSelected ? "true" : "false"}
disabled={props.thinkingDisabled}
onClick={(event: MouseEvent) => {
event.stopPropagation();
if (props.thinkingDisabled || view().onlyStopSelected) {
event.preventDefault();
return;
}
const stop = view().onlyStop;
if (stop) {
refreshAfterSelection(
props.onThinkingSelect(stop.value, props.sessionKey),
props.onRequestUpdate,
);
}
}}
>
<span>{view().onlyStop?.label}</span>
{view().onlyStopSelected && (
<span class="chat-controls__inline-select-check" aria-hidden="true">
<Icon name="check" />
</span>
)}
</button>
) : undefined}
</div>
)}
{props.fastMode.supported && (
<div class="chat-controls__speed-panel">
<span class="chat-controls__effort-heading">{t("chat.modelControls.speed")}</span>
<div
class="chat-controls__speed-options"
role="radiogroup"
aria-label={t("chat.modelControls.speed")}
onKeyDown={onSpeedKeyDown}
>
<For each={view().speedOptions} keyed={(option) => option.value}>
{(option, index) => (
<button
type="button"
role="radio"
class="chat-controls__speed-option"
data-chat-speed-option={option().value}
aria-checked={option().value === view().selectedSpeed ? "true" : "false"}
tabindex={index() === view().tabbableSpeedIndex ? 0 : -1}
disabled={props.fastMode.disabled || option().disabled}
{view().sliderStops.length > 1 ? (
<div class="chat-controls__reasoning-slider">
<div class="chat-controls__reasoning-dots" aria-hidden="true">
<For each={view().sliderStops} keyed={(stop) => stop.value}>
{(stop) => (
<span class="chat-controls__reasoning-dot" data-stop={stop().value} />
)}
</For>
</div>
<input
class={[
"chat-controls__reasoning-range",
{
"chat-controls__reasoning-range--inherit":
!view().hasThinkingOverride,
"chat-controls__reasoning-range--unanchored": view().sliderUnanchored,
},
]}
type="range"
min="0"
max={view().sliderStops.length - 1}
step="1"
ref={liveValue(committedSliderValue)}
style={{
"--reasoning-fill": `${sliderFillPercent(view().sliderIndex, view().sliderStops.length)}%`,
}}
data-chat-thinking-slider="true"
data-effort-boost={view().committedBoost}
data-chat-thinking-values={view()
.sliderStops.map((stop) => stop.value)
.join(",")}
aria-label={t("chat.selectors.thinkingLevel")}
aria-valuetext={view().reasoningValueLabel}
disabled={props.thinkingDisabled}
onInput={onSliderDrag}
onChange={onSliderCommit}
onClick={(event: MouseEvent) => {
event.stopPropagation();
if (
!props.fastMode.disabled &&
!option().disabled &&
option().value !== props.fastMode.persistedOverride
view().sliderUnanchored &&
// SAFETY: This click handler belongs to the range input.
Number((event.currentTarget as HTMLInputElement).value) ===
view().sliderIndex
) {
refreshAfterSelection(
props.onFastModeSelect(option().value, props.sessionKey),
props.onRequestUpdate,
);
onSliderCommit(event);
}
}}
>
{option().label}
</button>
)}
</For>
onKeyDown={(event: KeyboardEvent) => {
if (
view().sliderUnanchored &&
["Home", "ArrowLeft", "ArrowDown", "PageDown"].includes(event.key)
) {
onSliderCommit(event);
}
}}
onPointerCancel={(event: PointerEvent) =>
// SAFETY: This cancellation handler belongs to the range input.
resetSliderPreview(event.currentTarget as HTMLInputElement, true)
}
onBlur={(event: FocusEvent) =>
// SAFETY: This blur handler belongs to the range input.
resetSliderPreview(event.currentTarget as HTMLInputElement, true)
}
/>
</div>
) : view().onlyStop ? (
<button
class={[
"chat-controls__reasoning-option",
{ "chat-controls__reasoning-option--selected": view().onlyStopSelected },
]}
data-chat-thinking-option={view().onlyStop?.value}
type="button"
aria-pressed={view().onlyStopSelected ? "true" : "false"}
disabled={props.thinkingDisabled}
onClick={(event: MouseEvent) => {
event.stopPropagation();
if (props.thinkingDisabled || view().onlyStopSelected) {
event.preventDefault();
return;
}
const stop = view().onlyStop;
if (stop) {
refreshAfterSelection(
props.onThinkingSelect(stop.value, props.sessionKey),
props.onRequestUpdate,
);
}
}}
>
<span>{view().onlyStop?.label}</span>
{view().onlyStopSelected && (
<span class="chat-controls__inline-select-check" aria-hidden="true">
<Icon name="check" />
</span>
)}
</button>
) : undefined}
</div>
</div>
)}
)}
{props.fastMode.supported && (
<div class="chat-controls__speed-panel">
<span class="chat-controls__effort-heading">
{t("chat.modelControls.speed")}
</span>
<div
class="chat-controls__speed-options"
role="radiogroup"
aria-label={t("chat.modelControls.speed")}
onKeyDown={onSpeedKeyDown}
>
<For each={view().speedOptions} keyed={(option) => option.value}>
{(option, index) => (
<button
type="button"
role="radio"
class="chat-controls__speed-option"
data-chat-speed-option={option().value}
aria-checked={
option().value === view().selectedSpeed ? "true" : "false"
}
tabindex={index() === view().tabbableSpeedIndex ? 0 : -1}
disabled={props.fastMode.disabled || option().disabled}
onClick={(event: MouseEvent) => {
event.stopPropagation();
if (
!props.fastMode.disabled &&
!option().disabled &&
option().value !== props.fastMode.persistedOverride
) {
refreshAfterSelection(
props.onFastModeSelect(option().value, props.sessionKey),
props.onRequestUpdate,
);
}
}}
>
{option().label}
</button>
)}
</For>
</div>
</div>
)}
</div>
</div>
</wa-popup>
</details>
)}
</>
</details>
);
}}
</Show>
);
}

View file

@ -7,6 +7,7 @@ import type {
import { afterEach, describe, expect, it, vi } from "vitest";
import { applicationContext, type ApplicationContext } from "../../../app/context.ts";
import { bumpCanvasWidgetFrameConnectionGeneration } from "../../../lib/chat/canvas-widget-frame-generation.ts";
import { waitForSolid } from "../../../test-helpers/solid-settle.ts";
import type { ChatHtmlPreviewElement } from "./chat-html-preview-element.tsx";
import "./chat-html-preview-element.tsx";
@ -393,9 +394,11 @@ describe("ordinary HTML preview transport", () => {
"rejects invalid or Gateway-origin sandbox metadata (%s)",
async (sandboxPort) => {
const { view } = mount(vi.fn().mockResolvedValue({ ...metadata, sandboxPort }));
await expect
.poll(() => view.querySelector('[role="alert"]')?.textContent)
.toContain("Sandbox host URL is invalid");
await waitForSolid(() => {
expect(view.querySelector('[role="alert"]')?.textContent).toContain(
"Sandbox host URL is invalid",
);
});
expect(view.querySelector("iframe")).toBeNull();
},
);
@ -469,9 +472,9 @@ describe("ordinary HTML preview transport", () => {
.mockRejectedValueOnce(new Error("Preview denied"))
.mockResolvedValue(metadata);
const { view } = mount(request);
await expect
.poll(() => view.querySelector('[role="alert"]')?.textContent)
.toContain("Preview denied");
await waitForSolid(() => {
expect(view.querySelector('[role="alert"]')?.textContent).toContain("Preview denied");
});
view.title += " ";
await view.updateComplete;
expect(request).toHaveBeenCalledOnce();

View file

@ -259,11 +259,17 @@ export function handleModelPickerKeydown(event: KeyboardEvent): void {
// SAFETY: Bound only to the model picker’s details element.
const details = event.currentTarget as HTMLDetailsElement;
if (
event.defaultPrevented ||
!details.open ||
event.target instanceof HTMLInputElement ||
event
.composedPath()
.some((target) => target instanceof HTMLElement && target.localName === "wa-dropdown") ||
.some(
(target) =>
target instanceof HTMLElement &&
target !== details &&
target.matches('[role="menu"][popover], details[data-chat-native-picker]'),
) ||
!/^[1-9]$/u.test(event.key)
) {
return;

View file

@ -1,4 +1,3 @@
import type WaPopup from "@awesome.me/webawesome/dist/components/popup/popup.js";
import { createSignal } from "solid-js";
import { afterEach, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
@ -67,14 +66,13 @@ function mountPicker() {
const trigger = picker.querySelector("summary")!;
const search = picker.querySelector<HTMLInputElement>("[data-chat-model-search]")!;
const composer = container.querySelector("textarea")!;
const popup = picker.querySelector<WaPopup>("wa-popup")!;
const toggle = async (activate: () => Promise<void>) => {
const toggled = new Promise<void>((resolve) => {
picker.addEventListener("toggle", () => resolve(), { once: true });
});
await activate();
await toggled;
await popup.updateComplete;
flush();
await new Promise(requestAnimationFrame);
};
return { params, update, picker, trigger, search, composer, toggle };
@ -110,6 +108,30 @@ it("keeps typing in the filter and preserves focus and query across catalog rere
expect(params.onModelSelect).toHaveBeenCalledWith("example/beta", "main", undefined);
});
it("leaves digit shortcuts to a nested native menu", async () => {
const { picker, toggle, params, trigger } = mountPicker();
await toggle(() => page.getByText("Alpha", { exact: true }).first().click());
const menu = document.createElement("div");
menu.setAttribute("role", "menu");
menu.setAttribute("popover", "manual");
const item = document.createElement("button");
item.setAttribute("role", "menuitem");
item.dataset.shortcut = "1";
item.textContent = "Nested choice";
menu.append(item);
picker.querySelector(".chat-controls__model-menu")!.append(menu);
menu.showPopover();
item.focus();
await userEvent.keyboard("1");
expect(params.onModelSelect).not.toHaveBeenCalled();
expect(picker.open).toBe(true);
menu.hidePopover();
menu.remove();
trigger.focus();
await userEvent.keyboard("1");
expect(params.onModelSelect).toHaveBeenCalledWith("example/alpha", "main", undefined);
});
it("focuses the filter on keyboard open and reopen, and returns Escape to the trigger", async () => {
const { picker, trigger, search, params, toggle } = mountPicker();
trigger.focus();

View file

@ -1,16 +1,18 @@
/* @vitest-environment jsdom */
import { createSignal } from "solid-js";
import { expect, it, vi } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import type { ModelProviderAuthLabel } from "../../../lib/model-provider-auth-label.ts";
import { mountSolid } from "../../../test-helpers/mount-solid.ts";
import { flush } from "../../../test-helpers/solid-settle.ts";
import { ChatModelPicker } from "./chat-model-picker.tsx";
afterEach(() => document.body.replaceChildren());
it.each([false, true])(
"initially expands the selected provider and retains toggles on reopen (inherited=%s)",
async (inherited) => {
const container = document.createElement("div");
const container = document.body.appendChild(document.createElement("div"));
const params: Parameters<typeof ChatModelPicker>[0] = {
disabled: false,
modelSelectionLocked: false,
@ -90,7 +92,7 @@ it.each([false, true])(
);
it.each([false, true])("keeps current visible with Default=%s", (hasDefault) => {
const container = document.createElement("div");
const container = document.body.appendChild(document.createElement("div"));
mountSolid(
() => (
<ChatModelPicker
@ -128,7 +130,7 @@ it.each([false, true])("keeps current visible with Default=%s", (hasDefault) =>
it.each([false, true])(
"drops the All models row once expanded so the rest continue the group (rest disabled=%s)",
async (restDisabled) => {
const container = document.createElement("div");
const container = document.body.appendChild(document.createElement("div"));
document.body.append(container);
mountSolid(
() => (
@ -197,7 +199,7 @@ it.each([false, true])(
);
it("groups Anthropic refs pinned to Claude CLI under Claude CLI", () => {
const container = document.createElement("div");
const container = document.body.appendChild(document.createElement("div"));
const option = (provider: string, id: string, agentRuntimeId?: string) => ({
provider,
value: `${provider}/${id}`,

View file

@ -37,7 +37,7 @@ import {
toggleModelProviderGroup,
updateModelSearch,
} from "./chat-model-picker-search.ts";
import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-picker-overlay.ts";
import { createChatPickerOverlay } from "./chat-picker-overlay.ts";
registerEnglishCatalog(registerModelControlsEnglish);
@ -195,6 +195,7 @@ function ModelList(props: { label: string; more?: boolean; children: JSX.Element
}
export function ChatModelPicker(params: ChatModelPickerParams) {
const pickerOverlay = createChatPickerOverlay();
const state = createMemo(() => prepareChatModelPicker(params));
const contextWindowContent = createMemo(() => {
const control = params.contextWindow;
@ -380,6 +381,7 @@ export function ChatModelPicker(params: ChatModelPickerParams) {
<details
ref={(element) => {
details = element;
pickerOverlay(element);
}}
class="chat-controls__inline-select chat-controls__model-picker"
data-chat-autotype-shortcuts
@ -389,8 +391,6 @@ export function ChatModelPicker(params: ChatModelPickerParams) {
// SAFETY: This toggle handler belongs to the surrounding native details.
const target = event.currentTarget as HTMLDetailsElement;
params.onOpenChange?.(target.open);
handleChatComposerDetailsToggle(event);
syncChatPickerOverlay(target);
if (!target.open) {
params.accountSection?.onClose();
resetModelSearch(target);
@ -469,7 +469,7 @@ export function ChatModelPicker(params: ChatModelPickerParams) {
<Icon name="chevronUp" />
</span>
</summary>
<wa-popup data-anchored-overlay>
<div class="oc-overlay chat-picker-overlay" data-anchored-overlay popover="manual">
<div
class="chat-controls__inline-select-menu chat-controls__model-menu"
aria-label={t("chat.selectors.model")}
@ -623,7 +623,7 @@ export function ChatModelPicker(params: ChatModelPickerParams) {
</div>
) : undefined}
</div>
</wa-popup>
</div>
</details>
);
}

View file

@ -0,0 +1,93 @@
/* @vitest-environment jsdom */
import { expect, it, onTestFinished, vi } from "vitest";
import { mountSolid } from "../../../test-helpers/mount-solid.ts";
import { flush, waitForSolid } from "../../../test-helpers/solid-settle.ts";
import { focusChatComposerFromPrintableKeydown } from "../chat-pane-shared.ts";
import { ChatPermissionPicker } from "./chat-permission-picker.tsx";
it("keeps locked modes unavailable and selects enabled numeric shortcuts with return focus", async () => {
const onSelect = vi.fn();
const { container } = mountSolid(() => (
<ChatPermissionPicker canSelectFull={false} mode="guarded" onSelect={onSelect} />
));
flush();
const trigger = container.querySelector<HTMLButtonElement>("[data-chat-permission-select]")!;
trigger.focus();
trigger.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true, cancelable: true }));
const menu = container.querySelector<HTMLElement>('[role="menu"]')!;
await waitForSolid(() => expect(menu.matches(":popover-open")).toBe(true));
expect(document.activeElement).toBe(trigger);
expect(
container.querySelector('[data-chat-permission-option="full"]')?.matches(":disabled"),
).toBe(true);
menu.dispatchEvent(new KeyboardEvent("keydown", { key: "5", bubbles: true, cancelable: true }));
expect(onSelect).not.toHaveBeenCalled();
expect(menu.matches(":popover-open")).toBe(true);
menu.dispatchEvent(new KeyboardEvent("keydown", { key: "4", bubbles: true, cancelable: true }));
expect(onSelect).toHaveBeenCalledExactlyOnceWith("workspace");
expect(document.activeElement).toBe(trigger);
});
it("includes the documentation link in keyboard menu navigation", async () => {
const { container } = mountSolid(() => (
<ChatPermissionPicker canSelectFull={false} onSelect={() => {}} />
));
flush();
const trigger = container.querySelector<HTMLButtonElement>("[data-chat-permission-select]")!;
trigger.focus();
trigger.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
);
const link = container.querySelector<HTMLAnchorElement>(".chat-controls__permission-learn-more")!;
await waitForSolid(() => expect(document.activeElement).toBe(link));
expect(link.getAttribute("href")).toBe("https://docs.openclaw.ai/gateway/permission-modes");
expect(link.target).toBe("_blank");
link.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
);
await waitForSolid(() =>
expect(document.activeElement).toBe(
container.querySelector('[data-chat-permission-option="default"]'),
),
);
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Home", bubbles: true, cancelable: true }),
);
await waitForSolid(() => expect(document.activeElement).toBe(link));
});
it("keeps declared shortcuts in the menu and forwards ordinary typing to the composer", async () => {
const onSelect = vi.fn();
const { container } = mountSolid(() => (
<>
<div class="agent-chat__composer-combobox">
<textarea />
</div>
<ChatPermissionPicker canSelectFull={false} mode="guarded" onSelect={onSelect} />
</>
));
flush();
const redirect = (event: KeyboardEvent) =>
focusChatComposerFromPrintableKeydown(container, event);
document.addEventListener("keydown", redirect, true);
onTestFinished(() => document.removeEventListener("keydown", redirect, true));
const trigger = container.querySelector<HTMLButtonElement>("[data-chat-permission-select]")!;
const menu = container.querySelector<HTMLElement>('[role="menu"]')!;
container.querySelector("textarea")!.focus();
trigger.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true, cancelable: true }));
await waitForSolid(() => expect(menu.matches(":popover-open")).toBe(true));
expect(document.activeElement).toBe(trigger);
trigger.dispatchEvent(
new KeyboardEvent("keydown", { key: "4", bubbles: true, cancelable: true }),
);
expect(onSelect).toHaveBeenCalledExactlyOnceWith("workspace");
expect(document.activeElement).toBe(trigger);
trigger.click();
await waitForSolid(() => expect(menu.matches(":popover-open")).toBe(true));
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "q", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(container.querySelector("textarea"));
expect(onSelect).toHaveBeenCalledTimes(1);
});

View file

@ -1,10 +1,11 @@
import { For, Show, createMemo, createSignal } from "solid-js";
import { createMemo, createUniqueId, onCleanup } from "solid-js";
import type { SessionPermissionMode } from "../../../../../packages/gateway-protocol/src/index.js";
import { Icon } from "../../../components/solid/icon.tsx";
import { Menu, type MenuHandle, type MenuItem } from "../../../components/solid/menu.tsx";
import { registerModelControlsEnglish } from "../../../i18n/locales/en-model-controls.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../../lib/external-link.ts";
import { registerEnglishCatalog, t } from "../../../lib/reactive/i18n.ts";
import { restorePointerOpenedChatComposerTrigger } from "./chat-picker-overlay.ts";
import { closeOtherChatComposerPickers } from "./chat-picker-overlay.ts";
registerEnglishCatalog(registerModelControlsEnglish);
@ -31,35 +32,6 @@ export type ChatPermissionPickerProps = {
pending?: boolean;
};
function handlePermissionPickerKeydown(
event: KeyboardEvent,
onSelect: (mode: PermissionSelection) => void,
): void {
const dropdown = event.currentTarget;
if (
!(dropdown instanceof HTMLElement) ||
!dropdown.hasAttribute("open") ||
!/^[1-5]$/u.test(event.key)
) {
return;
}
const option = dropdown.querySelector<HTMLElement>(
`[data-chat-permission-shortcut="${event.key}"]`,
);
if (!option || option.hasAttribute("disabled")) {
return;
}
const mode = permissionSelection(option.dataset.chatPermissionOption);
if (mode === undefined) {
return;
}
event.preventDefault();
event.stopPropagation();
onSelect(mode);
dropdown.removeAttribute("open");
dropdown.querySelector<HTMLButtonElement>("[slot=trigger]")?.focus();
}
function modeLabel(
mode: SessionPermissionMode | null | undefined,
defaultMode?: SessionPermissionMode,
@ -73,11 +45,10 @@ function modeLabel(
: t("chat.permissionControls.default");
}
function permissionSelection(value: string | undefined): PermissionSelection | undefined {
if (value === DEFAULT_PERMISSION_VALUE) {
return null;
}
return PERMISSION_MODES.find((mode) => mode === value);
function permissionSelection(value: string): PermissionSelection | undefined {
return value === DEFAULT_PERMISSION_VALUE
? null
: PERMISSION_MODES.find((mode) => mode === value);
}
function permissionIcon(mode: PermissionSelection | undefined) {
@ -85,126 +56,144 @@ function permissionIcon(mode: PermissionSelection | undefined) {
}
export function ChatPermissionPicker(props: ChatPermissionPickerProps) {
const [opened, setOpened] = createSignal(false);
const id = createUniqueId();
let menu: MenuHandle | undefined;
const disabled = createMemo(() => props.disabled || props.pending);
const selectMode = (mode: PermissionSelection) => {
if (disabled() || (mode === "full" && !props.canSelectFull)) {
return;
}
if (mode !== (props.mode ?? null)) {
if (!disabled() && (mode !== "full" || props.canSelectFull) && mode !== (props.mode ?? null)) {
void props.onSelect(mode);
}
};
const items = createMemo<MenuItem[]>(() => [
{
id: "learn-more",
label: t("common.learnMore"),
href: PERMISSION_MODES_DOCS_URL,
attributes: { target: EXTERNAL_LINK_TARGET, rel: buildExternalLinkRel() },
class: "chat-controls__permission-learn-more learn-more-link",
},
...PERMISSION_OPTIONS.map((mode, index): MenuItem => {
const value = mode ?? DEFAULT_PERMISSION_VALUE;
const selected = (props.mode ?? null) === mode;
const locked = mode === "full" && !props.canSelectFull;
const label = modeLabel(mode, props.defaultMode);
return {
id: value,
label: locked ? `${label}. ${t("chat.permissionControls.fullRequiresAdmin")}` : label,
type: "radio",
checked: selected,
disabled: disabled() || locked,
class: `chat-controls__permission-option${selected ? " chat-controls__permission-option--selected" : ""}`,
attributes: {
"data-chat-permission-option": value,
"data-chat-permission-shortcut": String(index + 1),
"data-shortcut": String(index + 1),
title: locked ? t("chat.permissionControls.fullRequiresAdmin") : undefined,
},
icon: (
<span class="chat-controls__permission-option-icon" aria-hidden="true">
<Icon name={permissionIcon(mode)} />
</span>
),
content: (
<span class="chat-controls__permission-option-copy">
<span class="chat-controls__permission-option-title">
<span>{label}</span>
</span>
<span class="chat-controls__permission-option-description">
{mode
? t(`chat.permissionControls.modes.${mode}.description`)
: t("chat.permissionControls.defaultDescription")}
</span>
</span>
),
details: (
<span class="chat-controls__permission-option-state" aria-hidden="true">
{!selected && !locked && (
<span class="chat-controls__permission-shortcut">{index + 1}</span>
)}
{locked && (
<span class="chat-controls__permission-lock">
<Icon name="lock" />
</span>
)}
{selected && !locked && (
<span class="chat-controls__inline-select-check">
<Icon name="check" />
</span>
)}
</span>
),
};
}),
]);
const keydown = (event: KeyboardEvent) => {
if (
!menu?.overlay.open ||
event.isComposing ||
event.keyCode === 229 ||
event.defaultPrevented ||
!/^[1-5]$/u.test(event.key)
) {
return;
}
const item = items()[Number(event.key)];
if (!item || item.disabled) {
return;
}
const mode = permissionSelection(item.id);
if (mode !== undefined) {
event.preventDefault();
event.stopPropagation();
selectMode(mode);
menu.close();
}
};
onCleanup(() => {
menu?.overlay.surface.removeEventListener("keydown", keydown, true);
menu?.overlay.trigger?.removeEventListener("keydown", keydown, true);
});
return (
<wa-dropdown
<Menu
id={`chat-permission-${id}`}
label={`${t("chat.permissionControls.label")}: ${modeLabel(props.mode, props.defaultMode)}`}
class="chat-controls__inline-select chat-controls__permission-picker"
panelClass="chat-controls__permission-menu"
triggerClass={`chat-controls__inline-select-trigger chat-controls__permission-trigger${props.disabled ? " chat-controls__inline-select-trigger--disabled" : ""}${!props.mode ? " chat-controls__permission-trigger--default" : ""}${(props.mode ?? props.defaultMode) === "full" ? " chat-controls__permission-trigger--full" : ""}`}
triggerAttributes={{
"data-chat-permission-select": "true",
"data-chat-select-value": props.mode ?? "",
title: props.disabledReason ?? t("chat.permissionControls.help"),
}}
disabled={Boolean(disabled())}
placement="top-start"
onWa-show={() => setOpened(true)}
onWa-after-show={restorePointerOpenedChatComposerTrigger}
onKeyDown={(event: KeyboardEvent) => handlePermissionPickerKeydown(event, selectMode)}
onWa-select={(event: CustomEvent<{ item: { value?: string } }>) => {
const mode = permissionSelection(event.detail.item.value);
pointerFocus="trigger"
items={items()}
ref={(handle) => {
menu = handle;
handle.overlay.surface.addEventListener("keydown", keydown, true);
handle.overlay.trigger?.addEventListener("keydown", keydown, true);
}}
onBeforeShow={() => {
if (menu?.overlay.trigger) {
closeOtherChatComposerPickers(menu.overlay.trigger);
}
}}
onSelect={(item) => {
const mode = permissionSelection(item.id);
if (mode !== undefined) {
selectMode(mode);
}
}}
>
<button
slot="trigger"
type="button"
class={[
"chat-controls__inline-select-trigger chat-controls__permission-trigger",
{
"chat-controls__inline-select-trigger--disabled": props.disabled,
"chat-controls__permission-trigger--default": !props.mode,
"chat-controls__permission-trigger--full": (props.mode ?? props.defaultMode) === "full",
},
]}
data-chat-permission-select="true"
data-chat-select-value={props.mode ?? ""}
aria-label={`${t("chat.permissionControls.label")}: ${modeLabel(props.mode, props.defaultMode)}`}
aria-disabled={disabled() ? "true" : "false"}
title={props.disabledReason ?? t("chat.permissionControls.help")}
disabled={disabled()}
>
trigger={
<span class="chat-controls__permission-icon" aria-hidden="true">
<Icon name={permissionIcon(props.mode)} />
</span>
</button>
<Show when={opened()}>
<div class="chat-controls__popover-title chat-controls__permission-heading">
{t("chat.permissionControls.label")}
</div>
<wa-dropdown-item
class="chat-controls__permission-learn-more learn-more-link"
href={PERMISSION_MODES_DOCS_URL}
target={EXTERNAL_LINK_TARGET}
rel={buildExternalLinkRel()}
>
{t("common.learnMore")}
</wa-dropdown-item>
<For each={PERMISSION_OPTIONS} keyed={(mode) => mode ?? DEFAULT_PERMISSION_VALUE}>
{(mode, index) => {
const value = () => mode() ?? DEFAULT_PERMISSION_VALUE;
const selected = () => (props.mode ?? null) === mode();
const locked = () => mode() === "full" && !props.canSelectFull;
const label = () => modeLabel(mode(), props.defaultMode);
return (
<wa-dropdown-item
class={[
"chat-controls__permission-option",
{ "chat-controls__permission-option--selected": selected() },
]}
value={value()}
data-chat-permission-option={value()}
data-chat-permission-shortcut={String(index() + 1)}
role="menuitemradio"
aria-checked={selected() ? "true" : "false"}
aria-label={
locked()
? `${label()}. ${t("chat.permissionControls.fullRequiresAdmin")}`
: label()
}
title={locked() ? t("chat.permissionControls.fullRequiresAdmin") : undefined}
disabled={disabled() || locked()}
>
<span slot="icon" class="chat-controls__permission-option-icon" aria-hidden="true">
<Icon name={permissionIcon(mode())} />
</span>
<span class="chat-controls__permission-option-copy">
<span class="chat-controls__permission-option-title">
<span>{label()}</span>
</span>
<span class="chat-controls__permission-option-description">
{mode()
? t(`chat.permissionControls.modes.${mode()}.description`)
: t("chat.permissionControls.defaultDescription")}
</span>
</span>
<span
slot="details"
class="chat-controls__permission-option-state"
aria-hidden="true"
>
{!selected() && !locked() && (
<span class="chat-controls__permission-shortcut">{index() + 1}</span>
)}
{locked() && (
<span class="chat-controls__permission-lock">
<Icon name="lock" />
</span>
)}
{selected() && !locked() && (
<span class="chat-controls__inline-select-check">
<Icon name="check" />
</span>
)}
</span>
</wa-dropdown-item>
);
}}
</For>
</Show>
</wa-dropdown>
}
>
<div class="chat-controls__popover-title chat-controls__permission-heading">
{t("chat.permissionControls.label")}
</div>
</Menu>
);
}

View file

@ -2,7 +2,9 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { settleTooltip, tooltipSurface } from "../../../components/tooltip.test-support.ts";
import { closeChatPaneDetails } from "../chat-pane-shared.ts";
import {
bindChatPickerOverlay,
installChatComposerPickerDismissal,
handleChatComposerDetailsToggle,
handleChatComposerDropdownShow,
@ -81,6 +83,57 @@ describe("chat picker overlay", () => {
}
});
it("lets the native owner clear search before closing and returning focus", () => {
const composer = document.createElement("div");
composer.className = "agent-chat__input";
const picker = document.createElement("details");
picker.className = "chat-controls__inline-select";
picker.innerHTML =
'<summary>Model</summary><div data-anchored-overlay popover="manual"><input data-chat-model-search></div>';
composer.append(picker);
document.body.append(composer);
const dismissLegacyDetails = (event: KeyboardEvent) => {
if (
!event.defaultPrevented &&
!event.isComposing &&
event.keyCode !== 229 &&
event.key === "Escape" &&
closeChatPaneDetails(composer)
) {
event.preventDefault();
}
};
document.addEventListener("keydown", dismissLegacyDetails, true);
onTestFinished(() => document.removeEventListener("keydown", dismissLegacyDetails, true));
onTestFinished(installChatComposerPickerDismissal(document));
onTestFinished(bindChatPickerOverlay(picker));
const trigger = picker.querySelector("summary")!;
const surface = picker.querySelector<HTMLElement>("[data-anchored-overlay]")!;
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-controls")).toBe(surface.id);
expect(document.getElementById(surface.getAttribute("aria-labelledby")!)).toBe(trigger);
expect(surface.getAttribute("role")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
picker.open = true;
picker.dispatchEvent(new Event("toggle"));
const search = picker.querySelector("input")!;
search.value = "beta";
search.focus();
const escape = () =>
search.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
escape();
expect(search.value).toBe("");
expect(picker.open).toBe(true);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(search);
escape();
expect(picker.open).toBe(false);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(trigger);
});
it("shares dismissal until the final owner releases it and can reconnect", () => {
const releaseFirst = installChatComposerPickerDismissal(document);
const releaseSecond = installChatComposerPickerDismissal(document);

View file

@ -1,5 +1,14 @@
import { createEffect, createSignal } from "solid-js";
import { syncAnchoredOverlay } from "../../../components/anchored-overlay.ts";
import { createOverlayAnchor } from "../../../components/overlay-anchor.ts";
import {
createOverlay,
findOverlayParent,
type Overlay,
} from "../../../components/overlay-lifecycle.ts";
import "../../../components/solid/overlay.css";
import { consumeTooltipEscape } from "../../../components/tooltip.ts";
import { acquireNativeOverlaySurface } from "../../../lib/native-overlay-occlusion.ts";
import { clearChatModelSearchOnEscape } from "./chat-model-picker-search.ts";
const MOBILE_COMPOSER_OVERLAY_QUERY =
@ -13,6 +22,71 @@ const composerPickerDismissals = new WeakMap<
const POINTER_RESTORED_FOCUS_ATTRIBUTE = "data-chat-pointer-restored-focus";
const POINTER_OPENED_PICKER_ATTRIBUTE = "data-chat-pointer-opened-picker";
const CHAT_COMPOSER_DISMISS_INVOCATIONS_EVENT = "openclaw-composer-dismiss-invocations";
const LEGACY_INVOCATION_MENUS =
".agent-chat__input > :is(.slash-menu, .skill-menu, .emoji-menu-popup:not([popover]))";
const nativePickers = new WeakMap<HTMLDetailsElement, { overlay: Overlay; sync(): void }>();
let nextPickerId = 0;
/** Keep native details selectors while the shared overlay owns visibility and dismissal. */
export function bindChatPickerOverlay(details: HTMLDetailsElement): () => void {
const trigger = details.querySelector<HTMLElement>(":scope > summary")!;
const surface = details.querySelector<HTMLElement>(":scope > [data-anchored-overlay]")!;
const overlay = createOverlay(`chat-picker-${++nextPickerId}`, findOverlayParent(trigger), {
exclusiveGroup: "menu",
interactionElements: () => [details],
acquireOcclusion: acquireNativeOverlaySurface,
onEscape: clearChatModelSearchOnEscape,
});
trigger.id = `${overlay.id}-trigger`;
trigger.setAttribute("aria-haspopup", "dialog");
trigger.setAttribute("aria-controls", overlay.id);
surface.id = overlay.id;
surface.setAttribute("role", "dialog");
surface.setAttribute("aria-labelledby", trigger.id);
overlay.bindSurface(surface);
overlay.bindTrigger(trigger);
const anchor = createOverlayAnchor(surface);
const sync = () => {
const mobile = details.ownerDocument.defaultView?.matchMedia?.(
MOBILE_COMPOSER_OVERLAY_QUERY,
).matches;
anchor.update(
mobile ? (details.closest<HTMLElement>(".agent-chat__input") ?? trigger) : trigger,
"top-end",
);
if (!overlay.request(details.open)) {
details.open = overlay.open;
}
};
nativePickers.set(details, { overlay, sync });
details.setAttribute("data-chat-native-picker", "");
const stop = overlay.subscribe((open) => {
details.open = open;
});
const toggle = () => {
if (details.open) {
closeOtherChatComposerPickers(details);
}
sync();
};
details.addEventListener("toggle", toggle);
sync();
return () => {
details.removeEventListener("toggle", toggle);
stop();
nativePickers.delete(details);
details.removeAttribute("data-chat-native-picker");
overlay.dispose();
anchor.dispose();
};
}
/** The ref only publishes the node; the component owner binds and releases its surface. */
export function createChatPickerOverlay(): (details: HTMLDetailsElement) => void {
const [element, setElement] = createSignal<HTMLDetailsElement>();
createEffect(element, (details) => (details ? bindChatPickerOverlay(details) : undefined));
return (details) => setElement(details);
}
function composerPickerIsOpen(picker: HTMLElement): boolean {
if (picker instanceof HTMLDetailsElement) {
@ -30,6 +104,11 @@ function openChatComposerPickers(root: ParentNode): HTMLElement[] {
}
function closeComposerPicker(picker: HTMLElement): void {
const native = picker instanceof HTMLDetailsElement ? nativePickers.get(picker) : undefined;
if (native) {
native.overlay.request(false);
return;
}
pointerOpenedDropdowns.delete(picker);
picker.removeAttribute(POINTER_OPENED_PICKER_ATTRIBUTE);
if (picker instanceof HTMLDetailsElement) {
@ -90,13 +169,11 @@ function connectChatComposerPickerDismissal(ownerDocument: Document): () => void
function dismissChatComposerPickersOutside(event: PointerEvent): void {
const path = event.composedPath();
for (const picker of openChatComposerPickers(ownerDocument)) {
if (!path.includes(picker)) {
if (!picker.hasAttribute("data-chat-native-picker") && !path.includes(picker)) {
closeComposerPicker(picker);
}
}
for (const menu of ownerDocument.querySelectorAll<HTMLElement>(
".agent-chat__input > :is(.slash-menu, .skill-menu, .emoji-menu-popup)",
)) {
for (const menu of ownerDocument.querySelectorAll<HTMLElement>(LEGACY_INVOCATION_MENUS)) {
if (!path.includes(menu)) {
menu
.closest(".agent-chat__input")
@ -116,14 +193,15 @@ function connectChatComposerPickerDismissal(ownerDocument: Document): () => void
) {
return;
}
if (ownerDocument.querySelector(".agent-chat__input details[data-chat-native-picker][open]")) {
return;
}
if (clearChatModelSearchOnEscape(event)) {
return;
}
const pickers = openChatComposerPickers(ownerDocument);
const invocationComposer = ownerDocument
.querySelector<HTMLElement>(
".agent-chat__input > :is(.slash-menu, .skill-menu, .emoji-menu-popup)",
)
.querySelector<HTMLElement>(LEGACY_INVOCATION_MENUS)
?.closest<HTMLElement>(".agent-chat__input");
if (pickers.length === 0 && !invocationComposer) {
return;
@ -168,7 +246,7 @@ function connectChatComposerPickerDismissal(ownerDocument: Document): () => void
return () => controller.abort();
}
function closeOtherChatComposerPickers(source: HTMLElement): void {
export function closeOtherChatComposerPickers(source: HTMLElement): void {
const composer = source.closest(".agent-chat__input");
if (!composer) {
return;
@ -227,6 +305,11 @@ export function restorePointerOpenedChatComposerTrigger(event: Event): void {
}
export function syncChatPickerOverlay(details: HTMLDetailsElement): void {
const native = nativePickers.get(details);
if (native) {
native.sync();
return;
}
// Mobile panels span the composer, so anchor to that stable box; desktop
// panels stay attached to the individual trigger.
const composerAnchor =

View file

@ -16,6 +16,7 @@ export function ChatPullRequestChecks(props: {
showLabel?: boolean;
checksState: string;
presented: PresentationValue;
dismissed?: boolean;
content: unknown;
}) {
let details!: HTMLDetailsElement;
@ -35,12 +36,16 @@ export function ChatPullRequestChecks(props: {
onCleanup(() => overlay.dispose());
const sync = () => {
if (mounted) {
// Explicit Details dismissal clears intent; parking only hides the surface.
if (props.dismissed) {
details.open = false;
}
overlay.request(presented() && details.open);
}
};
createEffect(
() => props.presented,
(value) => {
() => [props.presented, props.dismissed] as const,
([value]) => {
sync();
if (typeof value === "boolean") {
return undefined;

View file

@ -29,7 +29,7 @@ it.each(["props", "owner", "parked"])(
let painted = false;
let part: ReturnType<typeof render>;
const binding = { owner, isPresented: () => isPresented };
const draw = (presented: boolean) => {
const draw = (presented: boolean, dismissed = false) => {
isPresented = presented;
if (mode !== "props" && painted) {
owner.dispatchEvent(new Event(PRESENTATION_CHANGED_EVENT));
@ -59,6 +59,7 @@ it.each(["props", "owner", "parked"])(
],
status: "ready",
presented: mode === "props" ? presented : binding,
dismissed,
onDismiss: () => {},
})}
</section>
@ -87,6 +88,16 @@ it.each(["props", "owner", "parked"])(
summary.focus();
await userEvent.keyboard(" ");
await expect.element(menu).not.toBeVisible();
if (mode === "props") {
await page.elementLocator(summary).click();
await expect.element(menu).toBeVisible();
draw(false, true);
await expect.element(menu).not.toBeVisible();
expect(disclosure.open).toBe(false);
draw(true);
await expect.element(menu).not.toBeVisible();
expect(disclosure.open).toBe(false);
}
},
);

View file

@ -122,6 +122,7 @@ function renderChecks(
sessionKey?: string;
sessionId?: string;
presented?: PresentationValue;
dismissed?: boolean;
compact?: boolean;
},
) {
@ -133,6 +134,7 @@ function renderChecks(
showLabel: props.compact,
checksState: checks?.state ?? "none",
presented,
dismissed: props.dismissed,
content: html`
<div
class="chat-pr__checks-menu"
@ -303,6 +305,7 @@ export function renderChatPullRequests(props: {
sessionKey?: string;
sessionId?: string;
presented?: PresentationValue;
dismissed?: boolean;
branch?: ControlUiSessionBranch;
/** Hides the branch row and its idle publish offer; retained publication outcomes stay visible. */
branchDismissed?: boolean;

View file

@ -13,7 +13,11 @@ import {
useDebugTestEnvironment,
} from "./debug.test-support.ts";
type TestDebugContent = HTMLElement & { readonly updateComplete: Promise<boolean> };
type TestDebugContent = HTMLElement & {
readonly updateComplete: Promise<boolean>;
context: ApplicationContext;
minimized: boolean;
};
type TestDebugOverlay = HTMLElement & {
readonly updateComplete: Promise<boolean>;
@ -169,7 +173,7 @@ describe("DebugOverlay", () => {
}
});
it("graphs bounded status samples without clamping CPU and resets history on reopen", async () => {
it("graphs status samples without clamping CPU and resets history on reopen", async () => {
vi.useFakeTimers();
let sampleCount = 0;
let uptimeMs = 60_000;
@ -285,26 +289,11 @@ describe("DebugOverlay", () => {
// Healthy event loop: no tile carries the degraded tint.
expect(overlay.querySelector(".gateway-vital[data-degraded]")).toBeNull();
// Accumulate history without refreshing the unrelated expanded sections.
overlay.querySelector<HTMLButtonElement>('[aria-label="Minimize system busyness"]')!.click();
await vitalUpdated();
for (let interval = 0; interval < 90; interval += 1) {
const nextSample = sampleCount + 1;
vi.advanceTimersByTime(10_000);
await waitForSolid(() => {
expect(
overlay.querySelector<TestSparkline>(".gateway-vital--cpu")?.samples.at(-1)?.value,
).toBe(1 + nextSample / 10);
});
}
overlay.querySelector<HTMLButtonElement>('[aria-label="Expand system busyness"]')!.click();
await vitalUpdated();
const points = overlay
.querySelector(".gateway-vital--cpu polyline")
?.getAttribute("points")
?.split(" ");
expect(points).toHaveLength(90);
expect(points).toHaveLength(2);
uptimeMs = 0;
overlay.toggle();
@ -359,6 +348,50 @@ describe("DebugOverlay", () => {
}
});
it("retains the latest 90 status samples in the minimized content", async () => {
vi.useFakeTimers();
let sampleCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "system.info") {
return { eventLoop: { cpuCoreRatio: ++sampleCount } };
}
throw new Error(`Unexpected diagnostics method: ${method}`);
});
const content = document.createElement("openclaw-debug-overlay-content") as TestDebugContent;
content.context = createDebugApplicationContext(request);
content.minimized = true;
document.body.append(content);
try {
await content.updateComplete;
vi.advanceTimersByTime(0);
await waitForSolid(() => {
expect(content.querySelector<TestSparkline>(".gateway-vital--cpu")?.samples).toHaveLength(
1,
);
});
for (let value = 2; value <= 91; value += 1) {
vi.advanceTimersByTime(10_000);
await waitForSolid(() => {
expect(
content.querySelector<TestSparkline>(".gateway-vital--cpu")?.samples.at(-1)?.value,
).toBe(value);
});
}
await waitForSolid(() => {
const cpu = content.querySelector<TestSparkline>(".gateway-vital--cpu");
expect(sampleCount).toBe(91);
expect(cpu?.samples).toHaveLength(90);
expect(cpu?.samples[0]?.value).toBe(2);
expect(cpu?.samples.at(-1)?.value).toBe(91);
expect(cpu?.querySelector("polyline")?.getAttribute("points")?.split(" ")).toHaveLength(90);
});
} finally {
content.remove();
vi.useRealTimers();
}
});
it.each([
"same-client reconnect",
"client replacement",

View file

@ -561,6 +561,7 @@ describe("DebugPage", () => {
it.each(["models.list", "health"] as const)(
"preserves snapshots and independent Manual RPC errors through %s failure and recovery",
async (failedMethod) => {
vi.useFakeTimers();
let failure: typeof failedMethod | null = null;
let marker = "initial";
const request = vi.fn(async (method: string) => {
@ -573,32 +574,37 @@ describe("DebugPage", () => {
return diagnosticResponse(method, marker);
});
const page = await mountDebugPage(request);
expectSnapshots(page, "initial");
callMethod(page, "manual.latest");
await waitForSolid(() => expect(page.textContent).toContain("manual request failed"));
expect(page.querySelector('.settings-section:first-child [role="alert"]')).toBeNull();
try {
expectSnapshots(page, "initial");
callMethod(page, "manual.latest");
await waitForSolid(() => expect(page.textContent).toContain("manual request failed"));
expect(page.querySelector('.settings-section:first-child [role="alert"]')).toBeNull();
marker = "uncommitted";
failure = failedMethod;
page.querySelector<HTMLButtonElement>(".settings-section button")!.click();
await waitForSolid(() => expect(page.textContent).toContain(`${failedMethod} unavailable`));
marker = "uncommitted";
failure = failedMethod;
page.querySelector<HTMLButtonElement>(".settings-section button")!.click();
await waitForSolid(() => expect(page.textContent).toContain(`${failedMethod} unavailable`));
expect(page.textContent).toContain("manual request failed");
expectSnapshots(page, "initial");
const alert = page.querySelector<HTMLElement>('.settings-section [role="alert"]');
expect(alert?.closest(".settings-section")?.querySelector("h2")?.textContent.trim()).toBe(
"Snapshots",
);
expect(alert?.classList).toContain("settings-row");
expect(page.querySelector(".callout")).toBeNull();
expect(page.textContent).toContain("manual request failed");
expectSnapshots(page, "initial");
const alert = page.querySelector<HTMLElement>('.settings-section [role="alert"]');
expect(alert?.closest(".settings-section")?.querySelector("h2")?.textContent.trim()).toBe(
"Snapshots",
);
expect(alert?.classList).toContain("settings-row");
expect(page.querySelector(".callout")).toBeNull();
marker = "recovered";
failure = null;
page.querySelector<HTMLButtonElement>(".settings-section button")!.click();
await waitForSolid(() => expectSnapshots(page, "recovered"));
marker = "recovered";
failure = null;
page.querySelector<HTMLButtonElement>(".settings-section button")!.click();
await waitForSolid(() => expectSnapshots(page, "recovered"));
expect(page.querySelector('.settings-section:first-child [role="alert"]')).toBeNull();
expect(page.textContent).toContain("manual request failed");
expect(page.querySelector('.settings-section:first-child [role="alert"]')).toBeNull();
expect(page.textContent).toContain("manual request failed");
} finally {
unmountDebugHost(page);
vi.useRealTimers();
}
},
);
});

View file

@ -84,7 +84,9 @@ suite.define(() => {
const url = new URL("workboard/ops", suite.server.baseUrl);
url.hostname = "ui.example";
url.hash = new URLSearchParams({ gatewayUrl }).toString();
await page.goto(url.href);
const response = await page.goto(url.href);
expect(response?.status()).toBe(200);
expect(errors).toEqual([]);
await page.getByRole("button", { name: "Switch to gateway.example", exact: true }).click();
await page.locator(".workboard-page-title").filter({ hasText: "Operations" }).waitFor();
expect(await page.evaluate(() => window.isSecureContext)).toBe(false);

View file

@ -2,7 +2,7 @@
.chat-controls__inline-select-menu,
.agent-chat__attach-menu::part(menu),
.agent-chat__capability-menu::part(menu),
.chat-controls__permission-picker::part(menu),
.chat-controls__permission-menu,
.chat-talk-input-picker::part(menu) {
border: 1px solid var(--chat-composer-popover-border, var(--overlay-border));
border-radius: var(--chat-composer-popover-radius, var(--radius-lg));
@ -11,18 +11,39 @@
corner-shape: superellipse(1.5);
}
.chat-controls__inline-select[open] > wa-popup::part(popup) {
.chat-controls__inline-select[open] > wa-popup::part(popup),
.chat-controls__inline-select[open] > .chat-picker-overlay {
animation: chat-composer-picker-in var(--control-ui-transition-fast) ease;
}
.chat-controls__inline-select[open] > wa-popup[data-current-placement^="top"]::part(popup) {
.chat-controls__inline-select[open] > wa-popup[data-current-placement^="top"]::part(popup),
.chat-controls__inline-select[open] > .chat-picker-overlay[data-placement^="top"] {
transform-origin: bottom;
}
.chat-controls__inline-select[open] > wa-popup[data-current-placement^="bottom"]::part(popup) {
.chat-controls__inline-select[open] > wa-popup[data-current-placement^="bottom"]::part(popup),
.chat-controls__inline-select[open] > .chat-picker-overlay[data-placement^="bottom"] {
transform-origin: top;
}
.chat-picker-overlay {
/* Try the full panel before shrinking it to the preferred side's remaining space. */
max-height: min(var(--oc-overlay-max-height, 420px), calc(100dvh - 16px));
position-try-fallbacks:
--chat-picker-fit flip-block,
--chat-picker-fit,
--chat-picker-fit flip-inline,
--chat-picker-fit flip-block flip-inline;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
}
@position-try --chat-picker-fit {
max-height: min(var(--oc-overlay-max-height, 420px), calc(100% - 16px));
}
@keyframes chat-composer-picker-in {
from {
opacity: 0;
@ -102,6 +123,10 @@
}
@media (prefers-reduced-motion: reduce) {
.chat-controls__inline-select[open] > .chat-picker-overlay {
animation: none;
}
.agent-chat__input--dictating::after {
--chat-dictation-edge-angle: 238deg;
animation: none;
@ -1207,6 +1232,11 @@
);
max-height: min(440px, var(--auto-size-available-height, calc(100dvh - 116px)));
}
.agent-chat__input .chat-controls__inline-select > .chat-picker-overlay {
position-area: top span-all;
justify-self: center;
}
}
@media (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
@ -1938,6 +1968,7 @@ html[dir="rtl"] .chat-attachment-loading > span {
}
.chat-controls__inline-select-trigger:hover,
.chat-controls__inline-select-trigger[aria-expanded="true"],
.chat-controls__inline-select[open] > .chat-controls__inline-select-trigger {
background: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text-strong);
@ -2044,7 +2075,7 @@ html[dir="rtl"] .chat-attachment-loading > span {
max-height: min(280px, var(--auto-size-available-height, calc(100vh - 120px)));
}
.chat-controls__permission-picker::part(menu) {
.chat-controls__permission-menu {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 16px;
@ -2077,6 +2108,14 @@ html[dir="rtl"] .chat-attachment-loading > span {
grid-column: 1 / -1;
}
.chat-controls__permission-option > .oc-menu-item__check {
display: none;
}
.chat-controls__permission-option > :is(.oc-menu-item__icon, .oc-menu-item__details) {
margin-inline: 0;
}
.chat-controls__permission-icon,
.chat-controls__permission-lock {
display: inline-flex;
@ -2093,6 +2132,10 @@ html[dir="rtl"] .chat-attachment-loading > span {
color: currentColor;
}
.chat-controls__permission-trigger > .oc-menu-item__label {
display: contents;
}
.agent-chat__input .chat-controls__permission-trigger--default,
.agent-chat__input .chat-controls__permission-trigger,
.agent-chat__input .chat-controls__model-settings .chat-controls__inline-select-trigger {
@ -2130,7 +2173,7 @@ html[dir="rtl"] .chat-attachment-loading > span {
color: var(--muted);
}
.chat-controls__permission-option:state(disabled) .chat-controls__permission-option-icon,
.chat-controls__permission-option:disabled .chat-controls__permission-option-icon,
.chat-controls__permission-option[data-chat-permission-option="full"]
.chat-controls__permission-option-icon {
color: var(--accent);