mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): keep composer pickers usable on mobile (#121881)
* fix(ui): keep composer pickers usable on mobile * fix(ui): right-align mobile composer controls * fix(ui): isolate mobile composer sizing --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
a9edd8b58c
commit
f0d6cc4ade
@@ -13,6 +13,55 @@ const suite = createControlUiE2eSuite({
|
||||
|
||||
// Browser contexts preserve test isolation; keep one process warm for this file.
|
||||
suite.define(() => {
|
||||
it("keeps mobile picker panels above an attachment-expanded composer", async () => {
|
||||
await suite.withPage({ viewport: { width: 667, height: 375 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page);
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
|
||||
const composer = page.locator(".agent-chat__input");
|
||||
await composer.waitFor({ state: "visible" });
|
||||
await composer.locator(".agent-chat__file-input").setInputFiles({
|
||||
name: "mobile-composer-proof.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("mobile composer attachment"),
|
||||
});
|
||||
await composer.locator(".chat-attachments-preview").waitFor({ state: "visible" });
|
||||
|
||||
for (const picker of [
|
||||
{
|
||||
menu: ".chat-controls__model-menu",
|
||||
trigger: '[data-chat-model-select="true"]',
|
||||
},
|
||||
{
|
||||
menu: ".chat-controls__effort-menu",
|
||||
trigger: '[data-chat-thinking-select="true"]',
|
||||
},
|
||||
]) {
|
||||
await composer.locator(picker.trigger).click();
|
||||
await page.waitForTimeout(100);
|
||||
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(),
|
||||
]);
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
expect(menuBox).not.toBeNull();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
if (!composerBox || !footerBox || !menuBox || !triggerBox) {
|
||||
throw new Error(`expected mobile layout boxes for ${picker.menu}`);
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the model in the bottom bar, session settings in the header, and switches the primary action with input state", async () => {
|
||||
await suite.withPage({ viewport: { width: 1920, height: 1080 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
|
||||
@@ -120,6 +120,8 @@ type ControlRect = {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
clientWidth?: number;
|
||||
scrollWidth?: number;
|
||||
clientHeight?: number;
|
||||
scrollHeight?: number;
|
||||
scrollTop?: number;
|
||||
@@ -129,6 +131,7 @@ type ControlRect = {
|
||||
|
||||
type ChatFixtureOptions = {
|
||||
composerAttachment?: boolean;
|
||||
crowdedComposerFooter?: boolean;
|
||||
direct?: boolean;
|
||||
sessionRailBody?: string;
|
||||
sessionRailDocked?: boolean;
|
||||
@@ -269,14 +272,27 @@ function chatControlsHtml(opts: { agent?: boolean } = {}) {
|
||||
`;
|
||||
}
|
||||
|
||||
function composerControlsHtml() {
|
||||
function composerControlsHtml(crowded = false) {
|
||||
return `
|
||||
<div class="agent-chat__composer-controls">
|
||||
${
|
||||
crowded
|
||||
? `<div class="agent-chat__composer-run-status">
|
||||
<span class="agent-chat__run-status agent-chat__run-status--interrupted">
|
||||
${iconSvg()}<span class="agent-chat__run-status-label">Interrupted</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="agent-chat__session-overrides-pill">
|
||||
<button class="agent-chat__session-overrides-open" type="button">4 session overrides</button>
|
||||
<button class="agent-chat__session-overrides-clear" type="button" aria-label="Clear session overrides">${iconSvg()}</button>
|
||||
</span>`
|
||||
: ""
|
||||
}
|
||||
<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" data-chat-composer-model="true" aria-label="Chat model">
|
||||
<span class="chat-controls__inline-select-label">GPT-5.6</span>
|
||||
<summary class="chat-controls__inline-select-trigger chat-controls__model-trigger" data-chat-composer-model="true" aria-label="Chat model">
|
||||
<span class="chat-controls__inline-select-label">GPT-5.6 Luna</span>
|
||||
</summary>
|
||||
<div class="chat-controls__inline-select-menu chat-controls__model-menu">
|
||||
<div class="chat-controls__model-search-wrap"><input class="chat-controls__model-search" placeholder="Search models" /></div>
|
||||
@@ -284,11 +300,14 @@ function composerControlsHtml() {
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option chat-controls__inline-select-option--selected">Default model</button>
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option">gpt-5.5</button>
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option">claude-sonnet-4-6</button>
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option">gpt-5.6-luna</button>
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option">gpt-5.6-sol</button>
|
||||
<button class="chat-controls__inline-select-option chat-controls__model-option">openrouter/auto</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="chat-controls__inline-select chat-controls__effort-picker">
|
||||
<summary class="chat-controls__inline-select-trigger" data-chat-composer-effort="true" aria-label="Effort">
|
||||
<summary class="chat-controls__inline-select-trigger chat-controls__effort-trigger" data-chat-composer-effort="true" aria-label="Effort">
|
||||
<span class="chat-controls__inline-select-label">High</span>
|
||||
</summary>
|
||||
<div class="chat-controls__inline-select-menu chat-controls__effort-menu">
|
||||
@@ -462,7 +481,19 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
${composerControlsHtml()}
|
||||
${composerControlsHtml(opts.crowdedComposerFooter)}
|
||||
${
|
||||
opts.crowdedComposerFooter
|
||||
? `<div class="agent-chat__typing-indicator" role="status">
|
||||
<span class="agent-chat__typing-avatars" aria-hidden="true">
|
||||
<span class="chat-author-avatar">A</span>
|
||||
<span class="chat-author-avatar">B</span>
|
||||
<span class="chat-author-avatar">C</span>
|
||||
</span>
|
||||
<span class="agent-chat__typing-text">Alexandria, Bartholomew, and Cassandra are typing</span>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-meta">
|
||||
<div class="context-usage">
|
||||
<details>
|
||||
@@ -503,12 +534,30 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function syncFixtureComposerPopoverAnchor(page: Page) {
|
||||
await page.locator(".agent-chat__input").evaluate((node) => {
|
||||
const viewport = window.visualViewport;
|
||||
const viewportTop = viewport?.offsetTop ?? 0;
|
||||
const layoutViewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
||||
const composerTop = node.getBoundingClientRect().top;
|
||||
node.style.setProperty(
|
||||
"--chat-composer-popover-bottom",
|
||||
`${layoutViewportHeight - composerTop + 6}px`,
|
||||
);
|
||||
node.style.setProperty(
|
||||
"--chat-composer-popover-max-height",
|
||||
`${Math.max(0, composerTop - viewportTop - 28)}px`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openFixture(width: number, height: number, opts: ChatFixtureOptions = {}) {
|
||||
const page = await openBrowserPage(width, height);
|
||||
try {
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>${chatHtml(opts, width <= 1100)}</body></html>`,
|
||||
);
|
||||
await syncFixtureComposerPopoverAnchor(page);
|
||||
return page;
|
||||
} catch (error) {
|
||||
await closeBrowserPage(page);
|
||||
@@ -2171,27 +2220,161 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[320, 568],
|
||||
[375, 812],
|
||||
[667, 375],
|
||||
[768, 500],
|
||||
[320, 568, false],
|
||||
[375, 812, false],
|
||||
[667, 375, false],
|
||||
[768, 500, false],
|
||||
[320, 568, true],
|
||||
[667, 375, true],
|
||||
] as const)(
|
||||
"keeps the composer model menu inside the mobile viewport at %sx%s",
|
||||
async (width, height) => {
|
||||
const page = await openFixture(width, height);
|
||||
"keeps the composer popovers inside the mobile viewport and clear of the input at %sx%s (attachment: %s)",
|
||||
async (width, height, composerAttachment) => {
|
||||
const page = await openFixture(width, height, { composerAttachment });
|
||||
try {
|
||||
await page.locator('[data-chat-composer-model="true"]').evaluate((node) => {
|
||||
node.parentElement?.setAttribute("open", "");
|
||||
});
|
||||
const menu = await getBoundingBox(page, ".chat-controls__model-menu");
|
||||
expect(menu.x).toBeGreaterThanOrEqual(0);
|
||||
expect(menu.x + menu.width).toBeLessThanOrEqual(width + 1);
|
||||
const composer = await getBoundingBox(page, ".agent-chat__input");
|
||||
for (const picker of [
|
||||
{
|
||||
menu: ".chat-controls__model-menu",
|
||||
trigger: '[data-chat-composer-model="true"]',
|
||||
},
|
||||
{
|
||||
menu: ".chat-controls__effort-menu",
|
||||
trigger: '[data-chat-composer-effort="true"]',
|
||||
},
|
||||
{
|
||||
menu: ".context-usage__popover",
|
||||
trigger: ".context-ring",
|
||||
},
|
||||
]) {
|
||||
await page.locator(picker.trigger).evaluate((node) => {
|
||||
node.parentElement?.setAttribute("open", "");
|
||||
});
|
||||
await waitForLayoutSettled(page);
|
||||
await syncFixtureComposerPopoverAnchor(page);
|
||||
await waitForLayoutSettled(page);
|
||||
const menu = await getBoundingBox(page, picker.menu);
|
||||
const trigger = await getBoundingBox(page, picker.trigger);
|
||||
const footer = await getBoundingBox(page, ".agent-chat__composer-footer");
|
||||
const menuPosition = await page.locator(picker.menu).evaluate((node) => ({
|
||||
bottom: getComputedStyle(node).bottom,
|
||||
boxSizing: getComputedStyle(node).boxSizing,
|
||||
maxHeight: getComputedStyle(node).maxHeight,
|
||||
}));
|
||||
const menuLabel = `${picker.menu} ${JSON.stringify(menuPosition)}`;
|
||||
expect(menu.x, picker.menu).toBeGreaterThanOrEqual(0);
|
||||
expect(menu.x + menu.width, picker.menu).toBeLessThanOrEqual(width + 1);
|
||||
expect(menu.y, menuLabel).toBeGreaterThanOrEqual(0);
|
||||
expect(menu.y + menu.height, picker.menu).toBeLessThanOrEqual(composer.y + 1);
|
||||
expect(trigger.y + trigger.height, picker.trigger).toBeLessThanOrEqual(height + 1);
|
||||
expect(footer.y + footer.height, picker.menu).toBeLessThanOrEqual(height + 1);
|
||||
await page.locator(picker.trigger).evaluate((node) => {
|
||||
node.parentElement?.removeAttribute("open");
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("anchors mobile composer popovers when the iPhone visual viewport is panned", async () => {
|
||||
const page = await openFixture(375, 812);
|
||||
try {
|
||||
await page.locator(".card.chat").evaluate(async (node) => {
|
||||
await Promise.all(node.getAnimations().map((animation) => animation.finished));
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: { height: 400, offsetTop: 300 },
|
||||
});
|
||||
});
|
||||
await syncFixtureComposerPopoverAnchor(page);
|
||||
await page.locator('[data-chat-composer-model="true"]').evaluate((node) => {
|
||||
node.parentElement?.setAttribute("open", "");
|
||||
});
|
||||
await waitForLayoutSettled(page);
|
||||
await syncFixtureComposerPopoverAnchor(page);
|
||||
await waitForLayoutSettled(page);
|
||||
const composer = await getBoundingBox(page, ".agent-chat__input");
|
||||
const menu = await getBoundingBox(page, ".chat-controls__model-menu");
|
||||
const anchorEvidence = await page.locator(".agent-chat__input").evaluate((node) => ({
|
||||
anchorBottom: getComputedStyle(node).getPropertyValue("--chat-composer-popover-bottom"),
|
||||
layoutHeight: document.documentElement.clientHeight,
|
||||
}));
|
||||
|
||||
expect(menu.y).toBeGreaterThanOrEqual(300);
|
||||
expect(
|
||||
Math.abs(menu.y + menu.height - (composer.y - 6)),
|
||||
JSON.stringify({ anchorEvidence, composer, menu }),
|
||||
).toBeLessThanOrEqual(1);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps transient footer controls from crushing the mobile model pickers", async () => {
|
||||
const page = await openFixture(320, 568, { crowdedComposerFooter: true });
|
||||
try {
|
||||
await expectNoHorizontalOverflow(page);
|
||||
const layout = await page.evaluate(() => {
|
||||
const rectFor = (selector: string) => {
|
||||
const node = document.querySelector<HTMLElement>(selector)!;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
clientWidth: node.clientWidth,
|
||||
scrollWidth: node.scrollWidth,
|
||||
};
|
||||
};
|
||||
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"),
|
||||
status: rectFor(".agent-chat__composer-run-status"),
|
||||
typing: rectFor(".agent-chat__typing-indicator"),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.controls.scrollWidth).toBeLessThanOrEqual(layout.controls.clientWidth + 1);
|
||||
for (const control of [
|
||||
layout.status,
|
||||
layout.overrides,
|
||||
layout.model,
|
||||
layout.effort,
|
||||
layout.typing,
|
||||
]) {
|
||||
expect(control.x).toBeGreaterThanOrEqual(layout.footer.x - 1);
|
||||
expect(control.x + control.width).toBeLessThanOrEqual(
|
||||
layout.footer.x + layout.footer.width + 1,
|
||||
);
|
||||
}
|
||||
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);
|
||||
for (const [left, right] of [
|
||||
[layout.status, layout.overrides],
|
||||
[layout.overrides, layout.model],
|
||||
[layout.model, layout.effort],
|
||||
[layout.effort, layout.typing],
|
||||
[layout.typing, layout.meta],
|
||||
] as const) {
|
||||
expect(rectsOverlap(left, right)).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[320, 568],
|
||||
[393, 852],
|
||||
@@ -2218,6 +2401,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
clientWidth: node.clientWidth,
|
||||
scrollWidth: node.scrollWidth,
|
||||
display: getComputedStyle(node).display,
|
||||
};
|
||||
};
|
||||
@@ -2230,6 +2415,14 @@ 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"),
|
||||
modelTrigger: rectFor(".chat-controls__model-trigger"),
|
||||
modelLabel: rectFor(
|
||||
".chat-controls__model-trigger .chat-controls__inline-select-label",
|
||||
),
|
||||
effortTrigger: rectFor(".chat-controls__effort-trigger"),
|
||||
effortLabel: rectFor(
|
||||
".chat-controls__effort-trigger .chat-controls__inline-select-label",
|
||||
),
|
||||
context: rectFor(".context-ring"),
|
||||
attach: rectFor('.agent-chat__input-btn[aria-label="Add attachment"]'),
|
||||
send: rectFor(".chat-send-btn"),
|
||||
@@ -2244,11 +2437,28 @@ 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",
|
||||
);
|
||||
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, context, attach, send]) {
|
||||
for (const control of [
|
||||
footer,
|
||||
textarea,
|
||||
meta,
|
||||
model,
|
||||
modelTrigger,
|
||||
effortTrigger,
|
||||
context,
|
||||
attach,
|
||||
send,
|
||||
]) {
|
||||
expect(control.x).toBeGreaterThanOrEqual(input.x - 1);
|
||||
expect(control.x + control.width).toBeLessThanOrEqual(input.x + input.width + 1);
|
||||
}
|
||||
@@ -2267,6 +2477,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(send.x).toBeGreaterThanOrEqual(textarea.x + textarea.width - 1);
|
||||
expect(send.x + send.width).toBeLessThanOrEqual(input.x + input.width + 1);
|
||||
expect(rectsOverlap(model, send)).toBe(false);
|
||||
const effortContextGap = context.x - (effortTrigger.x + effortTrigger.width);
|
||||
expect(effortContextGap).toBeGreaterThanOrEqual(-1);
|
||||
expect(effortContextGap).toBeLessThanOrEqual(9);
|
||||
const composerFontSize = await page
|
||||
.locator(".agent-chat__composer-combobox > textarea")
|
||||
.evaluate((textareaNode) => Number.parseFloat(getComputedStyle(textareaNode).fontSize));
|
||||
@@ -2274,6 +2487,15 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(composerFontSize).toBe(16);
|
||||
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);
|
||||
}
|
||||
for (const label of [modelLabel, effortLabel]) {
|
||||
expect(label.clientWidth).toBeDefined();
|
||||
expect(label.scrollWidth).toBeDefined();
|
||||
expect(label.scrollWidth ?? 0).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1);
|
||||
}
|
||||
expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
for (const control of [model, context]) {
|
||||
|
||||
@@ -17,10 +17,109 @@ type ComposerTextareaResizeObserverState = {
|
||||
adjustmentFrame: number | null;
|
||||
};
|
||||
|
||||
type ComposerPopoverAnchorObserverState = {
|
||||
resizeObserver: ResizeObserver | null;
|
||||
toggleObserver: MutationObserver | null;
|
||||
viewport: VisualViewport | null;
|
||||
updateFrame: number | null;
|
||||
scheduleUpdate: () => void;
|
||||
};
|
||||
|
||||
const composerTextareaResizeObservers = new WeakMap<
|
||||
HTMLTextAreaElement,
|
||||
ComposerTextareaResizeObserverState
|
||||
>();
|
||||
const composerPopoverAnchorObservers = new WeakMap<
|
||||
HTMLElement,
|
||||
ComposerPopoverAnchorObserverState
|
||||
>();
|
||||
|
||||
const COMPOSER_POPOVER_GAP_PX = 6;
|
||||
// max-height constrains the menu's scrollable box before its border/padding;
|
||||
// include that chrome so the outer panel retains a viewport gutter.
|
||||
const COMPOSER_POPOVER_VIEWPORT_INSET_PX = 28;
|
||||
|
||||
function updateComposerPopoverAnchor(el: HTMLElement) {
|
||||
const viewport = window.visualViewport;
|
||||
const viewportTop = viewport?.offsetTop ?? 0;
|
||||
const layoutViewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
||||
const composerTop = el.getBoundingClientRect().top;
|
||||
const bottom = layoutViewportHeight - composerTop + COMPOSER_POPOVER_GAP_PX;
|
||||
const maxHeight = composerTop - viewportTop - COMPOSER_POPOVER_VIEWPORT_INSET_PX;
|
||||
el.style.setProperty("--chat-composer-popover-bottom", `${Math.max(0, bottom)}px`);
|
||||
el.style.setProperty("--chat-composer-popover-max-height", `${Math.max(0, maxHeight)}px`);
|
||||
}
|
||||
|
||||
function observeComposerPopoverAnchor(el: HTMLElement) {
|
||||
if (composerPopoverAnchorObservers.has(el)) {
|
||||
return;
|
||||
}
|
||||
const viewport = window.visualViewport;
|
||||
const state: ComposerPopoverAnchorObserverState = {
|
||||
resizeObserver: null,
|
||||
toggleObserver: null,
|
||||
viewport,
|
||||
updateFrame: null,
|
||||
scheduleUpdate: () => {
|
||||
if (state.updateFrame !== null) {
|
||||
return;
|
||||
}
|
||||
state.updateFrame = requestAnimationFrame(() => {
|
||||
state.updateFrame = null;
|
||||
if (composerPopoverAnchorObservers.get(el) === state) {
|
||||
updateComposerPopoverAnchor(el);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
if (typeof ResizeObserver === "function") {
|
||||
state.resizeObserver = new ResizeObserver(state.scheduleUpdate);
|
||||
state.resizeObserver.observe(el);
|
||||
}
|
||||
if (typeof MutationObserver === "function") {
|
||||
state.toggleObserver = new MutationObserver(state.scheduleUpdate);
|
||||
state.toggleObserver.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["open"],
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", state.scheduleUpdate);
|
||||
viewport?.addEventListener("resize", state.scheduleUpdate);
|
||||
viewport?.addEventListener("scroll", state.scheduleUpdate);
|
||||
composerPopoverAnchorObservers.set(el, state);
|
||||
updateComposerPopoverAnchor(el);
|
||||
}
|
||||
|
||||
export function disconnectComposerPopoverAnchorObserver(el: HTMLElement) {
|
||||
const state = composerPopoverAnchorObservers.get(el);
|
||||
composerPopoverAnchorObservers.delete(el);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.resizeObserver?.disconnect();
|
||||
state.toggleObserver?.disconnect();
|
||||
window.removeEventListener("resize", state.scheduleUpdate);
|
||||
state.viewport?.removeEventListener("resize", state.scheduleUpdate);
|
||||
state.viewport?.removeEventListener("scroll", state.scheduleUpdate);
|
||||
if (state.updateFrame !== null) {
|
||||
cancelAnimationFrame(state.updateFrame);
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceComposerPopoverAnchor(
|
||||
previous: HTMLElement | null,
|
||||
element?: Element,
|
||||
): HTMLElement | null {
|
||||
const next = element instanceof HTMLElement ? element : null;
|
||||
if (previous && previous !== next) {
|
||||
disconnectComposerPopoverAnchorObserver(previous);
|
||||
}
|
||||
if (next) {
|
||||
observeComposerPopoverAnchor(next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function updateTextareaOverflow(el: HTMLTextAreaElement) {
|
||||
el.style.overflowY = el.scrollHeight > el.clientHeight ? "auto" : "hidden";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ChatQueueItem } from "../../../lib/chat/chat-types.ts";
|
||||
import type { ChatRunUiStatus } from "../run-lifecycle.ts";
|
||||
import { adjustTextareaHeight } from "./chat-composer-dom.ts";
|
||||
import {
|
||||
adjustTextareaHeight,
|
||||
disconnectComposerPopoverAnchorObserver,
|
||||
} from "./chat-composer-dom.ts";
|
||||
import { clearGoalElapsedTimers } from "./chat-composer-goal.ts";
|
||||
import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts";
|
||||
|
||||
@@ -30,6 +33,7 @@ function createChatComposerState(): ChatComposerState {
|
||||
gatewayQuestionCollapsed: false,
|
||||
questionTakeoverActive: false,
|
||||
restoreComposerFocus: false,
|
||||
composerInput: null,
|
||||
composerTextarea: null,
|
||||
microphonePickerOpen: false,
|
||||
microphonePickerLoading: false,
|
||||
@@ -40,6 +44,7 @@ function createChatComposerState(): ChatComposerState {
|
||||
capabilityMenuOpen: false,
|
||||
capabilityMenuView: "root",
|
||||
textareaRef: null,
|
||||
composerInputRef: null,
|
||||
dictation: null,
|
||||
dictationDraftKey: null,
|
||||
dictationSelection: null,
|
||||
@@ -158,6 +163,9 @@ export function resetChatComposerState(paneId?: string) {
|
||||
const paneState = composerStates.get(paneId);
|
||||
paneState?.dictation?.dispose();
|
||||
if (paneState) {
|
||||
if (paneState.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(paneState.composerInput);
|
||||
}
|
||||
releaseMicrophoneDeviceWatch(paneState);
|
||||
}
|
||||
composerStates.delete(paneId);
|
||||
@@ -165,6 +173,9 @@ export function resetChatComposerState(paneId?: string) {
|
||||
}
|
||||
for (const state of composerStates.values()) {
|
||||
state.dictation?.dispose();
|
||||
if (state.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(state.composerInput);
|
||||
}
|
||||
releaseMicrophoneDeviceWatch(state);
|
||||
}
|
||||
composerStates.clear();
|
||||
|
||||
@@ -182,6 +182,7 @@ export type ChatComposerState = {
|
||||
gatewayQuestionCollapsed: boolean;
|
||||
questionTakeoverActive: boolean;
|
||||
restoreComposerFocus: boolean;
|
||||
composerInput: HTMLElement | null;
|
||||
composerTextarea: HTMLTextAreaElement | null;
|
||||
microphonePickerOpen: boolean;
|
||||
microphonePickerLoading: boolean;
|
||||
@@ -192,8 +193,9 @@ export type ChatComposerState = {
|
||||
microphoneDiscoveryRequest: number;
|
||||
capabilityMenuOpen: boolean;
|
||||
capabilityMenuView: ChatComposerPlusMenuView;
|
||||
// Stable Lit ref: an inline arrow would change identity per render and force
|
||||
// a layout re-measure of the textarea on every chat render, not just attach.
|
||||
// Stable Lit refs: inline arrows would change identity per render and force
|
||||
// layout observers to detach and reconnect on every chat update.
|
||||
composerInputRef: ((element?: Element) => void) | null;
|
||||
textareaRef: ((element?: Element) => void) | null;
|
||||
dictation: ComposerDictationController | null;
|
||||
dictationDraftKey: string | null;
|
||||
|
||||
@@ -170,6 +170,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
? html`<div
|
||||
class="agent-chat__input ${props.offline ? "agent-chat__input--offline" : ""}"
|
||||
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
|
||||
${ref(state.composerInputRef ?? undefined)}
|
||||
>
|
||||
${props.offline
|
||||
? html`<div class="agent-chat__offline-hint" role="status" aria-live="polite">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
disconnectTextareaOverflowObserver,
|
||||
observeTextareaOverflow,
|
||||
preserveComposerFocusOnPrimaryAction,
|
||||
replaceComposerPopoverAnchor,
|
||||
restoreHistoryCaret,
|
||||
scheduleTextareaHeightAdjustment,
|
||||
} from "./chat-composer-dom.ts";
|
||||
@@ -140,6 +141,9 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
state.dictationDraftKey = draftKey;
|
||||
const visibleDraft =
|
||||
state.composingDraft?.key === draftKey ? state.composingDraft.value : props.draft;
|
||||
state.composerInputRef ??= (element?: Element) => {
|
||||
state.composerInput = replaceComposerPopoverAnchor(state.composerInput, element);
|
||||
};
|
||||
state.textareaRef ??= (element?: Element) => {
|
||||
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
|
||||
const prevTextarea = state.composerTextarea;
|
||||
|
||||
@@ -3629,16 +3629,48 @@ openclaw-chat-video-player {
|
||||
|
||||
.agent-chat__composer-controls {
|
||||
order: 0;
|
||||
display: grid;
|
||||
/* Three columns: settings, the transient interrupted toast (absent in
|
||||
the normal state, so its column collapses), and the model picker. */
|
||||
grid-template-columns: 40px minmax(0, max-content) minmax(0, max-content);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
margin-left: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__composer-controls {
|
||||
min-width: 187px;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__composer-run-status,
|
||||
.agent-chat__input .agent-chat__session-overrides-pill {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__composer-run-status {
|
||||
min-width: 23px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__session-overrides-pill {
|
||||
min-width: 26px;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__composer-run-status .agent-chat__run-status {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__session-overrides-open {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__session-overrides-clear {
|
||||
flex: 0 0 26px;
|
||||
}
|
||||
|
||||
.agent-chat__composer-meta {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -3651,25 +3683,49 @@ openclaw-chat-video-player {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* The borderless trigger is 30px on desktop; keep a >=40px touch target
|
||||
inside the mobile composer grid. */
|
||||
.agent-chat__composer-controls .chat-controls__inline-select-trigger {
|
||||
/* Reserve the readable model trigger, 44px effort target, and their gap;
|
||||
keep the picker cluster right-aligned like desktop while transient status
|
||||
text yields before either picker does. */
|
||||
.agent-chat__input .agent-chat__composer-controls .chat-composer-model-control {
|
||||
flex: 0 1 auto;
|
||||
min-width: 138px;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__composer-controls .chat-controls__model-picker {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__composer-controls .chat-controls__effort-picker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__typing-indicator {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The borderless trigger is 30px on desktop; keep a >=44px touch target
|
||||
inside the mobile composer row. */
|
||||
.agent-chat__input .agent-chat__composer-controls .chat-controls__inline-select-trigger {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
gap: 4px;
|
||||
padding: 0 2px 0 4px;
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__model-menu,
|
||||
.agent-chat__input .chat-controls__effort-menu {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
left: auto;
|
||||
left: max(12px, var(--safe-area-left));
|
||||
right: max(12px, var(--safe-area-right));
|
||||
bottom: calc(96px + var(--safe-area-bottom));
|
||||
width: min(392px, calc(100vw - 24px));
|
||||
max-height: min(440px, calc(100vh - 116px - var(--safe-area-bottom)));
|
||||
bottom: var(--chat-composer-popover-bottom, calc(96px + var(--safe-area-bottom)));
|
||||
width: auto;
|
||||
max-height: min(440px, var(--chat-composer-popover-max-height, calc(100dvh - 116px)));
|
||||
}
|
||||
|
||||
.agent-chat__composer-shell {
|
||||
@@ -3690,16 +3746,10 @@ openclaw-chat-video-player {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.agent-chat__input:has(.chat-controls__inline-select[open]),
|
||||
.agent-chat__input:has(.agent-chat__attach-menu[open]) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.agent-chat__input .chat-controls__model-menu,
|
||||
.agent-chat__input .chat-controls__effort-menu {
|
||||
max-height: calc(100vh - 148px);
|
||||
}
|
||||
|
||||
.agent-chat__composer-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -3724,22 +3774,22 @@ openclaw-chat-video-player {
|
||||
}
|
||||
|
||||
@media (max-width: 640px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
|
||||
/* Backdrop-filter contains fixed descendants; lift it and the landscape scroll clip while open. */
|
||||
/* Backdrop filtering contains fixed descendants, so lift it while the
|
||||
viewport-anchored panels are open. */
|
||||
.agent-chat__input:has(.chat-controls__inline-select[open]),
|
||||
.agent-chat__input:has(.context-usage details[open]),
|
||||
.agent-chat__input:has(.agent-chat__attach-menu[open]) {
|
||||
overflow: visible;
|
||||
.agent-chat__input:has(.context-usage details[open]) {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.context-usage__popover {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
left: max(12px, var(--safe-area-left));
|
||||
right: max(12px, var(--safe-area-right));
|
||||
bottom: calc(96px + var(--safe-area-bottom));
|
||||
bottom: var(--chat-composer-popover-bottom, calc(96px + var(--safe-area-bottom)));
|
||||
width: auto;
|
||||
max-height: calc(100vh - 116px - var(--safe-area-bottom));
|
||||
max-height: var(--chat-composer-popover-max-height, calc(100dvh - 116px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,17 +628,17 @@ wa-dropdown.new-session-page__start-menu {
|
||||
gap: 4px 2px;
|
||||
}
|
||||
|
||||
/* The shared chat footer becomes a three-column grid on mobile, but a new
|
||||
session has visibility pills instead of the chat settings slot. Keep
|
||||
those pills intrinsic and let the model picker use the remaining width. */
|
||||
/* A new session has visibility pills instead of the chat settings cluster.
|
||||
Keep those pills intrinsic and let the model picker use the remaining width. */
|
||||
.new-session-page__composer .agent-chat__composer-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.new-session-page__composer .chat-composer-model-control {
|
||||
.new-session-page__composer .agent-chat__input .chat-composer-model-control {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user