mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
improve(ui): collapse mobile composer controls (#129853)
* improve(ui): collapse mobile model controls * refactor(ui): align mobile composer controls * test(ui): capture mobile composer states * fix(ui): preserve mobile settings semantics * fix(ui): restore mobile picker focus Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * test(ui): await sidebar narration preload --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -136,6 +136,12 @@ export const icons = {
|
||||
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />`),
|
||||
slidersHorizontal: strokeIcon(svg` <line x1="4" x2="14" y1="7" y2="7" />
|
||||
<line x1="18" x2="20" y1="7" y2="7" />
|
||||
<line x1="4" x2="6" y1="17" y2="17" />
|
||||
<line x1="10" x2="20" y1="17" y2="17" />
|
||||
<line x1="14" x2="14" y1="4" y2="10" />
|
||||
<line x1="8" x2="8" y1="14" y2="20" />`),
|
||||
bug: strokeIcon(svg` <path d="m8 2 1.88 1.88" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
|
||||
|
||||
@@ -36,7 +36,7 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
it("keeps mobile picker panels above an attachment-expanded composer", async () => {
|
||||
await suite.withPage({ viewport: { width: 667, height: 375 } }, async ({ page }) => {
|
||||
await suite.withPage({ viewport: { width: 393, height: 852 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page);
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
@@ -49,6 +49,7 @@ suite.define(() => {
|
||||
buffer: Buffer.from("mobile composer attachment"),
|
||||
});
|
||||
await composer.locator(".chat-attachments-preview").waitFor({ state: "visible" });
|
||||
const mobileModelSettings = composer.locator('[data-chat-model-settings="true"]');
|
||||
|
||||
for (const picker of [
|
||||
{
|
||||
@@ -60,13 +61,22 @@ suite.define(() => {
|
||||
trigger: '[data-chat-thinking-select="true"]',
|
||||
},
|
||||
]) {
|
||||
await composer.locator(picker.trigger).click();
|
||||
if (picker.menu === ".chat-controls__effort-menu") {
|
||||
await mobileModelSettings.click();
|
||||
await composer.locator(".chat-controls__mobile-effort-option").click();
|
||||
} else {
|
||||
await composer.locator(picker.trigger).click();
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
const visibleTrigger =
|
||||
picker.menu === ".chat-controls__effort-menu"
|
||||
? mobileModelSettings
|
||||
: composer.locator(picker.trigger);
|
||||
const [composerBox, footerBox, menuBox, triggerBox] = await Promise.all([
|
||||
composer.boundingBox(),
|
||||
composer.locator(".agent-chat__composer-footer").boundingBox(),
|
||||
page.locator(picker.menu).boundingBox(),
|
||||
composer.locator(picker.trigger).boundingBox(),
|
||||
visibleTrigger.boundingBox(),
|
||||
]);
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
@@ -76,19 +86,42 @@ suite.define(() => {
|
||||
throw new Error(`expected mobile layout boxes for ${picker.menu}`);
|
||||
}
|
||||
expect(menuBox.x).toBeGreaterThanOrEqual(12);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(655);
|
||||
expect(menuBox.width).toBeGreaterThanOrEqual(642);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(381);
|
||||
expect(menuBox.width).toBeGreaterThanOrEqual(368);
|
||||
expect(menuBox.y).toBeGreaterThanOrEqual(0);
|
||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(composerBox.y + 1);
|
||||
expect(triggerBox.y + triggerBox.height).toBeLessThanOrEqual(376);
|
||||
expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(376);
|
||||
await composer.locator(picker.trigger).click();
|
||||
expect(triggerBox.y + triggerBox.height).toBeLessThanOrEqual(853);
|
||||
expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(853);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await mobileModelSettings.press("Enter");
|
||||
const mobileEffortOption = composer.locator(".chat-controls__mobile-effort-option");
|
||||
await expect.poll(() => mobileEffortOption.isVisible()).toBe(true);
|
||||
await mobileEffortOption.press("Enter");
|
||||
const focusedEffortControl = composer.locator(
|
||||
"[data-chat-thinking-slider]:not([disabled]), [data-chat-speed-toggle]:not([disabled])",
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
focusedEffortControl.first().evaluate((node) => node === document.activeElement),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect
|
||||
.poll(() => mobileModelSettings.evaluate((node) => node === document.activeElement))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the model in the bottom bar, session settings in the header, and holds send beside the microphone in every input state", async () => {
|
||||
await suite.withPage({ viewport: { width: 1920, height: 1080 } }, async ({ page }) => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const pageOptions = {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
...(artifactDir
|
||||
? { recordVideo: { dir: artifactDir, size: { width: 393, height: 852 } } }
|
||||
: {}),
|
||||
};
|
||||
await suite.withPage(pageOptions, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
assistantName: "Rosita",
|
||||
deferredMethods: ["chat.send"],
|
||||
@@ -190,6 +223,16 @@ suite.define(() => {
|
||||
const voice = page.getByRole("button", { name: "Start voice input" });
|
||||
const microphonePicker = page.getByRole("button", { name: "Microphone input" });
|
||||
const microphonePickerShell = page.locator(".chat-talk-input-picker");
|
||||
const captureMobileState = async (fileName: string) => {
|
||||
if (!artifactDir) {
|
||||
return;
|
||||
}
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: `${artifactDir}/${fileName}`,
|
||||
});
|
||||
};
|
||||
|
||||
await expect.poll(() => model.isVisible()).toBe(true);
|
||||
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
||||
@@ -527,6 +570,8 @@ suite.define(() => {
|
||||
});
|
||||
await expect.poll(() => followUp.isVisible()).toBe(true);
|
||||
await expect.poll(() => page.locator(".chat-send-btn--stop").count()).toBe(0);
|
||||
await page.setViewportSize({ width: 393, height: 852 });
|
||||
await captureMobileState("mobile-composer-active-follow-up.png");
|
||||
|
||||
await textarea.fill("");
|
||||
const stop = page.getByRole("button", { name: "Stop generating" });
|
||||
@@ -536,6 +581,17 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => stop.evaluate((node) => getComputedStyle(node).backgroundColor))
|
||||
.not.toBe(brandFill);
|
||||
const mobileModelSettings = composer.locator('[data-chat-model-settings="true"]');
|
||||
await expect.poll(() => mobileModelSettings.isVisible()).toBe(true);
|
||||
const [activeMobileSettingsBox, activeMobileStopBox] = await Promise.all([
|
||||
mobileModelSettings.boundingBox(),
|
||||
stop.boundingBox(),
|
||||
]);
|
||||
expect(activeMobileSettingsBox?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(activeMobileSettingsBox?.height).toBeGreaterThanOrEqual(44);
|
||||
expect(activeMobileStopBox?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(activeMobileStopBox?.height).toBeGreaterThanOrEqual(44);
|
||||
await captureMobileState("mobile-composer-active-stop.png");
|
||||
await textarea.press("Escape");
|
||||
const abortRequest = await gateway.waitForRequest("chat.abort");
|
||||
expect(abortRequest.params).toMatchObject({
|
||||
@@ -550,6 +606,7 @@ suite.define(() => {
|
||||
.toBe(true);
|
||||
await expect.poll(() => emptySend.isVisible()).toBe(true);
|
||||
await expect.poll(() => emptySend.isDisabled()).toBe(true);
|
||||
await captureMobileState("mobile-composer-idle.png");
|
||||
// Send holds its place with nothing to send: it goes unavailable rather
|
||||
// than disappearing, so the composer never looks like it lost the control
|
||||
// that commits a turn.
|
||||
@@ -563,7 +620,6 @@ suite.define(() => {
|
||||
})
|
||||
.toBeGreaterThanOrEqual(-1);
|
||||
|
||||
await page.setViewportSize({ width: 393, height: 852 });
|
||||
await expect.poll(() => camera.count()).toBe(0);
|
||||
expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(false);
|
||||
await expect
|
||||
@@ -577,47 +633,47 @@ suite.define(() => {
|
||||
return settled ? settled.x + settled.width : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(393);
|
||||
const [mobileAttachBox, mobileModelBox, mobileSettingsBox, mobileContextBox, mobileVoiceBox] =
|
||||
await Promise.all([
|
||||
attach.boundingBox(),
|
||||
model.boundingBox(),
|
||||
settings.boundingBox(),
|
||||
contextUsage.boundingBox(),
|
||||
voice.boundingBox(),
|
||||
]);
|
||||
await expect.poll(() => mobileModelSettings.isVisible()).toBe(true);
|
||||
await expect.poll(() => effort.isVisible()).toBe(false);
|
||||
const [
|
||||
mobileAttachBox,
|
||||
mobileModelSettingsBox,
|
||||
mobileSettingsBox,
|
||||
mobileContextBox,
|
||||
mobileVoiceBox,
|
||||
] = await Promise.all([
|
||||
attach.boundingBox(),
|
||||
mobileModelSettings.boundingBox(),
|
||||
settings.boundingBox(),
|
||||
contextUsage.boundingBox(),
|
||||
voice.boundingBox(),
|
||||
]);
|
||||
expect(mobileAttachBox).not.toBeNull();
|
||||
expect(mobileModelBox).not.toBeNull();
|
||||
expect(mobileModelSettingsBox).not.toBeNull();
|
||||
expect(mobileSettingsBox).not.toBeNull();
|
||||
expect(mobileContextBox).not.toBeNull();
|
||||
expect(mobileVoiceBox).not.toBeNull();
|
||||
if (
|
||||
!mobileAttachBox ||
|
||||
!mobileModelBox ||
|
||||
!mobileModelSettingsBox ||
|
||||
!mobileSettingsBox ||
|
||||
!mobileContextBox ||
|
||||
!mobileVoiceBox
|
||||
) {
|
||||
throw new Error("expected mobile composer controls to have layout boxes");
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
model.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return [style.paddingInlineStart, style.paddingInlineEnd];
|
||||
}),
|
||||
)
|
||||
.toEqual(["0px", "0px"]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
effort.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return [style.paddingInlineStart, style.paddingInlineEnd];
|
||||
}),
|
||||
)
|
||||
.toEqual(["4px", "4px"]);
|
||||
for (const control of [mobileModelBox, mobileContextBox]) {
|
||||
expect(mobileModelSettingsBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(mobileModelSettingsBox.height).toBeGreaterThanOrEqual(44);
|
||||
expect(mobileModelSettingsBox.x).toBeGreaterThanOrEqual(
|
||||
mobileContextBox.x + mobileContextBox.width - 1,
|
||||
);
|
||||
for (const control of [mobileModelSettingsBox, mobileContextBox]) {
|
||||
expect(
|
||||
Math.abs(control.y + control.height / 2 - (mobileModelBox.y + mobileModelBox.height / 2)),
|
||||
Math.abs(
|
||||
control.y +
|
||||
control.height / 2 -
|
||||
(mobileModelSettingsBox.y + mobileModelSettingsBox.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect(mobileSettingsBox.x).toBeGreaterThanOrEqual(0);
|
||||
@@ -653,12 +709,14 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Send message" }).isVisible())
|
||||
.toBe(true);
|
||||
await captureMobileState("mobile-composer-send-ready.png");
|
||||
await textarea.fill("");
|
||||
await expect.poll(() => camera.count()).toBe(0);
|
||||
await model.click();
|
||||
await mobileModelSettings.click();
|
||||
await expect
|
||||
.poll(() => composer.locator(".chat-controls__model-menu").isVisible())
|
||||
.toBe(true);
|
||||
await captureMobileState("mobile-composer-model-open.png");
|
||||
const mobilePickerBox = await composer.locator(".chat-controls__model-menu").boundingBox();
|
||||
expect(mobilePickerBox).not.toBeNull();
|
||||
if (!mobilePickerBox) {
|
||||
@@ -666,7 +724,12 @@ suite.define(() => {
|
||||
}
|
||||
expect(mobilePickerBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePickerBox.x + mobilePickerBox.width).toBeLessThanOrEqual(393);
|
||||
await model.click();
|
||||
await composer.locator(".chat-controls__mobile-effort-option").click();
|
||||
await expect
|
||||
.poll(() => composer.locator(".chat-controls__effort-menu").isVisible())
|
||||
.toBe(true);
|
||||
await captureMobileState("mobile-composer-effort-open.png");
|
||||
await page.keyboard.press("Escape");
|
||||
await settings.click();
|
||||
await expect.poll(() => viewMenu.isVisible()).toBe(true);
|
||||
await settings.click();
|
||||
@@ -684,7 +747,6 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => microphonePicker.evaluate((node) => getComputedStyle(node).borderLeftWidth))
|
||||
.toBe("0px");
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
if (artifactDir) {
|
||||
await composerShell.screenshot({
|
||||
animations: "disabled",
|
||||
|
||||
@@ -278,6 +278,38 @@ suite.define(() => {
|
||||
expect(await page.locator(".new-session-page__message").getAttribute("rows")).toBe("1");
|
||||
await captureProjectUiProof(page, "new-session-control-layout.png");
|
||||
|
||||
await page.setViewportSize({ width: 393, height: 852 });
|
||||
const mobileModelSettings = page.locator(
|
||||
'.new-session-page__composer [data-chat-model-settings="true"]',
|
||||
);
|
||||
await expect.poll(() => mobileModelSettings.isVisible()).toBe(true);
|
||||
const [mobileFooterBox, mobileModelSettingsBox] = await Promise.all([
|
||||
page.locator(".new-session-page__composer .agent-chat__composer-footer").boundingBox(),
|
||||
mobileModelSettings.boundingBox(),
|
||||
]);
|
||||
expect(mobileFooterBox).not.toBeNull();
|
||||
expect(mobileModelSettingsBox).not.toBeNull();
|
||||
if (!mobileFooterBox || !mobileModelSettingsBox) {
|
||||
throw new Error("expected mobile new-session composer controls");
|
||||
}
|
||||
expect(mobileModelSettingsBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(mobileModelSettingsBox.height).toBeGreaterThanOrEqual(44);
|
||||
expect(mobileModelSettingsBox.x).toBeGreaterThan(
|
||||
mobileFooterBox.x + mobileFooterBox.width / 2,
|
||||
);
|
||||
expect(mobileModelSettingsBox.x + mobileModelSettingsBox.width).toBeLessThanOrEqual(
|
||||
mobileFooterBox.x + mobileFooterBox.width,
|
||||
);
|
||||
await captureProjectUiProof(page, "mobile-new-session-idle.png");
|
||||
await mobileModelSettings.click();
|
||||
await expect.poll(() => page.locator(".chat-controls__model-menu").isVisible()).toBe(true);
|
||||
await captureProjectUiProof(page, "mobile-new-session-model-open.png");
|
||||
await page.locator(".chat-controls__mobile-effort-option").click();
|
||||
await expect.poll(() => page.locator(".chat-controls__effort-menu").isVisible()).toBe(true);
|
||||
await captureProjectUiProof(page, "mobile-new-session-effort-open.png");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select");
|
||||
await agentPicker.locator(".agent-select__trigger").click();
|
||||
await pollLocatorText(agentPicker.locator(".agent-select__menu-title")).toBe("Agents");
|
||||
|
||||
@@ -226,6 +226,7 @@ suite.define(() => {
|
||||
|
||||
it("pastes an image into the draft and forwards it with the initial turn", async () => {
|
||||
await withNewSessionPage(async (page) => {
|
||||
await page.setViewportSize({ width: 393, height: 852 });
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:image-draft", runStarted: true },
|
||||
@@ -237,6 +238,7 @@ suite.define(() => {
|
||||
await pastePng(message);
|
||||
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await captureUiProof(page, "mobile-composer-new-session-attachment.png");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
|
||||
@@ -428,10 +428,12 @@ function composerControlsHtml(crowded = false) {
|
||||
<div class="chat-composer-model-control">
|
||||
<div class="chat-controls__session chat-controls__model chat-controls__model-settings">
|
||||
<details class="chat-controls__inline-select chat-controls__model-picker">
|
||||
<summary class="chat-controls__inline-select-trigger chat-controls__model-trigger" data-chat-composer-model="true" aria-label="Chat model">
|
||||
<summary class="chat-controls__inline-select-trigger chat-controls__model-trigger" data-chat-composer-model="true" data-chat-model-settings="true" aria-label="Chat model: GPT-5.6 Luna; Chat thinking level: Medium">
|
||||
<span class="chat-controls__model-settings-icon">${iconSvg()}</span>
|
||||
<span class="chat-controls__inline-select-label">GPT-5.6 Luna</span>
|
||||
</summary>
|
||||
<div class="chat-controls__inline-select-menu chat-controls__model-menu">
|
||||
<button class="chat-controls__inline-select-option chat-controls__mobile-effort-option">Effort <span>Medium</span></button>
|
||||
<div class="chat-controls__model-search-wrap"><input class="chat-controls__model-search" placeholder="Search models" /></div>
|
||||
<div class="chat-controls__model-options">
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option chat-controls__inline-select-option--selected">Default model</button>
|
||||
@@ -3056,7 +3058,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps transient footer controls from crushing the mobile model pickers", async () => {
|
||||
it("keeps transient footer controls from crushing mobile model settings", async () => {
|
||||
const page = await openFixture(320, 568, { crowdedComposerFooter: true });
|
||||
try {
|
||||
await expectNoHorizontalOverflow(page);
|
||||
@@ -3075,19 +3077,17 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
return {
|
||||
controls: rectFor(".agent-chat__composer-controls"),
|
||||
effort: rectFor(".chat-controls__effort-trigger"),
|
||||
footer: rectFor(".agent-chat__composer-footer"),
|
||||
meta: rectFor(".agent-chat__composer-meta"),
|
||||
model: rectFor(".chat-controls__model-trigger"),
|
||||
modelLabel: rectFor(".chat-controls__model-trigger .chat-controls__inline-select-label"),
|
||||
overrides: rectFor(".agent-chat__session-overrides-pill"),
|
||||
settings: rectFor(".chat-controls__model-trigger"),
|
||||
status: rectFor(".agent-chat__composer-run-status"),
|
||||
typing: rectFor(".agent-chat__typing-indicator--outside"),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.controls.scrollWidth).toBeLessThanOrEqual(layout.controls.clientWidth + 1);
|
||||
for (const control of [layout.status, layout.overrides, layout.model, layout.effort]) {
|
||||
for (const control of [layout.status, layout.overrides, layout.settings]) {
|
||||
expect(control.x).toBeGreaterThanOrEqual(layout.footer.x - 1);
|
||||
expect(control.x + control.width).toBeLessThanOrEqual(
|
||||
layout.footer.x + layout.footer.width + 1,
|
||||
@@ -3095,16 +3095,12 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
}
|
||||
expect(layout.typing.x).toBeGreaterThanOrEqual(0);
|
||||
expect(layout.typing.x + layout.typing.width).toBeLessThanOrEqual(320);
|
||||
for (const trigger of [layout.model, layout.effort]) {
|
||||
expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
}
|
||||
expect(layout.modelLabel.scrollWidth).toBeLessThanOrEqual(layout.modelLabel.clientWidth + 1);
|
||||
expect(layout.settings.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(layout.settings.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
for (const [left, right] of [
|
||||
[layout.status, layout.overrides],
|
||||
[layout.overrides, layout.model],
|
||||
[layout.model, layout.effort],
|
||||
[layout.effort, layout.meta],
|
||||
[layout.overrides, layout.settings],
|
||||
[layout.settings, layout.meta],
|
||||
] as const) {
|
||||
expect(rectsOverlap(left, right)).toBe(false);
|
||||
}
|
||||
@@ -3165,6 +3161,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
textarea: rectFor(".agent-chat__composer-combobox > textarea"),
|
||||
meta: rectFor(".agent-chat__composer-meta"),
|
||||
model: rectFor(".chat-composer-model-control"),
|
||||
modelSettings: rectFor(".chat-controls__model-trigger"),
|
||||
modelTrigger: rectFor(".chat-controls__model-trigger"),
|
||||
modelTriggerPadding: paddingFor(".chat-controls__model-trigger"),
|
||||
modelLabel: rectFor(
|
||||
@@ -3189,30 +3186,11 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
const textarea = expectControlRect(controls.textarea, "composer textarea");
|
||||
const meta = expectControlRect(controls.meta, "composer metadata");
|
||||
const model = expectControlRect(controls.model, "composer model control");
|
||||
const modelTrigger = expectControlRect(controls.modelTrigger, "composer model trigger");
|
||||
const modelLabel = expectControlRect(controls.modelLabel, "composer model label");
|
||||
const effortTrigger = expectControlRect(
|
||||
controls.effortTrigger,
|
||||
"composer thinking trigger",
|
||||
);
|
||||
expect(controls.modelTriggerPadding).not.toBeNull();
|
||||
expect(controls.effortTriggerPadding).not.toBeNull();
|
||||
const effortLabel = expectControlRect(controls.effortLabel, "composer thinking label");
|
||||
const context = expectControlRect(controls.context, "composer context control");
|
||||
const attach = expectControlRect(controls.attach, "composer attach control");
|
||||
const send = expectControlRect(controls.send, "composer send control");
|
||||
|
||||
for (const control of [
|
||||
footer,
|
||||
textarea,
|
||||
meta,
|
||||
model,
|
||||
modelTrigger,
|
||||
effortTrigger,
|
||||
context,
|
||||
attach,
|
||||
send,
|
||||
]) {
|
||||
for (const control of [footer, textarea, meta, model, context, attach, send]) {
|
||||
expect(control.x).toBeGreaterThanOrEqual(input.x - 1);
|
||||
expect(control.x + control.width).toBeLessThanOrEqual(input.x + input.width + 1);
|
||||
}
|
||||
@@ -3242,25 +3220,34 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
const composerFontSize = await page
|
||||
.locator(".agent-chat__composer-combobox > textarea")
|
||||
.evaluate((textareaNode) => Number.parseFloat(getComputedStyle(textareaNode).fontSize));
|
||||
if (width <= 768) {
|
||||
const modelPadding = width <= 480 ? 0 : 4;
|
||||
expect(controls.modelTriggerPadding).toEqual({
|
||||
end: modelPadding,
|
||||
start: modelPadding,
|
||||
});
|
||||
expect(controls.effortTriggerPadding).toEqual({ end: 4, start: 4 });
|
||||
expect(composerFontSize).toBe(16);
|
||||
expect(composerFontSize).toBe(16);
|
||||
if (width <= 480) {
|
||||
const modelSettings = expectControlRect(
|
||||
controls.modelSettings,
|
||||
"composer model settings",
|
||||
);
|
||||
expect(model.width).toBeGreaterThanOrEqual(40);
|
||||
expect(model.width).toBeLessThanOrEqual(footer.width);
|
||||
for (const trigger of [modelTrigger, effortTrigger]) {
|
||||
expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
}
|
||||
expect(modelSettings.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(modelSettings.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(modelSettings.x).toBeGreaterThanOrEqual(context.x + context.width - 1);
|
||||
} else {
|
||||
const modelTrigger = expectControlRect(controls.modelTrigger, "composer model trigger");
|
||||
const modelLabel = expectControlRect(controls.modelLabel, "composer model label");
|
||||
const effortTrigger = expectControlRect(
|
||||
controls.effortTrigger,
|
||||
"composer thinking trigger",
|
||||
);
|
||||
const effortLabel = expectControlRect(controls.effortLabel, "composer thinking label");
|
||||
expect(controls.modelTriggerPadding).not.toBeNull();
|
||||
expect(controls.effortTriggerPadding).not.toBeNull();
|
||||
for (const label of [modelLabel, effortLabel]) {
|
||||
expect(label.clientWidth).toBeDefined();
|
||||
expect(label.scrollWidth).toBeDefined();
|
||||
expect(label.scrollWidth ?? 0).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1);
|
||||
}
|
||||
expect(modelTrigger.x).toBeGreaterThanOrEqual(model.x - 1);
|
||||
expect(effortTrigger.x).toBeGreaterThanOrEqual(modelTrigger.x + modelTrigger.width - 1);
|
||||
}
|
||||
if (width <= 768) {
|
||||
expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
for (const control of [model, context]) {
|
||||
@@ -3273,7 +3260,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
// The editor reads at input size, while the controls around it stay
|
||||
// chrome-sized — that difference is what marks the text as the
|
||||
// subject of the surface.
|
||||
expect(composerFontSize).toBe(16);
|
||||
expect(send.width).toBeCloseTo(32, 2);
|
||||
expect(send.height).toBeCloseTo(32, 2);
|
||||
}
|
||||
|
||||
@@ -6887,6 +6887,47 @@ describe("chat model controls", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("labels the mobile secondary setting as Fast mode when reasoning is unavailable", () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const sessionsResult = expectDefined(state.sessionsResult, "fast-only session");
|
||||
const session = expectDefined(sessionsResult.sessions[0], "fast-only session row");
|
||||
state.sessionsResult = {
|
||||
...sessionsResult,
|
||||
defaults: {
|
||||
...sessionsResult.defaults,
|
||||
thinkingLevels: [],
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
...session,
|
||||
thinkingLevels: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const container = renderModelControls(state);
|
||||
const mobileSecondary = container.querySelector(".chat-controls__mobile-effort-option");
|
||||
const modelTrigger = container.querySelector('[data-chat-model-select="true"]');
|
||||
|
||||
expect(mobileSecondary?.textContent).toContain("Fast mode");
|
||||
expect(mobileSecondary?.textContent).toContain("Standard");
|
||||
expect(modelTrigger?.getAttribute("aria-label")).toContain("Fast mode: Standard");
|
||||
expect(modelTrigger?.getAttribute("aria-label")).not.toContain("Thinking level");
|
||||
expect(getThinkingSlider(container)).toBeNull();
|
||||
expect(container.querySelector("[data-chat-speed-toggle]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("applies model, reasoning, and speed for the session that opened the picker", async () => {
|
||||
const { state } = createReasoningHeaderState({
|
||||
models: createOpenAiModelCatalog(),
|
||||
|
||||
@@ -122,8 +122,23 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
|
||||
<details
|
||||
class="chat-controls__inline-select chat-controls__effort-picker"
|
||||
@toggle=${(event: Event) => {
|
||||
const details = event.currentTarget as HTMLDetailsElement;
|
||||
handleChatComposerDetailsToggle(event);
|
||||
syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement);
|
||||
syncChatPickerOverlay(details);
|
||||
if (!details.open) {
|
||||
return;
|
||||
}
|
||||
if (!details.hasAttribute("data-chat-focus-panel")) {
|
||||
return;
|
||||
}
|
||||
details.removeAttribute("data-chat-focus-panel");
|
||||
queueMicrotask(() => {
|
||||
details
|
||||
.querySelector<HTMLElement>(
|
||||
"[data-chat-thinking-slider]:not(:disabled), [data-chat-thinking-option]:not(:disabled), [data-chat-speed-toggle]:not(:disabled)",
|
||||
)
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
|
||||
@@ -385,6 +385,16 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
commonDisabled ||
|
||||
effortMutationDisabled ||
|
||||
(thinking.options.length === 0 && (!showFastMode || fastMode.disabled));
|
||||
const effortLabel = thinking.selection.displayLabel.replace(/^Inherited:\s*/u, "");
|
||||
const showReasoning = thinking.options.length > 0;
|
||||
const mobileSecondary =
|
||||
showReasoning || (showFastMode && fastMode.supported)
|
||||
? {
|
||||
disabled: effortDisabled,
|
||||
label: showReasoning ? t("chat.modelControls.effort") : t("chat.modelControls.fastMode"),
|
||||
value: showReasoning ? effortLabel : fastMode.label,
|
||||
}
|
||||
: undefined;
|
||||
return html`
|
||||
<div class="chat-controls__session chat-controls__model chat-controls__model-settings">
|
||||
${renderChatModelPicker({
|
||||
@@ -402,6 +412,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
: undefined,
|
||||
disabled: modelDisabled,
|
||||
disabledReason: props.modelMutationDisabledReason,
|
||||
mobileSecondary,
|
||||
modelCatalogState: managedCatalog,
|
||||
modelSelectionLocked: props.modelSelectionLocked === true,
|
||||
modelOptions,
|
||||
|
||||
@@ -30,6 +30,7 @@ type ChatModelPickerParams = {
|
||||
contextWindow?: ChatContextWindowControlParams;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
mobileSecondary?: { disabled: boolean; label: string; value: string };
|
||||
modelCatalogState?: ChatModelCatalogState;
|
||||
modelSelectionLocked: boolean;
|
||||
modelOptions: ChatModelPickerOption[];
|
||||
@@ -411,6 +412,28 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
highlightModelRow(menu, row);
|
||||
}
|
||||
};
|
||||
const openEffortPicker = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (params.mobileSecondary?.disabled !== false) {
|
||||
return;
|
||||
}
|
||||
// SAFETY: Lit binds this handler directly to the effort button rendered below.
|
||||
const modelPicker = (event.currentTarget as HTMLElement).closest<HTMLDetailsElement>(
|
||||
".chat-controls__model-picker",
|
||||
);
|
||||
const effortPicker = modelPicker?.parentElement?.querySelector<HTMLDetailsElement>(
|
||||
".chat-controls__effort-picker",
|
||||
);
|
||||
if (!modelPicker || !effortPicker) {
|
||||
return;
|
||||
}
|
||||
effortPicker.setAttribute("data-chat-focus-panel", "");
|
||||
modelPicker.open = false;
|
||||
effortPicker.open = true;
|
||||
};
|
||||
const settingsLabel = params.mobileSecondary
|
||||
? `${t("chat.selectors.model")}: ${triggerTitle}; ${params.mobileSecondary.label}: ${params.mobileSecondary.value}`
|
||||
: `${t("chat.selectors.model")}: ${triggerTitle}`;
|
||||
return html`
|
||||
<details
|
||||
class="chat-controls__inline-select chat-controls__model-picker"
|
||||
@@ -437,10 +460,11 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
? "chat-controls__inline-select-trigger--disabled"
|
||||
: ""}"
|
||||
data-chat-model-select="true"
|
||||
data-chat-model-settings="true"
|
||||
data-chat-model-locked=${params.modelSelectionLocked ? "true" : "false"}
|
||||
data-chat-select-value=${params.selectedModelValue}
|
||||
data-chat-model-tools=${modelToolsUnavailable ? "unavailable" : "available"}
|
||||
aria-label=${`${t("chat.selectors.model")}: ${triggerTitle}`}
|
||||
aria-label=${settingsLabel}
|
||||
aria-disabled=${params.disabled ? "true" : "false"}
|
||||
title=${params.disabledReason ?? triggerTitle}
|
||||
@click=${(event: MouseEvent) => {
|
||||
@@ -451,6 +475,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
(event.currentTarget as HTMLElement).focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<span class="chat-controls__model-settings-icon" aria-hidden="true"
|
||||
>${icons.slidersHorizontal}</span
|
||||
>
|
||||
${modelToolsUnavailable
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.modelControls.chatOnlyHelp")}>
|
||||
@@ -484,6 +511,19 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
class="chat-controls__inline-select-menu chat-controls__model-menu"
|
||||
aria-label=${t("chat.selectors.model")}
|
||||
>
|
||||
${params.mobileSecondary
|
||||
? html`
|
||||
<button
|
||||
class="chat-controls__inline-select-option chat-controls__mobile-effort-option"
|
||||
type="button"
|
||||
?disabled=${params.mobileSecondary.disabled}
|
||||
@click=${openEffortPicker}
|
||||
>
|
||||
<span>${params.mobileSecondary.label}</span>
|
||||
<span>${params.mobileSecondary.value}</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${params.modelSelectionLocked
|
||||
? html`
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
handleChatComposerDetailsToggle,
|
||||
handleChatComposerDropdownShow,
|
||||
markPointerOpenedChatComposerDropdown,
|
||||
restorePointerOpenedChatComposerTrigger,
|
||||
@@ -33,4 +34,36 @@ describe("chat picker overlay", () => {
|
||||
|
||||
expect(trigger.hasAttribute("data-chat-pointer-restored-focus")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns Escape focus to the visible mobile settings trigger", async () => {
|
||||
const composer = document.createElement("div");
|
||||
composer.className = "agent-chat__input";
|
||||
const settings = document.createElement("div");
|
||||
settings.className = "chat-controls__model-settings";
|
||||
const modelPicker = document.createElement("details");
|
||||
modelPicker.className = "chat-controls__model-picker";
|
||||
const modelTrigger = document.createElement("summary");
|
||||
modelPicker.append(modelTrigger);
|
||||
const effortPicker = document.createElement("details");
|
||||
effortPicker.className = "chat-controls__effort-picker";
|
||||
const hiddenEffortTrigger = document.createElement("summary");
|
||||
hiddenEffortTrigger.style.display = "none";
|
||||
const effortControl = document.createElement("input");
|
||||
effortPicker.append(hiddenEffortTrigger, effortControl);
|
||||
settings.append(modelPicker, effortPicker);
|
||||
composer.append(settings);
|
||||
document.body.append(composer);
|
||||
|
||||
effortPicker.open = true;
|
||||
effortPicker.addEventListener("toggle", handleChatComposerDetailsToggle);
|
||||
effortPicker.dispatchEvent(new Event("toggle"));
|
||||
effortControl.focus();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
|
||||
expect(effortPicker.open).toBe(false);
|
||||
expect(document.activeElement).toBe(modelTrigger);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,25 @@ function pickerTrigger(picker: HTMLElement): HTMLElement | null {
|
||||
: picker.querySelector<HTMLElement>("[slot=trigger]");
|
||||
}
|
||||
|
||||
function restoreEscapeFocus(picker: HTMLElement): void {
|
||||
const trigger = pickerTrigger(picker);
|
||||
if (!trigger || getComputedStyle(trigger).display !== "none") {
|
||||
trigger?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const focusScope = picker.closest("openclaw-chat-pane") ?? picker.closest("openclaw-app");
|
||||
// Closing can replace the composer subtree; resolve the visible trigger only
|
||||
// after the retained pane has rendered the replacement controls.
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
const target = (focusScope ?? document).querySelector<HTMLElement>(
|
||||
".chat-controls__model-settings .chat-controls__model-picker > summary",
|
||||
);
|
||||
target?.focus({ preventScroll: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dismissChatComposerPickersOutside(event: PointerEvent): void {
|
||||
const path = event.composedPath();
|
||||
for (const picker of openChatComposerPickers()) {
|
||||
@@ -75,14 +94,14 @@ function dismissChatComposerPickersOnEscape(event: KeyboardEvent): void {
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const trigger = pickers.at(-1);
|
||||
const lastPicker = pickers.at(-1);
|
||||
pickers.forEach(closeComposerPicker);
|
||||
invocationComposer?.dispatchEvent(new CustomEvent(CHAT_COMPOSER_DISMISS_INVOCATIONS_EVENT));
|
||||
invocationComposer
|
||||
?.querySelector<HTMLTextAreaElement>(".agent-chat__composer-combobox > textarea")
|
||||
?.focus({ preventScroll: true });
|
||||
if (trigger) {
|
||||
pickerTrigger(trigger)?.focus({ preventScroll: true });
|
||||
if (lastPicker) {
|
||||
restoreEscapeFocus(lastPicker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +111,9 @@ export function ensureChatComposerPickerDismissal(): void {
|
||||
}
|
||||
composerPickerDismissalInstalled = true;
|
||||
document.addEventListener("pointerdown", dismissChatComposerPickersOutside, true);
|
||||
document.addEventListener("keydown", dismissChatComposerPickersOnEscape, true);
|
||||
// Window capture observes the open picker before component Escape handlers
|
||||
// mutate details.open and erase the return-focus owner.
|
||||
window.addEventListener("keydown", dismissChatComposerPickersOnEscape, true);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
|
||||
@@ -5970,6 +5970,11 @@ button.chat-pr__diff {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-controls__model-settings-icon,
|
||||
.chat-controls__mobile-effort-option {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-controls__model-settings .chat-controls__inline-select-trigger {
|
||||
width: auto;
|
||||
}
|
||||
@@ -7192,6 +7197,7 @@ button.chat-pr__diff {
|
||||
|
||||
.agent-chat__input .chat-controls__model-trigger {
|
||||
gap: 3px;
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@@ -7298,6 +7304,39 @@ button.chat-pr__diff {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-composer-model-control,
|
||||
.agent-chat__input .chat-controls__model-settings,
|
||||
.agent-chat__input .chat-controls__model-picker,
|
||||
.agent-chat__input .chat-controls__model-trigger {
|
||||
flex: 0 0 var(--chat-composer-control-size);
|
||||
width: var(--chat-composer-control-size);
|
||||
min-width: var(--chat-composer-control-size);
|
||||
max-width: var(--chat-composer-control-size);
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__model-trigger > :not(.chat-controls__model-settings-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__model-settings-icon,
|
||||
.agent-chat__input .chat-controls__mobile-effort-option {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__model-settings-icon svg {
|
||||
width: var(--chat-composer-chip-icon);
|
||||
height: var(--chat-composer-chip-icon);
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__effort-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__effort-picker > .chat-controls__effort-trigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1100,10 +1100,8 @@ wa-dropdown.new-session-page__start-menu {
|
||||
gap: 4px 2px;
|
||||
}
|
||||
|
||||
/* A new session has visibility chips instead of the chat settings cluster.
|
||||
Keep those chips intrinsic and let the model picker use the remaining width;
|
||||
spacing stays on the shared family token so the row keeps one rhythm as it
|
||||
wraps. */
|
||||
/* Visibility chips stay intrinsic and let the model control use the
|
||||
remaining width until the shared narrow-composer treatment takes over. */
|
||||
.new-session-page__composer .agent-chat__composer-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1121,3 +1119,12 @@ wa-dropdown.new-session-page__start-menu {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@container chat-composer (max-width: 480px) {
|
||||
.new-session-page__composer .agent-chat__input .chat-composer-model-control {
|
||||
flex: 0 0 var(--chat-composer-control-size);
|
||||
width: var(--chat-composer-control-size);
|
||||
min-width: var(--chat-composer-control-size);
|
||||
max-width: var(--chat-composer-control-size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,7 @@ export async function mountSidebar(
|
||||
sidebarMenus: { preloadMenuRenderer: () => Promise<unknown> };
|
||||
};
|
||||
await Promise.all([
|
||||
import("../components/app-sidebar-session-narration.ts"),
|
||||
sidebarWithPreloads.preloadCatalogRenderer(),
|
||||
sidebarWithPreloads.sidebarMenus.preloadMenuRenderer(),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user