From f92afe79c921dd7de20204e83d62fed0491b4e54 Mon Sep 17 00:00:00 2001 From: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:20:57 -0700 Subject: [PATCH] fix(web): find bar and thread details panel stop covering each other (#17858) Co-authored-by: Yash Singh --- apps/web/src/components/ChatView.tsx | 5 - .../src/components/chat/ChatCanvas.test.tsx | 119 +++++++++++++++++ apps/web/src/components/chat/ChatCanvas.tsx | 31 ++++- .../src/components/chat/ThreadDetailsCard.tsx | 38 +++++- .../web/src/components/chat/ThreadFindBar.tsx | 120 +++++++++--------- .../components/chat/ThreadFindProvider.tsx | 5 +- .../components/chat/chatCanvasLayout.test.ts | 40 ++++++ .../src/components/chat/chatCanvasLayout.ts | 19 ++- .../chat/threadDetailsCardLayout.test.ts | 5 +- .../chat/threadDetailsCardLayout.ts | 6 +- apps/web/src/components/ui/popover.tsx | 7 +- 11 files changed, 313 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/components/chat/ChatCanvas.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2ec0af4ed205ca7de7fed622fe39b2cb17f1cf8..e442fae36aa6a8e32307d2995bc89d82dd64016d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7998,11 +7998,6 @@ export default function ChatView(props: ChatViewProps) { const [isThreadFindActive, setIsThreadFindActive] = useState(false); const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []); const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []); - // The details popover hangs off the header over the find bar; opening find dismisses it. - useEffect(() => { - if (!isThreadFindActive || threadPanelPresentation !== "popover" || !activeThreadRef) return; - useRightPanelStore.getState().setThreadPanelOpen(activeThreadRef, "popover", false); - }, [activeThreadRef, isThreadFindActive, threadPanelPresentation]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { diff --git a/apps/web/src/components/chat/ChatCanvas.test.tsx b/apps/web/src/components/chat/ChatCanvas.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ab43b81cb2e168be892ac5546c82475db02d27f --- /dev/null +++ b/apps/web/src/components/chat/ChatCanvas.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom + +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { ChatCanvas } from "./ChatCanvas"; +import { useChatCanvas } from "./ChatCanvasContext"; + +const resize = vi.hoisted(() => ({ callbacks: new Set<() => void>() })); +vi.mock("../../lib/observeResize", () => ({ + observeResize: (_elements: unknown, callback: () => void) => { + resize.callbacks.add(callback); + return () => resize.callbacks.delete(callback); + }, +})); + +let root: Root; +let container: HTMLDivElement; +let fontSize = 16; +let canvas: ReturnType; + +function Preview({ x }: { x: number }) { + const current = useChatCanvas(); + useLayoutEffect(() => { + canvas = current; + }, [current]); + const reportPreview = current?.reportPreview; + useLayoutEffect(() => { + reportPreview?.({ + key: "browser:one", + width: 240, + source: { width: 240, height: 365 }, + position: { x, y: 12 }, + lastInteraction: "drag", + }); + }, [reportPreview, x]); + return null; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + fontSize = 16; + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1800); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(1000); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + // A nonzero canvas origin catches viewport coordinates used as canvas coordinates. + if (this.hasAttribute("data-thread-find-bar")) { + const gap = 0.75 * fontSize; + return new DOMRect( + 200 + 1800 - gap - 17.5 * fontSize, + 100 + gap, + 17.5 * fontSize, + 2.25 * fontSize, + ); + } + return new DOMRect(200, 100, 1800, 1000); + }); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + paddingLeft: "48px", + width: "736px", + minWidth: "640px", + } as CSSStyleDeclaration); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + resize.callbacks.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function renderFind(open: boolean, previewX = 1548) { + await act(() => + root.render( + + {open ?
: null} + + , + ), + ); +} + +it.each([16, 24])( + "keeps a preview below Find and releases its space on close at a %spx root font", + async (size) => { + fontSize = size; + await renderFind(false); + expect(canvas?.layout.frame?.y).toBe(12); + + await renderFind(true); + expect(canvas?.detailsCardTopInset).toBe(3 * size); + expect(canvas?.layout.frame?.y).toBe(3 * size + 12); + + await renderFind(false); + expect(canvas?.detailsCardTopInset).toBe(0); + expect(canvas?.layout.frame?.y).toBe(12); + + await renderFind(true); + expect(canvas?.layout.frame?.y).toBe(3 * size + 12); + }, +); + +it("remeasures the open bar when its font size changes without resizing the canvas", async () => { + await renderFind(true, 1200); + expect(canvas?.layout.frame?.y).toBe(12); + + fontSize = 24; + await act(() => { + for (const callback of resize.callbacks) callback(); + }); + expect(canvas?.detailsCardTopInset).toBe(72); + expect(canvas?.layout.frame?.y).toBe(84); +}); diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 35f9665658519e98c8ddb1e514df2b9353ba5012..809443c8f6861f95d0516f905a04f413177dab3f 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -47,6 +47,9 @@ export function ChatCanvas({ minChatWidth: 640, composerHeight: 0, timelineGutter: 0, + findBarLeft: 0, + findBarRight: 0, + findBarBottom: 0, }); const reportPreview = useCallback((next: ChatCanvasPreview) => { setPreview((current) => @@ -69,9 +72,16 @@ export function ChatCanvas({ const element = elementRef.current; const probe = widthProbeRef.current; if (!element || !probe) return; + const findBar = + detailsCardTopInset > 0 ? element.querySelector("[data-thread-find-bar]") : null; const measure = () => { + const canvasBounds = element.getBoundingClientRect(); + const findBarBounds = findBar?.getBoundingClientRect(); const styles = getComputedStyle(probe); const next = { + findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0, + findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0, + findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0, width: element.clientWidth, height: element.clientHeight, padding: Number.parseFloat(styles.paddingLeft), @@ -92,22 +102,37 @@ export function ChatCanvas({ }; measure(); const observed: Element[] = [element, probe]; + if (findBar) observed.push(findBar); if (composerOverlayElement) observed.push(composerOverlayElement); if (timelineElement) observed.push(timelineElement); return observeResize(observed, measure); - }, [composerOverlayElement, timelineElement]); + }, [composerOverlayElement, timelineElement, detailsCardTopInset]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; + const findBar = + detailsCardTopInset > 0 + ? { + left: measurements.findBarLeft, + right: measurements.findBarRight, + bottom: measurements.findBarBottom, + } + : null; return { container, lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, - layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), + layout: resolveChatCanvasLayout({ + ...measurements, + container, + preview, + detailsCard, + findBar, + }), previewKey: preview?.key ?? null, reportPreview, clearPreview, registerTimeline, reportDetailsCard, - detailsCardTopInset, + detailsCardTopInset: findBar?.bottom ?? 0, }; }, [ measurements, diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4aa4ce400b8bd8686785319df177c52016d783f1..0b0ebb8398bda106cc4c66a0b33a6edc2bcb94f8 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -7,6 +7,7 @@ import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore import type { ThreadPanelPresentation } from "../../rightPanelLayout"; import { useChatCanvas } from "./ChatCanvasContext"; import { + THREAD_DETAILS_CARD_GAP, resolveThreadDetailsCardDensity, resolveThreadDetailsCardLayout, } from "./threadDetailsCardLayout"; @@ -45,6 +46,7 @@ export function ThreadDetailsCard({ }) : null; const mode = placement ? "inline" : "popover"; + const topInset = canvas?.detailsCardTopInset ?? 0; const inlineOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), ); @@ -137,17 +139,38 @@ export function ThreadDetailsCard({ - useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open) - } + onOpenChange={(open, details) => { + // The find bar sits beside the card, so clicking or focusing it does not + // dismiss the card. Escape still does. + const target = + details.reason === "focus-out" + ? (details.event as FocusEvent).relatedTarget + : details.reason === "outside-press" + ? details.event.target + : null; + if (!open && target instanceof Element && target.closest("[data-thread-find-bar]")) { + details.cancel(); + return; + } + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open); + }} > {placement ? ( inlineOpen ? (