From ae20e2c16307c5d6629fac52e31ff94207e66ed7 Mon Sep 17 00:00:00 2001 From: Colin Johnson Date: Wed, 12 Aug 2026 23:08:45 -0400 Subject: [PATCH] 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 --------- Co-authored-by: Peter Steinberger --- ui/src/e2e/session-suggestions.e2e.test.ts | 21 +++++++ ui/src/pages/chat/chat-composer.test.ts | 37 ++--------- .../chat/chat-responsive.browser.test.ts | 30 ++++----- ui/src/pages/chat/chat-view.test.ts | 61 +++++++++++++++++++ .../chat/components/chat-composer-view.ts | 45 +++++++------- ui/src/styles/chat/layout.css | 13 ++-- 6 files changed, 132 insertions(+), 75 deletions(-) diff --git a/ui/src/e2e/session-suggestions.e2e.test.ts b/ui/src/e2e/session-suggestions.e2e.test.ts index a208247ce226..d6cda11da7d1 100644 --- a/ui/src/e2e/session-suggestions.e2e.test.ts +++ b/ui/src/e2e/session-suggestions.e2e.test.ts @@ -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" }); diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 15e3b702c4ab..ad83d1a6263d 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -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("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``, 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"); diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index ca6200d6b061..c61ac21eafe8 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -425,6 +425,18 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) { ` : "" } + ${ + opts.crowdedComposerFooter + ? `
+ + Alexandria, Bartholomew, and Cassandra are typing +
` + : "" + }
${ @@ -483,18 +495,6 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
` diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index f207bca1202e..9005bf84b32b 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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 {