fix(ui): model picker shifts when another user starts typing (#122809)

* fix(ui): keep composer model picker stable while typing

* test(ui): prove typing keeps model picker stable

Co-authored-by: Colin Johnson <colin@solvely.net>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Colin Johnson
2026-08-12 23:08:45 -04:00
committed by GitHub
parent 4c12c973ed
commit ae20e2c163
6 changed files with 132 additions and 75 deletions
@@ -99,8 +99,16 @@ suite.define(() => {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
const composer = page.locator(".agent-chat__composer-combobox textarea");
const modelTrigger = page.locator(".chat-controls__model-trigger");
const outsideTypingIndicator = page.locator(".agent-chat__typing-indicator--outside");
await gateway.waitForRequest("session.suggestions.list");
await expect(composer).toBeEnabled();
await modelTrigger.waitFor();
const idleModelBox = await modelTrigger.boundingBox();
if (idleModelBox === null) {
throw new Error("Expected the model trigger before remote typing");
}
await expect(outsideTypingIndicator).toHaveCount(0);
await gateway.emitGatewayEvent("session.typing", {
sessionKey: "main",
sessionId: "session-main",
@@ -110,6 +118,19 @@ suite.define(() => {
ts: Date.now(),
});
await expect(page.locator(".agent-chat__typing-text")).toHaveText("Owner is typing…");
const [typingModelBox, typingIndicatorBox, composerShellBox] = await Promise.all([
modelTrigger.boundingBox(),
outsideTypingIndicator.boundingBox(),
page.locator(".agent-chat__composer-shell").boundingBox(),
]);
if (typingModelBox === null || typingIndicatorBox === null || composerShellBox === null) {
throw new Error("Expected the composer layout after remote typing");
}
expect(Math.abs(typingModelBox.x - idleModelBox.x)).toBeLessThanOrEqual(0.5);
expect(Math.abs(typingModelBox.y - idleModelBox.y)).toBeLessThanOrEqual(2);
expect(typingIndicatorBox.y + typingIndicatorBox.height).toBeLessThanOrEqual(
composerShellBox.y + 1,
);
await composer.fill("Try the focused change");
const typing = await gateway.waitForRequest("session.typing");
expect(typing.params).toMatchObject({ sessionId: "session-main" });
+4 -33
View File
@@ -209,12 +209,14 @@ describe("renderChatComposer controls", () => {
actionLabel: "Unarchive",
onAction,
},
typingActors: [{ id: "ayaan", label: "Ayaan" }],
});
const banner = container.querySelector(".agent-chat__disabled-banner");
expect(banner?.textContent).toContain("This session is archived.");
expect(container.querySelector(".agent-chat__input")).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
banner?.querySelector<HTMLButtonElement>("button")?.click();
expect(onAction).toHaveBeenCalledOnce();
button(container, t("chat.runControls.stopGenerating")).click();
@@ -832,39 +834,6 @@ describe("renderChatComposer controls", () => {
});
describe("renderChatComposer status", () => {
it.each([
{
actors: [{ id: "ayaan", label: "Ayaan" }],
expectedText: "Ayaan is typing…",
expectedAvatars: 1,
},
{
actors: [
{ id: "ayaan", label: "Ayaan" },
{ id: "liam", label: "Liam" },
{ id: "maya", label: "Maya" },
{ id: "zoe", label: "Zoe" },
],
expectedText: "Ayaan, Liam, Maya, Zoe are typing…",
expectedAvatars: 3,
},
])(
"keeps $expectedText in the permanent composer footer",
({ actors, expectedText, expectedAvatars }) => {
const { container } = renderComposer({ typingActors: actors });
const indicator = container.querySelector(".agent-chat__typing-indicator");
expect(indicator?.closest(".agent-chat__composer-footer")).not.toBeNull();
expect(indicator?.closest(".agent-chat__input")?.firstElementChild).not.toBe(indicator);
expect(indicator?.querySelectorAll(".chat-author-avatar")).toHaveLength(expectedAvatars);
// The status text already names every typer; avatars must stay out of the
// accessibility tree or screen readers announce each name twice.
const avatars = indicator?.querySelector(".agent-chat__typing-avatars");
expect(avatars?.getAttribute("aria-hidden")).toBe("true");
expect(indicator?.textContent).toContain(expectedText);
},
);
it("swaps the expanded question with the composer and restores its draft and focus", async () => {
const container = document.createElement("div");
document.body.append(container);
@@ -876,6 +845,7 @@ describe("renderChatComposer status", () => {
gatewayQuestionPrompts: [],
composerControls: html`<button type="button">Model</button>`,
onRequestUpdate: vi.fn(),
typingActors: [{ id: "ayaan", label: "Ayaan" }],
});
composerProps.onDraftChange = (next) => {
composerProps.draft = next;
@@ -900,6 +870,7 @@ describe("renderChatComposer status", () => {
await panel.updateComplete;
expect(container.querySelector(".agent-chat__input")).toBeNull();
expect(container.querySelector(".agent-chat__composer-footer")).toBeNull();
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
expect(document.activeElement).toBe(panel.querySelector(".chat-question-panel"));
expect(composerProps.draft).toBe("Keep this draft while composing");
@@ -425,6 +425,18 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
</openclaw-chat-session-rail>`
: ""
}
${
opts.crowdedComposerFooter
? `<div class="agent-chat__typing-indicator agent-chat__typing-indicator--outside" 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-shell">
<div class="agent-chat__input">
${
@@ -483,18 +495,6 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
</div>
<div class="agent-chat__composer-footer">
${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>
@@ -2533,7 +2533,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
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"),
typing: rectFor(".agent-chat__typing-indicator--outside"),
};
});
@@ -2559,11 +2559,11 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
[layout.status, layout.overrides],
[layout.overrides, layout.model],
[layout.model, layout.effort],
[layout.effort, layout.typing],
[layout.typing, layout.meta],
[layout.effort, layout.meta],
] as const) {
expect(rectsOverlap(left, right)).toBe(false);
}
expect(rectsOverlap(layout.typing, layout.footer)).toBe(false);
} finally {
await closeBrowserPage(page);
}
+61
View File
@@ -748,6 +748,67 @@ function renderChatInto(container: HTMLElement, overrides: Partial<ChatProps> =
render(renderChat(createChatProps(overrides)), container);
}
describe("chat typing status", () => {
it.each([
{
actors: [{ id: "ayaan", label: "Ayaan" }],
expectedText: "Ayaan is typing…",
expectedAvatars: 1,
},
{
actors: [
{ id: "ayaan", label: "Ayaan" },
{ id: "liam", label: "Liam" },
{ id: "maya", label: "Maya" },
{ id: "zoe", label: "Zoe" },
],
expectedText: "Ayaan, Liam, Maya, Zoe are typing…",
expectedAvatars: 3,
},
])("renders $expectedText above the composer", ({ actors, expectedText, expectedAvatars }) => {
const container = renderChatView({ typingActors: actors });
const indicator = container.querySelector(".agent-chat__typing-indicator--outside");
expect(indicator?.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(
true,
);
expect(indicator?.closest(".agent-chat__composer-shell")).toBeNull();
expect(indicator?.querySelectorAll(".chat-author-avatar")).toHaveLength(expectedAvatars);
expect(
indicator?.querySelector(".agent-chat__typing-avatars")?.getAttribute("aria-hidden"),
).toBe("true");
expect(indicator?.textContent).toContain(expectedText);
});
it("keeps queue and error state above the typing status", () => {
const container = renderChatView({
typingActors: [{ id: "ayaan", label: "Ayaan" }],
runError: { summary: "Gateway unavailable" },
queue: [{ id: "queued", text: "Try again", createdAt: 1 }],
});
const indicator = requireElement(
container,
".agent-chat__typing-indicator--outside",
"typing status",
);
expect(indicator.previousElementSibling?.classList.contains("chat-run-error")).toBe(true);
expect(indicator.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(
true,
);
});
it("hides typing status with the model setup composer", () => {
const container = renderChatView({
canSend: false,
modelSetupRequired: true,
typingActors: [{ id: "ayaan", label: "Ayaan" }],
});
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
});
});
function createSessionWorkspace(
overrides: Partial<NonNullable<ChatProps["sessionWorkspace"]>> = {},
): NonNullable<ChatProps["sessionWorkspace"]> {
@@ -176,6 +176,29 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
</div>
`
: nothing}
${showComposerInput && props.typingActors?.length
? html`<div
class="agent-chat__typing-indicator agent-chat__typing-indicator--outside"
role="status"
>
<!-- Avatars stay aria-hidden: the status text already names every
typer, and role="img" avatars would announce each name twice. -->
<span class="agent-chat__typing-avatars" aria-hidden="true">
${props.typingActors
.slice(0, 3)
.map((actor) => renderChatAuthorAvatar({ id: actor.id, name: actor.label }))}
</span>
<span class="agent-chat__typing-text"
>${props.typingActors.length === 1
? t("chat.sessionSuggestions.typing", {
name: props.typingActors[0]?.label ?? "",
})
: t("chat.sessionSuggestions.typingMany", {
names: props.typingActors.map((actor) => actor.label).join(", "),
})}</span
>
</div>`
: nothing}
<div class="agent-chat__composer-shell">
${questionPanelProps
? html`
@@ -505,28 +528,6 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
</div>
`
: nothing}
${props.typingActors?.length
? html`<div class="agent-chat__typing-indicator" role="status">
<!-- Avatars stay aria-hidden: the status text already names every
typer, and role="img" avatars would announce each name twice. -->
<span class="agent-chat__typing-avatars" aria-hidden="true">
${props.typingActors
.slice(0, 3)
.map((actor) =>
renderChatAuthorAvatar({ id: actor.id, name: actor.label }),
)}
</span>
<span class="agent-chat__typing-text"
>${props.typingActors.length === 1
? t("chat.sessionSuggestions.typing", {
name: props.typingActors[0]?.label ?? "",
})
: t("chat.sessionSuggestions.typingMany", {
names: props.typingActors.map((actor) => actor.label).join(", "),
})}</span
>
</div>`
: nothing}
<div class="agent-chat__composer-meta">${contextNotice}</div>
</div>
</div>`
+8 -5
View File
@@ -1756,6 +1756,14 @@ button.chat-reply-preview--message:disabled {
white-space: nowrap;
}
.agent-chat__typing-indicator--outside {
box-sizing: border-box;
width: calc(100% - 36px);
max-width: var(--chat-thread-max-width, 48rem);
margin: 0 auto -2px;
padding-inline: 8px;
}
@media (max-width: 640px) {
.session-suggestion {
grid-template-columns: auto minmax(0, 1fr);
@@ -3772,11 +3780,6 @@ button.chat-reply-preview--message:disabled {
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 {