diff --git a/apps/web/components/task/chat/queued-ghost-message-overflow.test.tsx b/apps/web/components/task/chat/queued-ghost-message-overflow.test.tsx new file mode 100644 index 0000000000..307e4d46a4 --- /dev/null +++ b/apps/web/components/task/chat/queued-ghost-message-overflow.test.tsx @@ -0,0 +1,514 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import { QueuedGhostMessage } from "./queued-ghost-message"; + +const PREVIEW_TEST_ID = "queue-entry-text"; +const EXPAND_TEST_ID = "queue-entry-expand"; +const REMOVE_TEST_ID = "queue-entry-remove"; + +type Geometry = { scrollHeight: number; clientHeight: number }; +type ResizeListener = EventListenerOrEventListenerObject; + +let geometry: (element: HTMLElement) => Geometry; +let geometryReads: string[]; +let throwOnScrollHeight = false; +let originalScrollHeight: PropertyDescriptor | undefined; +let originalClientHeight: PropertyDescriptor | undefined; +let originalResizeObserver: typeof globalThis.ResizeObserver | undefined; +let originalVisualViewport: VisualViewport | null; +let windowResizeListeners: ResizeListener[]; +let originalWindowAddEventListener: typeof window.addEventListener; +let originalDocumentFonts: PropertyDescriptor | undefined; + +class FontFaceSetStub extends EventTarget { + status: FontFaceSetLoadStatus = "loading"; + readyReads = 0; + + get ready(): Promise { + this.readyReads += 1; + return Promise.resolve(this as unknown as FontFaceSet); + } +} + +class VisualViewportStub { + readonly resizeListeners = new Set(); + invokeDuringRemove = false; + + addEventListener(type: string, listener: ResizeListener) { + if (type === "resize") this.resizeListeners.add(listener); + } + + removeEventListener(type: string, listener: ResizeListener) { + if (type !== "resize") return; + if (this.invokeDuringRemove) invokeListener(listener); + this.resizeListeners.delete(listener); + } + + emitResize() { + for (const listener of [...this.resizeListeners]) invokeListener(listener); + } +} + +class ResizeObserverStub { + static instances: ResizeObserverStub[] = []; + readonly observed = new Set(); + disconnected = false; + invokeDuringDisconnect = false; + + constructor(readonly callback: ResizeObserverCallback) { + ResizeObserverStub.instances.push(this); + } + + observe(target: Element) { + this.observed.add(target); + } + + unobserve(target: Element) { + this.observed.delete(target); + } + + disconnect() { + if (this.invokeDuringDisconnect) this.emit([...this.observed]); + this.disconnected = true; + this.observed.clear(); + } + + emit(targets: Element[] = [...this.observed]) { + const entries = targets.map((target) => ({ target }) as ResizeObserverEntry); + this.callback(entries, this as unknown as ResizeObserver); + } +} + +let visualViewport: VisualViewportStub; + +function invokeListener(listener: ResizeListener): void { + const event = new Event("resize"); + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); +} + +function disclosureFor(element: HTMLElement): HTMLElement | null { + return ( + element + .closest('[data-testid="queue-entry"]') + ?.querySelector('[data-testid="queue-entry-expand"]') ?? null + ); +} + +function installGeometryGetters(): void { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + if (this.dataset.testid !== PREVIEW_TEST_ID) { + return originalScrollHeight?.get?.call(this) ?? 0; + } + geometryReads.push(disclosureFor(this)?.style.display ?? "absent"); + if (throwOnScrollHeight) throw new Error("geometry failed"); + return geometry(this).scrollHeight; + }, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get(this: HTMLElement) { + if (this.dataset.testid !== PREVIEW_TEST_ID) { + return originalClientHeight?.get?.call(this) ?? 0; + } + geometryReads.push(disclosureFor(this)?.style.display ?? "absent"); + return geometry(this).clientHeight; + }, + }); +} + +function queuedEntry(content: string, id = "queue-1"): QueuedMessage { + return { + id, + session_id: "session-1", + task_id: "task-1", + content, + plan_mode: false, + queued_at: "2026-09-04T00:00:00Z", + queued_by: "user", + }; +} + +function renderMessage(content: string, key = "queue-1") { + return render( + undefined} + onRemove={() => undefined} + />, + ); +} + +function latestObserver(): ResizeObserverStub { + const observer = ResizeObserverStub.instances.at(-1); + expect(observer).toBeDefined(); + return observer!; +} + +function emitWindowResize(): void { + act(() => window.dispatchEvent(new Event("resize"))); +} + +beforeEach(() => { + originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + originalResizeObserver = globalThis.ResizeObserver; + originalVisualViewport = window.visualViewport; + originalWindowAddEventListener = window.addEventListener.bind(window); + originalDocumentFonts = Object.getOwnPropertyDescriptor(document, "fonts"); + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + geometryReads = []; + throwOnScrollHeight = false; + windowResizeListeners = []; + ResizeObserverStub.instances = []; + visualViewport = new VisualViewportStub(); + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + vi.spyOn(window, "addEventListener").mockImplementation((type, listener, options) => { + if (type === "resize") windowResizeListeners.push(listener); + originalWindowAddEventListener(type, listener, options); + }); + installGeometryGetters(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + if (originalScrollHeight) { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight); + } else { + delete (HTMLElement.prototype as unknown as Record).scrollHeight; + } + if (originalClientHeight) { + Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight); + } else { + delete (HTMLElement.prototype as unknown as Record).clientHeight; + } + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + writable: true, + value: originalResizeObserver, + }); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: originalVisualViewport, + }); + if (originalDocumentFonts) { + Object.defineProperty(document, "fonts", originalDocumentFonts); + } else { + delete (document as unknown as Record).fonts; + } +}); + +describe("queued message rendered overflow measurement semantics", () => { + // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9 + it("offers disclosure for short content that renders beyond the collapsed cap", () => { + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + renderMessage("short rendered overflow"); + + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); + + // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9 + it("omits disclosure for long content that exactly fits", () => { + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + renderMessage("x".repeat(120)); + + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + }); + + it("escapes self-induced overflow at the width available without disclosure", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + renderMessage("x".repeat(120)); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + + geometryReads = []; + geometry = (element) => { + const disclosure = disclosureFor(element); + return { + scrollHeight: !disclosure || disclosure.style.display === "none" ? 44 : 45, + clientHeight: 44, + }; + }; + emitWindowResize(); + + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + expect(geometryReads).toEqual(["none", "none"]); + emitWindowResize(); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + }); + + it("hides disclosure before geometry reads and restores its exact inline display", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + renderMessage("x".repeat(120)); + const disclosure = screen.getByTestId(EXPAND_TEST_ID); + disclosure.style.display = "inline-grid"; + + geometryReads = []; + emitWindowResize(); + + expect(geometryReads).toEqual(["none", "none"]); + expect(disclosure.style.display).toBe("inline-grid"); + }); + + it("restores disclosure display when a geometry getter throws", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + renderMessage("x".repeat(120)); + const disclosure = screen.getByTestId(EXPAND_TEST_ID); + disclosure.style.display = "inline-flex"; + throwOnScrollHeight = true; + + expect(() => latestObserver().emit()).toThrow("geometry failed"); + expect(disclosure.style.display).toBe("inline-flex"); + }); + + it("retains the collapsed cap while expanded and collapses when natural content fits it", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + renderMessage("x".repeat(120)); + fireEvent.click(screen.getByTestId(EXPAND_TEST_ID)); + expect(screen.getByTestId(PREVIEW_TEST_ID).getAttribute("data-expanded")).toBe("true"); + + geometry = () => ({ scrollHeight: 40, clientHeight: 40 }); + emitWindowResize(); + + expect(screen.getByTestId(PREVIEW_TEST_ID).getAttribute("data-expanded")).toBe("false"); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + }); + + it("moves focus to remove when resize hides the focused disclosure", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + renderMessage("x".repeat(120)); + const disclosure = screen.getByTestId(EXPAND_TEST_ID); + const remove = screen.getByTestId(REMOVE_TEST_ID); + disclosure.focus(); + expect(document.activeElement).toBe(disclosure); + + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + emitWindowResize(); + + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + expect(document.activeElement).toBe(remove); + }); +}); + +describe("queued message rendered overflow signals and fallbacks", () => { + it("remeasures from additive observer, window, and visual viewport signals", () => { + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + renderMessage("x".repeat(120)); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + emitWindowResize(); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + act(() => visualViewport.emitResize()); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + act(() => latestObserver().emit()); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); + + it("remeasures when an asynchronous preview descendant loads", () => { + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + renderMessage("x".repeat(120)); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + const image = document.createElement("img"); + screen.getByTestId(PREVIEW_TEST_ID).append(image); + + act(() => image.dispatchEvent(new Event("load"))); + + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); + + it("remeasures when web fonts settle", () => { + const fonts = new FontFaceSetStub(); + Object.defineProperty(document, "fonts", { + configurable: true, + value: fonts, + }); + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + renderMessage("x".repeat(120)); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + act(() => document.fonts.dispatchEvent(new Event("loadingdone"))); + + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); + + it("does not read the settled font promise", () => { + const fonts = new FontFaceSetStub(); + fonts.status = "loaded"; + Object.defineProperty(document, "fonts", { + configurable: true, + value: fonts, + }); + + renderMessage("x".repeat(120)); + + expect(fonts.readyReads).toBe(0); + }); + + it("uses initial and viewport signals when ResizeObserver is unavailable or non-callable", () => { + vi.stubGlobal("ResizeObserver", undefined); + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + const view = renderMessage("x".repeat(120)); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + emitWindowResize(); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + + view.unmount(); + vi.stubGlobal("ResizeObserver", {}); + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + renderMessage("short"); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + act(() => visualViewport.emitResize()); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + }); + + it("ignores observer batches that do not contain the captured preview", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + renderMessage("x".repeat(120)); + geometryReads = []; + + act(() => latestObserver().emit([document.createElement("div")])); + + expect(geometryReads).toEqual([]); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); +}); + +describe("queued message rendered overflow stale callback guards", () => { + it("ignores callbacks from an earlier generation on the same preview element", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + const view = renderMessage("x".repeat(120)); + const preview = screen.getByTestId(PREVIEW_TEST_ID); + const oldObserver = latestObserver(); + + view.rerender( + undefined} + onRemove={() => undefined} + />, + ); + expect(screen.getByTestId(PREVIEW_TEST_ID)).toBe(preview); + geometryReads = []; + act(() => oldObserver.emit([preview])); + + expect(geometryReads).toEqual([]); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); + + it("ignores callbacks captured for a replaced preview element", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + const view = renderMessage("x".repeat(120), "first"); + const oldPreview = screen.getByTestId(PREVIEW_TEST_ID); + const oldObserver = latestObserver(); + + view.rerender( + undefined} + onRemove={() => undefined} + />, + ); + expect(screen.getByTestId(PREVIEW_TEST_ID)).not.toBe(oldPreview); + geometryReads = []; + act(() => oldObserver.emit([oldPreview])); + + expect(geometryReads).toEqual([]); + expect(screen.getByTestId(EXPAND_TEST_ID)).toBeTruthy(); + }); + + it("invalidates callbacks before teardown and keeps every callback stale after unmount", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + const view = renderMessage("x".repeat(120)); + fireEvent.click(screen.getByTestId(EXPAND_TEST_ID)); + const observer = latestObserver(); + const preview = screen.getByTestId(PREVIEW_TEST_ID); + const capturedWindowListeners = [...windowResizeListeners]; + const capturedViewportListeners = [...visualViewport.resizeListeners]; + observer.invokeDuringDisconnect = true; + visualViewport.invokeDuringRemove = true; + const originalRemove = window.removeEventListener.bind(window); + vi.spyOn(window, "removeEventListener").mockImplementation((type, listener, options) => { + if (type === "resize") invokeListener(listener); + originalRemove(type, listener, options); + }); + geometryReads = []; + + view.unmount(); + + expect(geometryReads).toEqual([]); + act(() => { + observer.emit([preview]); + capturedWindowListeners.forEach(invokeListener); + capturedViewportListeners.forEach(invokeListener); + }); + expect(geometryReads).toEqual([]); + expect(screen.queryByTestId(PREVIEW_TEST_ID)).toBeNull(); + }); +}); + +describe("queued message rendered overflow reset and styling", () => { + it("resets disclosure and expansion when preview content disappears", () => { + geometry = () => ({ scrollHeight: 80, clientHeight: 44 }); + const view = renderMessage("x".repeat(120)); + fireEvent.click(screen.getByTestId(EXPAND_TEST_ID)); + + view.rerender( + undefined} + onRemove={() => undefined} + />, + ); + expect(screen.queryByTestId(PREVIEW_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(EXPAND_TEST_ID)).toBeNull(); + + geometry = () => ({ scrollHeight: 44, clientHeight: 44 }); + view.rerender( + undefined} + onRemove={() => undefined} + />, + ); + expect(screen.getByTestId(PREVIEW_TEST_ID).getAttribute("data-expanded")).toBe("false"); + }); + + it("does not animate maximum-height changes", () => { + geometry = () => ({ scrollHeight: 45, clientHeight: 44 }); + renderMessage("short"); + + expect(screen.getByTestId(PREVIEW_TEST_ID).className).not.toContain("transition-[max-height]"); + }); +}); diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx index 531caa011b..3a55afc661 100644 --- a/apps/web/components/task/chat/queued-ghost-message.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.tsx @@ -40,6 +40,7 @@ import { } from "@/lib/entity-references/message-references"; import { buildEntityReferenceMarkdownComponents } from "@/components/task/chat/messages/entity-reference-chip"; import { QueuedGhostRowActions } from "@/components/task/chat/queued-ghost-row-actions"; +import { useQueuedMessageOverflow } from "@/components/task/chat/use-queued-message-overflow"; import { AttachmentRow, type QueuedAttachment } from "@/components/task/chat/queued-attachment-row"; import { t } from "@/lib/i18n"; import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard"; @@ -260,20 +261,10 @@ type DisplayViewProps = { sendNowDisabled: boolean; }; -/** Rough threshold above which we offer a per-row expand toggle. Two lines of - * the row's text width fit ~80 chars OR any explicit newline implies overflow, - * so we use either signal to surface the chevron — short multi-line messages - * (lists, code blocks) would otherwise be silently truncated. */ -const EXPAND_THRESHOLD = 80; - function queuePositionLabel(index: number | undefined, position: number | undefined): string { return `#${index === undefined ? (position ?? 1) : index + 1}`; } -function shouldOfferExpand(text: string): boolean { - return text.length > EXPAND_THRESHOLD || text.includes("\n"); -} - function DisplayView({ entry, entityReferences, @@ -293,7 +284,11 @@ function DisplayView({ const senderTask = getSenderTaskInfo(entry); const workflowMessage = getWorkflowMessageInfo(entry); const [expanded, setExpanded] = useState(false); - const canExpand = shouldOfferExpand(visible); + const { previewRef, disclosureButtonRef, canExpand } = useQueuedMessageOverflow( + visible, + expanded, + setExpanded, + ); const referenceMarkdownComponents = useMemo( () => buildEntityReferenceMarkdownComponents(entityReferences), [entityReferences], @@ -317,12 +312,12 @@ function DisplayView({ {senderTask && } {visible && (
*:first-child]:mt-0 [&>*:last-child]:mb-0", - "transition-[max-height] duration-200 ease-out motion-reduce:transition-none", expanded ? "max-h-[40rem]" : "max-h-[2.75rem]", )} > @@ -335,6 +330,7 @@ function DisplayView({
; expanded: boolean; canMerge: boolean; canEdit: boolean; @@ -24,8 +26,58 @@ export type QueuedGhostRowActionsProps = { sendNowDisabled: boolean; }; +function assignRef(ref: Ref, element: HTMLButtonElement | null): void { + if (typeof ref === "function") ref(element); + else if (ref) ref.current = element; +} + +function useDisclosureFocusRecovery( + canExpand: boolean, + disclosureButtonRef: Ref, +) { + const disclosureElementRef = useRef(null); + const removeElementRef = useRef(null); + const disclosureWasFocusedRef = useRef(false); + const focusRemoveAfterDisclosureRemovalRef = useRef(false); + const disclosureRef = useCallback( + (element: HTMLButtonElement | null) => { + const previousElement = disclosureElementRef.current; + if ( + element === null && + previousElement && + (document.activeElement === previousElement || disclosureWasFocusedRef.current) + ) { + focusRemoveAfterDisclosureRemovalRef.current = true; + } + disclosureElementRef.current = element; + assignRef(disclosureButtonRef, element); + }, + [disclosureButtonRef], + ); + const onDisclosureFocus = useCallback(() => { + disclosureWasFocusedRef.current = true; + }, []); + const onDisclosureBlur = useCallback((event: FocusEvent) => { + if (event.currentTarget.isConnected) disclosureWasFocusedRef.current = false; + }, []); + + useLayoutEffect(() => { + if (canExpand || !focusRemoveAfterDisclosureRemovalRef.current) return; + removeElementRef.current?.focus(); + focusRemoveAfterDisclosureRemovalRef.current = false; + }, [canExpand]); + + return { + disclosureRef, + removeRef: removeElementRef, + onDisclosureFocus, + onDisclosureBlur, + }; +} + export function QueuedGhostRowActions({ canExpand, + disclosureButtonRef, expanded, canMerge, canEdit, @@ -38,8 +90,12 @@ export function QueuedGhostRowActions({ sendNowDisabled, }: QueuedGhostRowActionsProps) { const { t } = useTranslation(); + const { disclosureRef, removeRef, onDisclosureFocus, onDisclosureBlur } = + useDisclosureFocusRecovery(canExpand, disclosureButtonRef); + return (
- {canExpand && ( - - )} {canMerge && onMerge && ( )} - {canRemove && ( + {canExpand && ( + )} + {canRemove && ( + )}
diff --git a/apps/web/components/task/chat/use-queued-message-overflow.ts b/apps/web/components/task/chat/use-queued-message-overflow.ts new file mode 100644 index 0000000000..6ba7c336b1 --- /dev/null +++ b/apps/web/components/task/chat/use-queued-message-overflow.ts @@ -0,0 +1,116 @@ +import { + type Dispatch, + type RefCallback, + type SetStateAction, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; + +type QueuedMessageOverflow = { + previewRef: RefCallback; + disclosureButtonRef: RefCallback; + canExpand: boolean; +}; + +/** Measures a collapsed queue preview at the width available without its optional disclosure. */ +export function useQueuedMessageOverflow( + visible: string, + expanded: boolean, + setExpanded: Dispatch>, +): QueuedMessageOverflow { + const [previewElement, setPreviewElement] = useState(null); + const [canExpand, setCanExpand] = useState(false); + const previewIdentityRef = useRef(null); + const disclosureIdentityRef = useRef(null); + const collapsedCapRef = useRef(0); + const generationRef = useRef(0); + + const previewRef = useCallback>((element) => { + previewIdentityRef.current = element; + setPreviewElement(element); + }, []); + const disclosureButtonRef = useCallback>((element) => { + disclosureIdentityRef.current = element; + }, []); + + useLayoutEffect(() => { + const generation = generationRef.current + 1; + generationRef.current = generation; + const preview = previewElement; + const isCurrent = () => + generationRef.current === generation && previewIdentityRef.current === preview; + + if (!preview || visible.length === 0) { + collapsedCapRef.current = 0; + setCanExpand(false); + setExpanded(false); + return () => { + if (generationRef.current === generation) generationRef.current += 1; + }; + } + + const measure = () => { + if (!isCurrent()) return; + const disclosure = disclosureIdentityRef.current; + const previousDisplay = disclosure?.style.display; + let scrollHeight: number; + let clientHeight: number; + try { + if (disclosure) disclosure.style.display = "none"; + scrollHeight = preview.scrollHeight; + clientHeight = preview.clientHeight; + } finally { + if (disclosure) disclosure.style.display = previousDisplay ?? ""; + } + if (!isCurrent()) return; + + if (expanded) { + const overflowsCollapsedCap = scrollHeight > collapsedCapRef.current; + setCanExpand(overflowsCollapsedCap); + if (!overflowsCollapsedCap) setExpanded(false); + return; + } + + collapsedCapRef.current = clientHeight; + setCanExpand(scrollHeight > clientHeight); + }; + + const handleWindowResize = () => measure(); + const visualViewport = window.visualViewport; + const handleAsyncContent = () => measure(); + const fonts = document.fonts; + + preview.addEventListener("load", handleAsyncContent, true); + fonts?.addEventListener("loadingdone", handleAsyncContent); + if (fonts && fonts.status !== "loaded") { + void fonts.ready.then(handleAsyncContent); + } + + measure(); + window.addEventListener("resize", handleWindowResize); + visualViewport?.addEventListener("resize", handleWindowResize); + + const ResizeObserverConstructor = window.ResizeObserver; + const observer = + typeof ResizeObserverConstructor === "function" + ? new ResizeObserverConstructor((entries) => { + if (!isCurrent() || !entries.some((entry) => entry.target === preview)) return; + measure(); + }) + : null; + observer?.observe(preview); + + return () => { + if (generationRef.current === generation) generationRef.current += 1; + window.removeEventListener("resize", handleWindowResize); + visualViewport?.removeEventListener("resize", handleWindowResize); + preview.removeEventListener("load", handleAsyncContent, true); + fonts?.removeEventListener("loadingdone", handleAsyncContent); + observer?.disconnect(); + }; + }, [expanded, previewElement, setExpanded, visible]); + + return { previewRef, disclosureButtonRef, canExpand }; +} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 64f41aacc2..aa873a5acb 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -2707,6 +2707,16 @@ export class ApiClient { return this.wsRequest("message.queue.get", { session_id: sessionId }); } + async setQueueAutoRun( + sessionId: string, + enabled: boolean, + ): Promise<{ session_id: string; auto_run: boolean; dispatched: boolean }> { + return this.wsRequest("message.queue.auto_run.set", { + session_id: sessionId, + enabled, + }); + } + // --- Integration config seeding (real API, not mock) --- async setJiraConfig(payload: { diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts index 7b43dcca57..bacbf80464 100644 --- a/apps/web/e2e/tests/chat/message-queue.spec.ts +++ b/apps/web/e2e/tests/chat/message-queue.spec.ts @@ -4,6 +4,7 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy"; import { SessionPage } from "../../pages/session-page"; +import { seedRunningGeneratingSession } from "../../helpers/generating-session"; import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers"; import { waitForQuickChatComposerReady } from "./quick-chat-helpers"; import { @@ -739,3 +740,138 @@ test.describe("Queue affordance", () => { await expect(testPage.getByTestId("queue-chip")).not.toBeVisible({ timeout: 5_000 }); }); }); + +async function readQueuePreviewMetricsWithoutDisclosure( + preview: Locator, +): Promise<{ clientHeight: number; scrollHeight: number }> { + return preview.evaluate((element) => { + const disclosure = element + .closest('[data-testid="queue-entry"]') + ?.querySelector('[data-testid="queue-entry-expand"]'); + const previousDisplay = disclosure?.style.display; + try { + if (disclosure) disclosure.style.display = "none"; + return { + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + } finally { + if (disclosure) disclosure.style.display = previousDisplay ?? ""; + } + }); +} + +test.describe("Queued row controls", () => { + test.describe.configure({ timeout: 120_000 }); + + // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9 + // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.10 + test("adapts queued row controls to rendered overflow", async ({ + testPage, + apiClient, + seedData, + }) => { + const fixture = "adaptive-width-probe ".repeat(12).trim(); + await testPage.setViewportSize({ width: 1280, height: 900 }); + const { session, taskId, sessionId } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Adaptive queued row controls", + { sleepSeconds: 60 }, + ); + await testPage.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect(testPage.getByTestId("app-sidebar")).toHaveAttribute("data-collapsed", "true"); + const chatMaximize = testPage + .locator( + '.dv-groupview:has([data-testid^="session-tab-"]) .dv-tabs-and-actions-container [data-testid="dockview-maximize-btn"]', + ) + .first(); + await expect(chatMaximize).toBeVisible(); + await chatMaximize.click(); + const autoRunResponse = await apiClient.setQueueAutoRun(sessionId, false); + expect(autoRunResponse).toMatchObject({ session_id: sessionId, auto_run: false }); + await waitForComposerQueueMode(testPage); + await apiClient.queueMessage(taskId, sessionId, fixture); + await expect + .poll(() => apiClient.getQueueStatus(sessionId)) + .toMatchObject({ count: 1, auto_run: false }); + + const chat = session.activeChat(); + await chat.getByTestId("queue-chip").click(); + const panel = chat.getByTestId("queued-ghost-list"); + const row = panel.getByTestId("queue-entry").filter({ hasText: fixture }); + const preview = row.getByTestId("queue-entry-text"); + const actions = row.getByTestId("queue-entry-actions"); + const remove = row.getByTestId("queue-entry-remove"); + await expect(row).toBeVisible(); + await expect + .poll(async () => { + const metrics = await readQueuePreviewMetricsWithoutDisclosure(preview); + return metrics.scrollHeight === metrics.clientHeight; + }) + .toBe(true); + await expect(row.getByTestId("queue-entry-expand")).toHaveCount(0); + + await testPage.setViewportSize({ width: 800, height: 900 }); + await expect + .poll(async () => { + const metrics = await readQueuePreviewMetricsWithoutDisclosure(preview); + return metrics.scrollHeight > metrics.clientHeight; + }) + .toBe(true); + const expand = row.getByTestId("queue-entry-expand"); + await expect(expand).toBeVisible(); + + await testPage.mouse.move(0, 0); + await expect(actions).toHaveCSS("opacity", "0"); + await row.hover(); + await expect(actions).toHaveCSS("opacity", "1"); + expect(await testPage.evaluate(() => matchMedia("(pointer: fine)").matches)).toBe(true); + await expect(remove).toHaveAttribute("title", "Remove queued message"); + await expect( + row.getByRole("button", { name: "Remove queued message", exact: true }), + ).toHaveCount(1); + await expect(remove.locator("svg")).toHaveClass(/tabler-icon-trash/); + const order = await actions.evaluate((container) => { + const direct = [...container.children] as HTMLElement[]; + const disclosure = direct.find((element) => element.dataset.testid === "queue-entry-expand"); + const terminal = direct.at(-1); + return { + adjacent: disclosure?.nextElementSibling === terminal, + terminalTestId: (terminal as HTMLElement | undefined)?.dataset.testid, + }; + }); + expect(order).toEqual({ adjacent: true, terminalTestId: "queue-entry-remove" }); + + const colors = await remove.evaluate((button) => { + const muted = document.createElement("span"); + muted.className = "text-muted-foreground"; + const destructive = document.createElement("span"); + destructive.className = "text-destructive"; + button.parentElement?.append(muted, destructive); + const values = { + muted: getComputedStyle(muted).color, + destructive: getComputedStyle(destructive).color, + }; + muted.remove(); + destructive.remove(); + return values; + }); + await expect(remove).toHaveCSS("color", colors.muted); + await remove.hover(); + await expect(remove).toHaveCSS("color", colors.destructive); + + await testPage.setViewportSize({ width: 1280, height: 900 }); + await expect + .poll(async () => { + const metrics = await readQueuePreviewMetricsWithoutDisclosure(preview); + return metrics.scrollHeight === metrics.clientHeight; + }) + .toBe(true); + await expect(row.getByTestId("queue-entry-expand")).toHaveCount(0); + + await remove.click(); + await expect(row).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts index d701eff6fb..783c366ea9 100644 --- a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts @@ -7,6 +7,7 @@ import { SessionPage } from "../../pages/session-page"; import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers"; import { registerSeparateQueueRows } from "../../helpers/message-queue-settings"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store"; registerSeparateQueueRows(test); @@ -200,3 +201,167 @@ test("mobile queue panel hides the desktop-only pin and keeps its controls", asy await expectTouchTarget(panel.getByTestId("queue-clear-all")); await expectTouchTarget(panel.getByTestId("queue-close")); }); + +test.describe("Mobile queued row controls", () => { + test.describe.configure({ timeout: 120_000 }); + + // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9 + // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.10 + test("keeps queued row controls ordered and touchable", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const fixture = "mobile-overflow-probe ".repeat(24).trim(); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile queued row controls", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.sendMessageViaButton("/sleep 60"); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await waitForActiveSessionForegroundActivity(testPage, "generating"); + const autoRunResponse = await apiClient.setQueueAutoRun(task.session_id, false); + expect(autoRunResponse).toMatchObject({ + session_id: task.session_id, + auto_run: false, + }); + await waitForComposerQueueMode(testPage); + await apiClient.queueMessage(task.id, task.session_id, fixture); + await expect + .poll(() => apiClient.getQueueStatus(task.session_id!)) + .toMatchObject({ count: 1, auto_run: false }); + + const chat = session.activeChat(); + await chat.getByTestId("queue-chip").tap(); + const panel = chat.getByTestId("queued-ghost-list"); + const scrollRegion = panel.getByTestId("queue-scroll-region"); + const row = scrollRegion.getByTestId("queue-entry").filter({ hasText: fixture }); + const preview = row.getByTestId("queue-entry-text"); + const actions = row.getByTestId("queue-entry-actions"); + const expand = row.getByTestId("queue-entry-expand"); + const remove = row.getByTestId("queue-entry-remove"); + await expect(row).toBeVisible(); + + const previewMetrics = await preview.evaluate((element) => ({ + clientHeight: element.clientHeight, + clientWidth: element.clientWidth, + scrollHeight: element.scrollHeight, + scrollWidth: element.scrollWidth, + })); + expect(previewMetrics.scrollHeight).toBeGreaterThan(previewMetrics.clientHeight); + await expect(expand).toBeVisible(); + await expect(actions).toHaveCSS("opacity", "1"); + expect(await testPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); + expect(await testPage.evaluate(() => matchMedia("(pointer: fine)").matches)).toBe(false); + await expect(remove).toHaveAttribute("title", "Remove queued message"); + await expect( + row.getByRole("button", { name: "Remove queued message", exact: true }), + ).toHaveCount(1); + await expect(remove.locator("svg")).toHaveClass(/tabler-icon-trash/); + const order = await actions.evaluate((container) => { + const direct = [...container.children] as HTMLElement[]; + const disclosure = direct.find((element) => element.dataset.testid === "queue-entry-expand"); + const terminal = direct.at(-1); + return { + adjacent: disclosure?.nextElementSibling === terminal, + terminalTestId: (terminal as HTMLElement | undefined)?.dataset.testid, + }; + }); + expect(order).toEqual({ adjacent: true, terminalTestId: "queue-entry-remove" }); + + const mutedColor = await remove.evaluate((button) => { + const muted = document.createElement("span"); + muted.className = "text-muted-foreground"; + button.parentElement?.append(muted); + const color = getComputedStyle(muted).color; + muted.remove(); + return color; + }); + await expect(remove).toHaveCSS("color", mutedColor); + + const documentWidths = await testPage.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(documentWidths.scrollWidth).toBe(documentWidths.clientWidth); + await expect(scrollRegion).toHaveCSS("overflow-y", "auto"); + const verticalScrollOwners = await panel.evaluate((element) => + [element, ...element.querySelectorAll("*")] + .filter((candidate) => { + const overflowY = getComputedStyle(candidate).overflowY; + return overflowY === "auto" || overflowY === "scroll"; + }) + .map((candidate) => (candidate as HTMLElement).dataset.testid ?? null), + ); + expect(verticalScrollOwners).toEqual(["queue-scroll-region"]); + + const [panelBox, scrollBox, rowBox, previewBox, actionsBox, expandBox, removeBox] = + await Promise.all([ + panel.boundingBox(), + scrollRegion.boundingBox(), + row.boundingBox(), + preview.boundingBox(), + actions.boundingBox(), + expand.boundingBox(), + remove.boundingBox(), + ]); + expect(panelBox).not.toBeNull(); + expect(scrollBox).not.toBeNull(); + expect(rowBox).not.toBeNull(); + expect(previewBox).not.toBeNull(); + expect(actionsBox).not.toBeNull(); + expect(expandBox).not.toBeNull(); + expect(removeBox).not.toBeNull(); + const viewportWidth = testPage.viewportSize()!.width; + const EPSILON = 1; + expect(panelBox!.x).toBeGreaterThanOrEqual(-EPSILON); + expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(viewportWidth + EPSILON); + expect(scrollBox!.x).toBeGreaterThanOrEqual(panelBox!.x - EPSILON); + expect(scrollBox!.x + scrollBox!.width).toBeLessThanOrEqual( + panelBox!.x + panelBox!.width + EPSILON, + ); + expect(rowBox!.x).toBeGreaterThanOrEqual(scrollBox!.x - EPSILON); + expect(rowBox!.x + rowBox!.width).toBeLessThanOrEqual( + scrollBox!.x + scrollBox!.width + EPSILON, + ); + expect(previewBox!.x).toBeGreaterThanOrEqual(rowBox!.x - EPSILON); + expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual( + rowBox!.x + rowBox!.width + EPSILON, + ); + expect(actionsBox!.x).toBeGreaterThanOrEqual(rowBox!.x - EPSILON); + expect(actionsBox!.x + actionsBox!.width).toBeLessThanOrEqual( + rowBox!.x + rowBox!.width + EPSILON, + ); + expect(expandBox!.x).toBeGreaterThanOrEqual(actionsBox!.x - EPSILON); + expect(expandBox!.x + expandBox!.width).toBeLessThanOrEqual( + actionsBox!.x + actionsBox!.width + EPSILON, + ); + expect(removeBox!.x).toBeGreaterThanOrEqual(actionsBox!.x - EPSILON); + expect(removeBox!.x + removeBox!.width).toBeLessThanOrEqual( + actionsBox!.x + actionsBox!.width + EPSILON, + ); + expect(previewMetrics.scrollWidth).toBeLessThanOrEqual(previewMetrics.clientWidth + EPSILON); + await expectEffectiveTouchTarget(expand); + await expectEffectiveTouchTarget(remove); + + await prCapture.screenshot("queued-row-controls-pixel-5", { + caption: + "Pixel 5 queued row with adaptive disclosure immediately before the touch-sized Remove action", + }); + await remove.tap(); + await expect(row).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts b/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts index 92a715d257..a609fc5343 100644 --- a/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts +++ b/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts @@ -36,25 +36,35 @@ test("dragging into a feeder wakes an open pull target without reload", async ({ const sourceCard = kanban.taskCard(task.id); const feederColumn = kanban.columnByStepId(feederStep.id); const sourceBox = await sourceCard.boundingBox(); - const feederBox = await feederColumn.boundingBox(); expect(sourceBox).not.toBeNull(); - expect(feederBox).not.toBeNull(); - await testPage.mouse.move( - sourceBox!.x + sourceBox!.width / 2, - sourceBox!.y + sourceBox!.height / 2, - ); + const sourceX = sourceBox!.x + sourceBox!.width / 2; + const sourceY = sourceBox!.y + sourceBox!.height / 2; + await testPage.mouse.move(sourceX, sourceY); await testPage.mouse.down(); - await testPage.mouse.move( - sourceBox!.x + sourceBox!.width / 2 + 20, - sourceBox!.y + sourceBox!.height / 2, - ); + await testPage.mouse.move(sourceX + 20, sourceY); + await expect(testPage.getByTestId("desktop-kanban-drag-end-reserve")).toHaveCount(1); + + // Dragging can temporarily move destinations before the anchored source + // column, so scroll them back into the viewport before dropping. + let feederBox = await feederColumn.boundingBox(); + const scrollWindowBox = await testPage.getByTestId("desktop-kanban-scroll-window").boundingBox(); + expect(scrollWindowBox).not.toBeNull(); + if (feederBox && feederBox.x + feederBox.width <= 0) { + await testPage.mouse.move(scrollWindowBox!.x + 2, sourceY, { steps: 12 }); + await expect + .poll(async () => { + const box = await feederColumn.boundingBox(); + return box ? box.x + box.width / 2 : -1; + }) + .toBeGreaterThan(0); + feederBox = await feederColumn.boundingBox(); + } + expect(feederBox).not.toBeNull(); await testPage.mouse.move( feederBox!.x + feederBox!.width / 2, feederBox!.y + feederBox!.height / 2, - { - steps: 12, - }, + { steps: 12 }, ); await testPage.mouse.up(); diff --git a/docs/plans/message-queue-row-controls/plan.md b/docs/plans/message-queue-row-controls/plan.md new file mode 100644 index 0000000000..1f722280f7 --- /dev/null +++ b/docs/plans/message-queue-row-controls/plan.md @@ -0,0 +1,96 @@ +--- +created: 2026-09-04 +status: complete +requirements: + - REQ-UI-MESSAGE-QUEUE-MANAGEMENT-001 +system_design: + - ../../specs/ui/system-design/message-queue-row-controls.md +legacy_specs: [] +--- + +# Implementation Plan: Message Queue Row Controls + +## Overview + +Replace the queued-message disclosure's character-count heuristic with rendered overflow measurement, then align the disclosure and destructive action with the existing row-control language. One sequential work order owns the browser-first regression, production change, desktop and phone evidence, and required repository checks because the behavior and files are coupled. + +## Scope + +### In scope + +- Hide the expand/collapse control when the collapsed queued-message preview does not overflow. +- Re-evaluate disclosure availability after content, asynchronous descendant-resource or font completion, viewport, `ResizeObserver`-reported panel-width, or browser-zoom layout changes. +- Render the disclosure immediately before Remove when both actions are present. +- Replace Remove's close glyph with the shared trash glyph and apply the destructive color on fine-pointer hover. +- Preserve desktop hover disclosure, coarse-pointer visibility, 44 by 44 CSS-pixel touch targets, action permissions, action handlers, and accessible copy. +- Add focused measurement-lifecycle tests plus desktop and Pixel 5 Playwright evidence for the changed behavior. + +### Out of scope + +- Production queue persistence, transport, ordering, provenance, authorization, removal semantics, merge rules, or Auto-run behavior. E2E setup may call the existing Auto-run operation through a test API-client wrapper; that adds no production contract. +- Queue panel composition, scroll ownership, safe-area handling, and responsive breakpoints. +- New or changed user-facing copy, public documentation, settings, or backend code. +- Refactoring the separate anchored-last-prompt disclosure beyond reusing its measurement pattern. + +## Technical approach + +### Adaptive disclosure + +Remove `EXPAND_THRESHOLD` and `shouldOfferExpand`. `DisplayView` retains expanded state/rendering and delegates measurement to `useQueuedMessageOverflow`, which returns preview and disclosure-button refs plus `canExpand`. Pass the disclosure ref through `QueuedGhostRowActions` and add `data-testid="queue-entry-actions"`. + +Every fit probe measures with the optional disclosure omitted from layout: save its inline `display`, set it to `none`, read preview geometry synchronously, and restore the exact value in `finally` before state publication. No button means direct measurement. This avoids a self-induced fixed point without a state-driven two-pass render or permanent empty slot. Collapsed measurements update cap before publication; expanded measurements use that cap. Remove max-height transition and reset absent content. + +Each lifecycle captures generation and preview identity. Cleanup invalidates first. Callbacks guard before reads/publication; observer batches require target identity. Observer support uses a callable-value check. Window, available visual-viewport, capture-phase descendant load, and document font-completion signals remain additive; fallback is initial plus viewport only. + +Keep collapsed/expanded maximum heights. The collapsed height measured at no-disclosure width is the fit source of truth. + +### Action order and destructive cue + +In `apps/web/components/task/chat/queued-ghost-row-actions.tsx`, replace `IconX` with `IconTrash` and move disclosure immediately before Remove. Set Remove to `variant={null}` because ghost injects competing hover text. Restore `hover:bg-muted dark:hover:bg-muted/50` locally and use only `text-muted-foreground` plus `[@media(pointer:fine)]:hover:text-destructive`. The `:hover` pseudo-class still requires hover, while the media variant additionally requires a fine primary pointer. Retain title, test ID, handler, and coarse dimensions. + +### Browser regressions + +Extend `apps/web/e2e/helpers/api-client.ts` with `setQueueAutoRun(sessionId, enabled)`, a typed wrapper around `message.queue.auto_run.set` that returns `{ session_id, auto_run, dispatched }`. Place the new desktop test in a `test.describe` suite configured with `test.describe.configure({ timeout: 120_000 })`, so its deadline applies before test-scoped fixtures initialize. Use the existing `seedRunningGeneratingSession` pattern with a 60-second turn and await `waitForActiveSessionForegroundActivity(..., "generating")`. Before target admission, call `setQueueAutoRun(sessionId, false)` and require its authoritative response to report `auto_run === false`; then wait for composer queue mode, admit the target, and poll `message.queue.get` until Auto-run remains false with the expected pending count. Open the panel and confirm the target row before reading geometry. Use the exact target text `"adaptive-width-probe ".repeat(12).trim()` and viewport widths 1280, 800, then 1280 CSS pixels. At each width, assert the geometry precondition before testing disclosure: equality wide, overflow narrow, and equality wide again. Those preconditions must pass in RED; expected failure belongs to disclosure state, adjacency, icon, hover, or removal behavior. Auto-run preserves the row through pre-removal assertions; invoke Remove last and assert disappearance. + +Place the new Pixel 5 test in a suite configured with `test.describe.configure({ timeout: 120_000 })` before fixtures initialize. Establish the 60-second generating-turn invariant by calling `sendMessageViaButton("/sleep 60")`, not the keyboard-submit path used by `seedRunningGeneratingSession`, then await `waitForActiveSessionForegroundActivity(..., "generating")`. Follow the same authoritative Auto-run-response, queue-mode, admission, and queue-status sequence before geometry. Queue the exact target text `"mobile-overflow-probe ".repeat(24).trim()`, confirm the row, and assert its `scrollHeight > clientHeight` precondition before checking disclosure. The expected RED failure belongs to disclosure state, adjacency, trash glyph, target sizing, containment, or removal behavior rather than fixture construction. Invoke Remove after the pre-removal checks and assert the row disappears. + +## Tests + +- **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9:** exact opposing fixtures guarantee RED. A boundary case starts overflow with disclosure present, then uses geometry that overflows only when disclosure participates but fits when its inline display is `none`; after a signal disclosure must disappear and remain absent. Spies require hide-before-read and exact restoration in `finally`, including when geometry throws. Lifecycle, fallback, guard, cleanup-order, and unmount cases remain. +- **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9:** desktop exact-title E2E proves wide/narrow/wide response at no-control fit semantics. +- **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.10:** both tests assert direct adjacency. Pixel 5 defines the exact locator chain. Document widths are equal; scroll region has `overflowY === "auto"`; evaluating panel plus all descendants, filtering computed `auto|scroll` overflow-Y, and mapping `data-testid` yields exactly `["queue-scroll-region"]`. With `EPSILON = 1`, explicit horizontal inequalities prove panel within viewport, scroll region/row/preview/actions within their named parent, and both Expand and Remove within actions. Preview content width fits and expand/Remove effective width and height each meet 44 pixels. Media, icon, accessibility, color, capture, and removal checks remain. + +## E2E tests + +- **Desktop Chromium:** exact test proves no-control-width response and direct DOM adjacency: disclosure's next element sibling is terminal Remove. +- **Mobile Chrome / Pixel 5:** exact test proves the same adjacency before retained screenshot, plus touch, geometry, icon, accessibility, and removal contracts. + +## Work orders + +- [x] [Task 01: Refine queued row controls](task-01-refine-queued-row-controls.md) + +## Verification results + +- Completed the rendered-overflow hook, row-action ordering/presentation, + focused lifecycle regressions, desktop Chromium regression, and Pixel 5 + geometry/touch regression. +- Focused GREEN evidence: 14 Vitest tests passed; both exact-title Playwright + scenarios passed. +- The retained Pixel 5 capture and manifest entry confirm disclosure directly + before terminal Remove without hierarchy, clipping, overlap, spacing, or + phone-composition defects. +- Formatting, typecheck, and lint passed. The combined repository command + reached unrelated host/runtime failures in backend tool discovery, web + Docker/time-limited tests, and the script suite's unavailable `unzip`; + affected focused checks passed. + +## Risks + +- Queue entries can drain as soon as the active turn completes. Browser scenarios must establish a slow running turn, wait for queue-mode readiness, disable Auto-run where needed, and confirm the row before geometry work. +- Optional disclosure can shrink its flex-sibling preview and justify itself. Every probe temporarily removes the button from layout, restores inline display in `finally`, and decides from no-control geometry. A boundary regression makes participation the sole overflow cause. +- The preview's integer geometry can sit at an equality boundary. Use the established strict `scrollHeight > clientHeight` contract rather than a text-length or newline fallback. +- Observer disconnection does not cancel an already queued callback. Generation and identity guards reject callbacks before reads/publication after same-element replacement, element replacement, and unmount. Cleanup invalidates generation before listener removal and disconnect. +- Expanded content uses a different maximum height. Losing the collapsed cap would make every expanded row appear to fit and remove its only collapse path. +- Browser E2E must seed text that demonstrably fits wide and overflows narrow. Assertions should check the measured geometry before checking control state rather than rely on a fixed character count alone. +- Browser zoom is not directly driven by Playwright. Observer-present tests dispatch additive signals. Fallback stubs `ResizeObserver` undefined and asserts construction/observe never occurs while initial and viewport remeasurement work, requiring callable-value detection rather than `"ResizeObserver" in window`; optional visual-viewport absence and cleanup are also covered. +- Fresh worktrees require `pnpm install --frozen-lockfile` from `apps/` before any pnpm or Playwright command. diff --git a/docs/plans/message-queue-row-controls/task-01-refine-queued-row-controls.md b/docs/plans/message-queue-row-controls/task-01-refine-queued-row-controls.md new file mode 100644 index 0000000000..6c5b9872a9 --- /dev/null +++ b/docs/plans/message-queue-row-controls/task-01-refine-queued-row-controls.md @@ -0,0 +1,152 @@ +--- +id: "01-refine-queued-row-controls" +title: "Refine queued row controls" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-MESSAGE-QUEUE-MANAGEMENT-001 +acceptance_criteria: + - AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9 + - AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.10 +system_design: + - ../../specs/ui/system-design/message-queue-row-controls.md +--- + +# Task 01: Refine Queued Row Controls + +## Summary + +Make queued-message disclosure depend on rendered overflow and remain responsive to live layout changes. Align the disclosure and Remove controls with the shared action-row order, iconography, destructive hover cue, and phone touch behavior. + +## In scope + +- Add focused overflow tests. Include a boundary sequence that first establishes real overflow/disclosure, then changes geometry so only a layout-participating disclosure causes overflow. After a signal, disclosure disappears and stays absent. Assert the probe sets inline `display: none` before geometry reads and restores the exact prior value in `finally`, including a throwing getter. Retain exact opposing, signal, fallback, guard, cleanup-order, and unmount coverage. +- Add desktop exact-title E2E. Use `queue-entry-actions`; when disclosure appears, evaluate direct children and require its `nextElementSibling` to be `queue-entry-remove`, with Remove the final actionable child. Retain authoritative setup, fit/overflow/fit, opacity, role/name, title, color, and removal assertions. +- Add Pixel 5 exact-title E2E with exact panel, scroll-region, fixture-filtered row, preview, actions, expand, and Remove test-ID locators. Require document width equality and scroll-region `overflowY === "auto"`. Evaluate panel plus all descendants, filter computed overflow-Y `auto|scroll`, map `data-testid`, and require exactly `["queue-scroll-region"]`. With `EPSILON = 1`, use explicit left/right inequalities for panel within viewport, scroll region/row/preview/actions within their named parent, and both Expand and Remove within actions. Require preview `scrollWidth <= clientWidth + EPSILON` and effective expand/Remove width and height each at least 44. Retain media, adjacency, icon, accessibility, color, capture, and removal. +- Extract measurement to `use-queued-message-overflow.ts`. Return preview and disclosure-button refs plus `canExpand`; keep `DisplayView` state/rendering ownership. Pass the button ref through row actions and expose `data-testid="queue-entry-actions"`. +- Probe at no-disclosure width by saving inline `display`, setting it to `none`, reading geometry synchronously, and restoring exactly in `finally` before state updates. Guard callbacks/lifecycles as specified; use callable observer detection with additive viewport, descendant-load, and font-completion signals. +- Reorder disclosure as Remove's direct DOM predecessor; replace close with `IconTrash`; use `variant={null}`, local ghost background, `text-muted-foreground`, and `[@media(pointer:fine)]:hover:text-destructive`. +- Run the focused Vitest, desktop Playwright, and mobile Playwright tests, then the user-required repository checks. + +## Out of scope + +- Production backend, WebSocket, store, queue mutation, permission, merge, ordering, capacity, or Auto-run behavior changes. Tests may invoke the existing Auto-run operation through the typed E2E API-client wrapper. +- Queue-panel restructuring, new responsive breakpoints, drawers, menus, or scroll owners. +- Translation catalog or public-documentation changes. +- Generalizing or refactoring unrelated expandable content. + +## Acceptance + +- Component tests prove exact opposing geometry, self-induced-overflow escape, exact inline-style restoration including exceptions, signals, fallback, and every lifecycle guard. +- Both E2Es assert direct disclosure/Remove adjacency and terminal Remove. Desktop asserts fine-pointer media, muted idle color, then destructive hover color. Pixel 5 asserts coarse-pointer media, rejects fine-pointer media, and requires muted Remove color before tap. Accessibility, opacity, icon, geometry, and touch contracts remain. +- Pixel 5 uses the exact locator chain and `EPSILON = 1`. It proves exact document width, sole `auto|scroll` overflow-Y descendant ownership by `queue-scroll-region`, explicit nested left/right bounding-box containment through panel/scroll-region/row/preview/actions and for both Expand and Remove within actions, preview content width, and effective 44-by-44 minimums for Expand and Remove before retained screenshot and last-step removal. + +## Verification + +Bootstrap once if `apps/node_modules` is absent: + +```bash +(cd apps && pnpm install --frozen-lockfile) +``` + +RED, before editing production files: + +```bash +(cd apps && pnpm --filter @kandev/web test -- components/task/chat/queued-ghost-message-overflow.test.tsx) +(cd apps/web && pnpm e2e:run --project chromium tests/chat/message-queue.spec.ts -- --grep "adapts queued row controls to rendered overflow") +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts -- --grep "keeps queued row controls ordered and touchable") +``` + +All three commands must discover their intended tests and fail on the new behavioral assertions rather than setup, build, import, or timeout errors. + +GREEN, after the minimum production change, rerun all three commands: + +```bash +(cd apps && pnpm --filter @kandev/web test -- components/task/chat/queued-ghost-message-overflow.test.tsx) +(cd apps/web && pnpm e2e:run --project chromium tests/chat/message-queue.spec.ts -- --grep "adapts queued row controls to rendered overflow") +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts -- --grep "keeps queued row controls ordered and touchable") +``` + +After the normal Pixel 5 command is GREEN, generate retained visual evidence with `(cd apps/web && CAPTURE_PR_ASSETS=true pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts -- --grep "keeps queued row controls ordered and touchable")`. The test must destructure `prCapture` and invoke `prCapture.screenshot("queued-row-controls-pixel-5", { caption: "Pixel 5 queued row with adaptive disclosure immediately before the touch-sized Remove action" })` before removal. Require both `apps/web/.pr-assets/mobile-message-queue-management--queued-row-controls-pixel-5.png` and its entry in `apps/web/.pr-assets/manifest.json`; inspect the PNG and record that path plus concrete hierarchy, clipping, overlap, spacing, and phone-composition observations under Verification results. Missing files or manifest entry fail verification. + +Then run the required repository checks in this order from the repository root: + +```bash +make fmt +make typecheck test lint +``` + +Confirm final whitespace separately: + +```bash +git diff --check +``` + +## Files likely touched + +- `apps/web/components/task/chat/queued-ghost-message.tsx` +- `apps/web/components/task/chat/use-queued-message-overflow.ts` +- `apps/web/components/task/chat/queued-ghost-row-actions.tsx` +- `apps/web/components/task/chat/queued-ghost-message-overflow.test.tsx` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/tests/chat/message-queue.spec.ts` +- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` +- `docs/plans/message-queue-row-controls/plan.md` +- `docs/plans/message-queue-row-controls/task-01-refine-queued-row-controls.md` + +## Dependencies + +None. + +## Risks + +- A pending row can drain before geometry assertions. Both Playwright scenarios must use a suite-level `test.describe.configure({ timeout: 120_000 })` so the deadline covers fixture setup, establish a 60-second running turn, await `waitForActiveSessionForegroundActivity(..., "generating")`, set Auto-run false through `ApiClient.setQueueAutoRun` before target admission, assert its authoritative response, wait for composer queue mode, then queue the fixture, poll queue status for false plus the expected pending count, and confirm its row. Desktop may use `seedRunningGeneratingSession`; Pixel 5 must submit `/sleep 60` with `sendMessageViaButton` because keyboard submission is unsupported there. Exact fixture contents and geometry preconditions separate setup failure from behavioral RED. Causal/backend waits, rather than fixed delays, preserve the invariant through every pre-removal assertion. +- Stale-callback tests cover foreign target, same-element generation, replaced-element identity, and unmount. Invoke all captured callback types after unmount. Separately, make mocked listener removal and observer disconnect synchronously invoke captured callbacks during cleanup. Every path asserts no geometry getter, `setExpanded`, or render/state output, behaviorally proving invalidation happens before teardown calls. +- Expanded measurement suppresses disclosure and compares natural height against the stored collapsed no-control cap. Manual and automatic collapse refresh final geometry with no transition. +- The optional button can create a flex-width fixed point. Boundary tests make it the sole overflow cause and require removal. The probe must restore inline display in `finally`; no permanent blank slot or visible two-pass state is allowed. +- Signals are asynchronous and additive. Observer-present tests prove each. Capture-phase descendant loads and document font completion remeasure rendered content that changes without a preview-box resize. Fallback stubs `ResizeObserver` undefined, asserts no constructor or observe call, and proves initial/window/visual-viewport behavior; implementation uses callable-value detection. Optional visual viewport, cleanup, absent previews, and stale callbacks remain covered. +- Both the existing component and test files are near `max-lines`. Keep all new measurement logic in `use-queued-message-overflow.ts` and all new component coverage in `queued-ghost-message-overflow.test.tsx`; do not grow the near-limit files. +- Remove cannot retain ghost variant because it injects `hover:text-foreground`. Use `variant={null}`, local ghost backgrounds, and `[@media(pointer:fine)]:hover:text-destructive`. Tests prove destructive hover only with a fine primary pointer and muted color under Pixel 5 coarse-pointer media. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/ui/requirements/message-queue-management.md`, especially `AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9` and `.10`. +- `docs/specs/ui/system-design/message-queue-row-controls.md`. +- `apps/web/components/task/chat/anchored-last-prompt-bar.tsx` as the rendered-overflow observation exemplar. +- Existing row-action and mobile queue patterns in `queued-ghost-row-actions.tsx`, `queued-ghost-message.test.tsx`, and `mobile-message-queue-management.spec.ts`. + +## Results + +- RED: the focused Vitest command discovered all 14 intended tests; 13 new + behavioral assertions failed before production changes. The desktop and + Pixel 5 exact-title tests each reached their geometry preconditions and + failed on the missing adaptive disclosure/action-container contracts. +- GREEN: the focused Vitest command passed all 14 tests. The exact desktop + Chromium test and exact mobile-chrome Pixel 5 test each passed once. +- Extracted rendered-overflow measurement into + `use-queued-message-overflow.ts`, including no-control-width probing, exact + inline-style restoration, additive layout signals, collapsed-cap behavior, + and stale-callback guards. +- Review remediation: descendant resource loads and font completion now remeasure a preview whose box remains capped while rendered content grows; focused Vitest coverage passed all 16 cases. +- Reordered disclosure immediately before terminal Remove, switched Remove to + the trash icon, preserved muted idle presentation, and limited destructive + hover color to fine primary pointers. +- Retained Pixel 5 evidence at + `apps/web/.pr-assets/mobile-message-queue-management--queued-row-controls-pixel-5.png` + with a matching manifest entry. Inspection confirmed the preview/action + hierarchy, no viewport clipping or action overlap, usable control spacing, + and balanced phone composition. +- `make fmt`, `make typecheck`, and `make lint` passed. The combined + `make typecheck test lint` reached unrelated environment-sensitive failures: + the web suite passed 15,279 tests but reported 28 Docker-gateway/time-limit + failures across 7 unrelated files under Node 22; script tests require an + unavailable `unzip`; backend discovery/cache tests depended on the host + login profile and installed-tool path. Focused reruns passed after isolating + the relevant backend tool paths. +- `python3 scripts/lint-spec-files.py --all` and `git diff --check` passed. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 6ed14f5037..483c46a715 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -161,6 +161,7 @@ UI owns responsive behavior; other systems own behavior/state. - [File Tree Keyboard Scope](system-design/file-tree-keyboard-scope.md) - [File Tree Chat](system-design/file-tree-chat-context.md) - [MCP results](system-design/kandev-mcp-tool-results.md) +- [Message queue row controls](system-design/message-queue-row-controls.md) - [Mobile Task Chrome](system-design/mobile-task-chrome.md) - [Persistent status motion](system-design/persistent-status-motion.md) - [Repository Groups](system-design/sidebar-repository-grouping.md) diff --git a/docs/specs/ui/requirements/message-queue-management.md b/docs/specs/ui/requirements/message-queue-management.md index 69f3165da2..a891ee565f 100644 --- a/docs/specs/ui/requirements/message-queue-management.md +++ b/docs/specs/ui/requirements/message-queue-management.md @@ -27,6 +27,8 @@ Long-running tasks can receive messages faster than their active session can dra - **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.6:** An entry already reserved for durable lifecycle delivery is not visible and cannot be removed by these controls. Task archive/delete retains its separate privileged purge behavior. - **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.7:** **Settings > Task Behavior > Message Queue** exposes the maximum number of persisted messages allowed per session alongside independent manual and automatic merge switches. - **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.8:** The default is `10`. A positive integer sets a cap; `0` means unlimited. +- **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9:** When a queued-message preview fits within its collapsed rendered height, evaluated at the row width available while the optional disclosure control is omitted, the row shall omit the expand/collapse control. The control itself shall not change that fit decision. When viewport width or zoom changes whether the preview overflows, the control shall appear or disappear without a reload. +- **AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.10:** When a visible row offers removal, its expand/collapse control, when present, shall be immediately before the Remove control. Remove shall use the shared trash symbol and shall adopt the destructive color on hover while retaining its existing accessible name. ## Migrated source detail diff --git a/docs/specs/ui/system-design/message-queue-row-controls.md b/docs/specs/ui/system-design/message-queue-row-controls.md new file mode 100644 index 0000000000..269da69a52 --- /dev/null +++ b/docs/specs/ui/system-design/message-queue-row-controls.md @@ -0,0 +1,63 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-MESSAGE-QUEUE-MANAGEMENT-001 +--- + +# Message Queue Row Controls System Design + +## Purpose and boundaries + +The message queue panel renders each pending message as a compact row on desktop and phone surfaces. This design owns the row's adaptive disclosure and destructive-action presentation. Queue ordering, provenance, editing permissions, merge eligibility, persistence, and removal transport remain governed by the existing message queue contracts. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-UI-MESSAGE-QUEUE-MANAGEMENT-001` | [Adaptive disclosure](#adaptive-disclosure), [Action presentation](#action-presentation), [Responsive composition](#responsive-composition), [Verification](#verification) | + +## Components and responsibilities + +- `DisplayView` retains rendering and expanded-state ownership. It passes visible content and expanded state/setter to the adjacent hook, and passes the returned preview and disclosure-button refs into the preview and action component. +- `useQueuedMessageOverflow` owns both refs, collapsed cap, `canExpand`, lifecycle generation, measurement, signals, and cleanup. +- `QueuedGhostRowActions` forwards the disclosure ref to the optional button, exposes `data-testid="queue-entry-actions"` on the action container, and renders disclosure immediately before Remove. +- `ResizeObserver`, window resize, visual-viewport resize, capture-phase descendant load, and font-completion signals are additive. Callable-value detection selects observer or viewport-only fallback. + +## Adaptive disclosure + +Fit is always measured at the row width available without the optional disclosure button. Before reading preview geometry, the measurement routine synchronously saves the disclosure button's inline `display`, sets `display: none`, reads collapsed `scrollHeight` and `clientHeight` after layout, and restores the exact prior inline value in `finally` before publishing state. When no disclosure exists, it reads directly. This no-control probe prevents the button from causing or preserving the overflow that justifies itself, without a visible two-pass render or permanently reserved blank action slot. + +Collapsed fit remains strict `scrollHeight > clientHeight`; a mutable cap ref updates before state publication. Expanded measurements compare natural height against the collapsed cap while still suppressing the disclosure during the probe. Empty or absent preview resets cap, disclosure, and expanded state. + +Each lifecycle owns generation and element identity. Cleanup invalidates generation before teardown. Every callback guards before reads/publication; observer batches require the captured target. Observer support uses `typeof ResizeObserver === "function"`. + +Measurement runs in a layout effect for content/state changes and from additive layout, descendant-resource, and font-completion signals. The temporary inline style is restored even if a geometry getter throws. If expanded content fits at no-control width, expanded/disclosure clear together. No maximum-height transition remains. Fallback supports initial and viewport-triggered measurement, not element-only panel resizing. + +## Action presentation + +`QueuedGhostRowActions` keeps Send Now, Merge, and Edit behavior. It exposes an action-container test ID, forwards the measurement ref to disclosure, and renders disclosure as the direct DOM sibling immediately before terminal Remove. Remove uses `IconTrash`, `variant={null}`, local ghost backgrounds, default `text-muted-foreground`, and `[@media(pointer:fine)]:hover:text-destructive`. The pointer-precision variant, rather than hover capability alone, makes destructive color a fine-primary-pointer cue. Existing localized title/name and handlers remain. + +No queue mutation, provenance or permission rule, translated copy, or event behavior changes. Disclosure availability intentionally changes to follow rendered overflow. + +## Responsive composition + +- **Desktop outcome:** the existing compact hover-revealed action row remains. A disclosure appears only for content clipped by the collapsed preview, and the trash action gains destructive hover feedback. +- **Mobile entry point and surface:** the existing inline message queue panel remains the phone composition and keeps the queue list as its single internal scroll owner. +- **Nearest shipped exemplars:** `AnchoredLastPromptBar` supplies the rendered-overflow and `ResizeObserver` pattern; existing trash actions such as the task comment control supply muted-to-destructive hover styling. `mobile-message-queue-management.spec.ts` remains the phone interaction exemplar. +- **Hierarchy and primary action:** message content remains primary. Secondary row actions stay visible on coarse pointers, with the disclosure directly before the terminal Remove action. +- **Surface rationale:** these are frequent, row-local actions, so inline controls are preferable to a drawer or menu. The change adds no overlay, route, scroll owner, or safe-area behavior. +- **Touch geometry:** existing coarse-pointer 44 by 44 CSS-pixel targets remain unchanged. Destructive hover color is a fine-pointer cue; Remove remains discoverable without hover on touch surfaces. +- **Shared logic:** one measurement and action-order path serves desktop and phone widths. Responsive changes affect only whether the rendered content needs disclosure. + +## Verification + +- Focused Vitest coverage includes a self-induced-overflow boundary. It first establishes genuine overflow and visible disclosure, then changes geometry so the preview overflows only while the disclosure participates in flex layout but fits while that button's inline display is `none`. A layout signal must remove disclosure; subsequent signals must keep it absent. Spies prove the probe hides the button before reads and restores its exact previous inline display in `finally`, including a throwing-geometry case. Remaining cases cover exact opposing fixtures, cap/collapse, additive/fallback signals, and foreign, replacement, generation, cleanup-order, and unmount guards. +- Both browser scenarios sit in suites configured with `test.describe.configure({ timeout: 120_000 })`, so the larger deadline covers test-scoped fixture setup as well as the test body. Desktop uses a 60-second `seedRunningGeneratingSession`-style active turn. Mobile performs the equivalent setup with `sendMessageViaButton("/sleep 60")`, because keyboard submission is unsupported at the Pixel 5 breakpoint. Each awaits `waitForActiveSessionForegroundActivity(..., "generating")`, calls `ApiClient.setQueueAutoRun(sessionId, false)` before target admission, and requires the authoritative `message.queue.auto_run.set` response to report `auto_run === false`. After queue-mode readiness and admission, each polls `message.queue.get` until it reports Auto-run false with the expected pending count, confirms the target row, and only then begins geometry assertions. This invokes existing production Auto-run behavior solely as test setup; no production Auto-run contract changes. +- Desktop uses the exact fixture `"adaptive-width-probe ".repeat(12).trim()` and viewport widths 1280, 800, then 1280 CSS pixels. Before checking the disclosure at each width, it asserts `scrollHeight === clientHeight` wide and `scrollHeight > clientHeight` narrow; these preconditions must pass during RED rather than being mistaken for the expected disclosure failure. The disclosure is then proven absent, present, and absent without reload. +- Desktop exact-title Playwright uses `queue-entry-actions`. When disclosure is present, it evaluates the action container's direct element children and requires the disclosure element's `nextElementSibling` to have `data-testid="queue-entry-remove"`; Remove is also the final actionable child. Opacity, row-scoped role/name, title, color, geometry, and removal assertions remain. +- Pixel 5 exact-title Playwright uses `panel = chat.getByTestId("queued-ghost-list")`, `scrollRegion = panel.getByTestId("queue-scroll-region")`, `row = scrollRegion.getByTestId("queue-entry").filter({ hasText: exactFixture })`, then row-scoped `preview`, `actions`, `expand`, and `remove` test IDs. It requires document `scrollWidth === clientWidth` and `scrollRegion` computed `overflowY === "auto"`. Evaluating `[panel, ...panel.querySelectorAll("*")]` filters elements whose computed `overflowY` is `auto` or `scroll`, maps their `data-testid`, and must yield exactly `["queue-scroll-region"]`. With `EPSILON = 1`, panel lies within viewport width, scroll region within panel, row within scroll region, preview/actions within row, and both Expand and Remove within actions: `child.x >= parent.x - EPSILON` and `child.x + child.width <= parent.x + parent.width + EPSILON`. It requires preview `scrollWidth <= clientWidth + EPSILON`; effective expand/Remove width and height each at least 44; and retains media, icon, accessibility, color, screenshot, and removal assertions. + +## Related decisions + +None. The change replaces a rendering heuristic with an existing layout-observation pattern and changes no architecture, persistence, or authorization boundary.