From a875ead7ef80237d7c8fa50a2c9e24fc7a7ed95f Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Fri, 14 Aug 2026 10:00:01 -0700 Subject: [PATCH] fix(ui): prevent dashboard chat messages from overlapping (#123713) Co-authored-by: Peter Steinberger --- ui/src/e2e/skills-owner-selection.e2e.test.ts | 8 ++++--- .../chat-transcript-controller.test.ts | 21 +++++++++++++++++++ .../components/chat-transcript-controller.ts | 17 ++++++++++++++- .../chat-transcript.test-support.ts | 9 ++++++-- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/ui/src/e2e/skills-owner-selection.e2e.test.ts b/ui/src/e2e/skills-owner-selection.e2e.test.ts index a3774f0aac8c..e9175a789e04 100644 --- a/ui/src/e2e/skills-owner-selection.e2e.test.ts +++ b/ui/src/e2e/skills-owner-selection.e2e.test.ts @@ -31,10 +31,12 @@ suite.define(() => { await page.goto(`${suite.server.baseUrl}skills`); await gateway.waitForRequest("skills.status"); - await expect - .poll(() => gateway.getRequests("skills.status")) - .toEqual([expect.objectContaining({ params: { agentId: "main" } })]); await page.getByText("No skills found.").waitFor(); + const requests = await gateway.getRequests("skills.status"); + expect(requests.length).toBeGreaterThan(0); + expect(requests.map((request) => request.params)).toEqual( + requests.map(() => ({ agentId: "main" })), + ); }); }); }); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.ts b/ui/src/pages/chat/components/chat-transcript-controller.test.ts index ee405b099f38..4891e856e694 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.ts @@ -51,6 +51,27 @@ describe("chat transcript controller", () => { } }); + it("measures newly inserted rows after Lit connects them", async () => { + // Lit invokes ref callbacks while a new row is still detached. Browsers + // report a zero offsetHeight there, which must not become the row's + // durable virtual size before the following user bubble is positioned. + transcriptDomState.detachedRowHeight = 0; + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const props = threadProps("pane-commentary-insert", "agent:main:session-a", [ + { role: "assistant", content: "commentary", timestamp: 1_000 }, + { role: "user", content: "next turn", timestamp: 2_000 }, + ]); + + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + render(renderChatThread(props, transcript), container); + + expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(100px)"); + }); + it("pauses an unmeasurable restore until loading commits an empty transcript", () => { const transcript = createTestTranscript(); const container = document.body.appendChild(document.createElement("div")); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts index 6981503daf28..089ebabc36de 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -132,7 +132,22 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri if (!callback) { callback = (element?: Element) => { if (element instanceof HTMLElement) { - this.virtualizerController.getVirtualizer().measureElement(element); + if (element.isConnected) { + this.virtualizerController.getVirtualizer().measureElement(element); + } else { + // Lit invokes refs before the row is connected. Measuring a new + // key there records offsetHeight=0, so a following row can share + // its transform and paint over it until ResizeObserver catches up. + queueMicrotask(() => { + if ( + element.isConnected && + element.dataset.virtualRowKey === key && + this.rowIndexesByKey.has(key) + ) { + this.virtualizerController.getVirtualizer().measureElement(element); + } + }); + } return; } // Re-stamps (e.g. the chat<->dashboard face switch) re-invoke each diff --git a/ui/src/pages/chat/components/chat-transcript.test-support.ts b/ui/src/pages/chat/components/chat-transcript.test-support.ts index 64b6e43e50ff..d900e99558ee 100644 --- a/ui/src/pages/chat/components/chat-transcript.test-support.ts +++ b/ui/src/pages/chat/components/chat-transcript.test-support.ts @@ -4,7 +4,7 @@ import { resetThreadPresentation } from "./chat-thread-interactions.ts"; export const observedElements = new Set(); export const resizeObservers = new Set(); -export const transcriptDomState = { measuredRowHeight: 100 }; +export const transcriptDomState = { measuredRowHeight: 100, detachedRowHeight: 100 }; class RecordingResizeObserver implements ResizeObserver { private readonly targets = new Set(); @@ -95,9 +95,14 @@ export function installTranscriptDomMocks(): void { observedElements.clear(); resizeObservers.clear(); transcriptDomState.measuredRowHeight = 100; + transcriptDomState.detachedRowHeight = 100; vi.stubGlobal("ResizeObserver", RecordingResizeObserver); vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( - () => transcriptDomState.measuredRowHeight, + function (this: HTMLElement) { + return this.isConnected + ? transcriptDomState.measuredRowHeight + : transcriptDomState.detachedRowHeight; + }, ); vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ x: 0,