mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-02 21:49:09 +00:00
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:
parent
de70e8372f
commit
58ea8e5e6a
39 changed files with 1180 additions and 601 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"]')],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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 + '"]',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
93
ui/src/pages/chat/components/chat-permission-picker.test.tsx
Normal file
93
ui/src/pages/chat/components/chat-permission-picker.test.tsx
Normal 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);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue