authorgravatar for 136640686+MatthewFeroz@users.noreply.github.comMatthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> 2026-10-10 15:20:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 17:20:57-05:00
logf92afe79c921dd7de20204e83d62fed0491b4e54
tree3725a671eafc1b6eec57367f90da8b7621000afc
parenta8bdfdb54181fb5c9d43f2747112c137a804d016
signature Signed by PGP key B5690EEEBB952194

fix(web): find bar and thread details panel stop covering each other (#17858)

Co-authored-by: Yash Singh <saiansh2525@gmail.com>

11 files changed, 313 insertions(+), 82 deletions(-)

apps/web/src/components/ChatView.tsx-5
...@@ -7998,11 +7998,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -7998,11 +7998,6 @@ export default function ChatView(props: ChatViewProps) {
7998 const [isThreadFindActive, setIsThreadFindActive] = useState(false);7998 const [isThreadFindActive, setIsThreadFindActive] = useState(false);
7999 const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []);7999 const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []);
8000 const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []);8000 const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []);
8001 // The details popover hangs off the header over the find bar; opening find dismisses it.
8002 useEffect(() => {
8003 if (!isThreadFindActive || threadPanelPresentation !== "popover" || !activeThreadRef) return;
8004 useRightPanelStore.getState().setThreadPanelOpen(activeThreadRef, "popover", false);
8005 }, [activeThreadRef, isThreadFindActive, threadPanelPresentation]);
80068001
8007 useEffect(() => {8002 useEffect(() => {
8008 const handler = (event: globalThis.KeyboardEvent) => {8003 const handler = (event: globalThis.KeyboardEvent) => {
apps/web/src/components/chat/ChatCanvas.test.tsx created+119
...@@ -0,0 +1,119 @@
1// @vitest-environment jsdom
2
3import { act, useLayoutEffect } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6import { ChatCanvas } from "./ChatCanvas";
7import { useChatCanvas } from "./ChatCanvasContext";
8
9const resize = vi.hoisted(() => ({ callbacks: new Set<() => void>() }));
10vi.mock("../../lib/observeResize", () => ({
11 observeResize: (_elements: unknown, callback: () => void) => {
12 resize.callbacks.add(callback);
13 return () => resize.callbacks.delete(callback);
14 },
15}));
16
17let root: Root;
18let container: HTMLDivElement;
19let fontSize = 16;
20let canvas: ReturnType<typeof useChatCanvas>;
21
22function Preview({ x }: { x: number }) {
23 const current = useChatCanvas();
24 useLayoutEffect(() => {
25 canvas = current;
26 }, [current]);
27 const reportPreview = current?.reportPreview;
28 useLayoutEffect(() => {
29 reportPreview?.({
30 key: "browser:one",
31 width: 240,
32 source: { width: 240, height: 365 },
33 position: { x, y: 12 },
34 lastInteraction: "drag",
35 });
36 }, [reportPreview, x]);
37 return null;
38}
39
40beforeEach(() => {
41 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
42 fontSize = 16;
43 vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1800);
44 vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(1000);
45 vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
46 this: HTMLElement,
47 ) {
48 // A nonzero canvas origin catches viewport coordinates used as canvas coordinates.
49 if (this.hasAttribute("data-thread-find-bar")) {
50 const gap = 0.75 * fontSize;
51 return new DOMRect(
52 200 + 1800 - gap - 17.5 * fontSize,
53 100 + gap,
54 17.5 * fontSize,
55 2.25 * fontSize,
56 );
57 }
58 return new DOMRect(200, 100, 1800, 1000);
59 });
60 vi.spyOn(window, "getComputedStyle").mockReturnValue({
61 paddingLeft: "48px",
62 width: "736px",
63 minWidth: "640px",
64 } as CSSStyleDeclaration);
65 container = document.createElement("div");
66 document.body.append(container);
67 root = createRoot(container);
68});
69
70afterEach(async () => {
71 await act(() => root.unmount());
72 container.remove();
73 resize.callbacks.clear();
74 vi.restoreAllMocks();
75 vi.unstubAllGlobals();
76});
77
78async function renderFind(open: boolean, previewX = 1548) {
79 await act(() =>
80 root.render(
81 <ChatCanvas composerOverlayElement={null} detailsCardTopInset={open ? 48 : 0}>
82 {open ? <div data-thread-find-bar /> : null}
83 <Preview x={previewX} />
84 </ChatCanvas>,
85 ),
86 );
87}
88
89it.each([16, 24])(
90 "keeps a preview below Find and releases its space on close at a %spx root font",
91 async (size) => {
92 fontSize = size;
93 await renderFind(false);
94 expect(canvas?.layout.frame?.y).toBe(12);
95
96 await renderFind(true);
97 expect(canvas?.detailsCardTopInset).toBe(3 * size);
98 expect(canvas?.layout.frame?.y).toBe(3 * size + 12);
99
100 await renderFind(false);
101 expect(canvas?.detailsCardTopInset).toBe(0);
102 expect(canvas?.layout.frame?.y).toBe(12);
103
104 await renderFind(true);
105 expect(canvas?.layout.frame?.y).toBe(3 * size + 12);
106 },
107);
108
109it("remeasures the open bar when its font size changes without resizing the canvas", async () => {
110 await renderFind(true, 1200);
111 expect(canvas?.layout.frame?.y).toBe(12);
112
113 fontSize = 24;
114 await act(() => {
115 for (const callback of resize.callbacks) callback();
116 });
117 expect(canvas?.detailsCardTopInset).toBe(72);
118 expect(canvas?.layout.frame?.y).toBe(84);
119});
apps/web/src/components/chat/ChatCanvas.tsx+28-3
...@@ -47,6 +47,9 @@ export function ChatCanvas({...@@ -47,6 +47,9 @@ export function ChatCanvas({
47 minChatWidth: 640,47 minChatWidth: 640,
48 composerHeight: 0,48 composerHeight: 0,
49 timelineGutter: 0,49 timelineGutter: 0,
50 findBarLeft: 0,
51 findBarRight: 0,
52 findBarBottom: 0,
50 });53 });
51 const reportPreview = useCallback((next: ChatCanvasPreview) => {54 const reportPreview = useCallback((next: ChatCanvasPreview) => {
52 setPreview((current) =>55 setPreview((current) =>
...@@ -69,9 +72,16 @@ export function ChatCanvas({...@@ -69,9 +72,16 @@ export function ChatCanvas({
69 const element = elementRef.current;72 const element = elementRef.current;
70 const probe = widthProbeRef.current;73 const probe = widthProbeRef.current;
71 if (!element || !probe) return;74 if (!element || !probe) return;
75 const findBar =
76 detailsCardTopInset > 0 ? element.querySelector<HTMLElement>("[data-thread-find-bar]") : null;
72 const measure = () => {77 const measure = () => {
78 const canvasBounds = element.getBoundingClientRect();
79 const findBarBounds = findBar?.getBoundingClientRect();
73 const styles = getComputedStyle(probe);80 const styles = getComputedStyle(probe);
74 const next = {81 const next = {
82 findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
83 findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
84 findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
75 width: element.clientWidth,85 width: element.clientWidth,
76 height: element.clientHeight,86 height: element.clientHeight,
77 padding: Number.parseFloat(styles.paddingLeft),87 padding: Number.parseFloat(styles.paddingLeft),
...@@ -92,22 +102,37 @@ export function ChatCanvas({...@@ -92,22 +102,37 @@ export function ChatCanvas({
92 };102 };
93 measure();103 measure();
94 const observed: Element[] = [element, probe];104 const observed: Element[] = [element, probe];
105 if (findBar) observed.push(findBar);
95 if (composerOverlayElement) observed.push(composerOverlayElement);106 if (composerOverlayElement) observed.push(composerOverlayElement);
96 if (timelineElement) observed.push(timelineElement);107 if (timelineElement) observed.push(timelineElement);
97 return observeResize(observed, measure);108 return observeResize(observed, measure);
98 }, [composerOverlayElement, timelineElement]);109 }, [composerOverlayElement, timelineElement, detailsCardTopInset]);
99 const context = useMemo(() => {110 const context = useMemo(() => {
100 const container = { width: measurements.width, height: measurements.height };111 const container = { width: measurements.width, height: measurements.height };
112 const findBar =
113 detailsCardTopInset > 0
114 ? {
115 left: measurements.findBarLeft,
116 right: measurements.findBarRight,
117 bottom: measurements.findBarBottom,
118 }
119 : null;
101 return {120 return {
102 container,121 container,
103 lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },122 lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },
104 layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }),123 layout: resolveChatCanvasLayout({
124 ...measurements,
125 container,
126 preview,
127 detailsCard,
128 findBar,
129 }),
105 previewKey: preview?.key ?? null,130 previewKey: preview?.key ?? null,
106 reportPreview,131 reportPreview,
107 clearPreview,132 clearPreview,
108 registerTimeline,133 registerTimeline,
109 reportDetailsCard,134 reportDetailsCard,
110 detailsCardTopInset,135 detailsCardTopInset: findBar?.bottom ?? 0,
111 };136 };
112 }, [137 }, [
113 measurements,138 measurements,
apps/web/src/components/chat/ThreadDetailsCard.tsx+31-7
...@@ -7,6 +7,7 @@ import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore...@@ -7,6 +7,7 @@ import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore
7import type { ThreadPanelPresentation } from "../../rightPanelLayout";7import type { ThreadPanelPresentation } from "../../rightPanelLayout";
8import { useChatCanvas } from "./ChatCanvasContext";8import { useChatCanvas } from "./ChatCanvasContext";
9import {9import {
10 THREAD_DETAILS_CARD_GAP,
10 resolveThreadDetailsCardDensity,11 resolveThreadDetailsCardDensity,
11 resolveThreadDetailsCardLayout,12 resolveThreadDetailsCardLayout,
12} from "./threadDetailsCardLayout";13} from "./threadDetailsCardLayout";
...@@ -45,6 +46,7 @@ export function ThreadDetailsCard({...@@ -45,6 +46,7 @@ export function ThreadDetailsCard({
45 })46 })
46 : null;47 : null;
47 const mode = placement ? "inline" : "popover";48 const mode = placement ? "inline" : "popover";
49 const topInset = canvas?.detailsCardTopInset ?? 0;
48 const inlineOpen = useRightPanelStore((state) =>50 const inlineOpen = useRightPanelStore((state) =>
49 selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),51 selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),
50 );52 );
...@@ -137,17 +139,38 @@ export function ThreadDetailsCard({...@@ -137,17 +139,38 @@ export function ThreadDetailsCard({
137 <Popover139 <Popover
138 handle={handle}140 handle={handle}
139 open={mode === "popover" && popoverOpen}141 open={mode === "popover" && popoverOpen}
140 onOpenChange={(open) =>142 onOpenChange={(open, details) => {
141 useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open)143 // The find bar sits beside the card, so clicking or focusing it does not
142 }144 // dismiss the card. Escape still does.
145 const target =
146 details.reason === "focus-out"
147 ? (details.event as FocusEvent).relatedTarget
148 : details.reason === "outside-press"
149 ? details.event.target
150 : null;
151 if (!open && target instanceof Element && target.closest("[data-thread-find-bar]")) {
152 details.cancel();
153 return;
154 }
155 useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open);
156 }}
143 >157 >
144 {placement ? (158 {placement ? (
145 inlineOpen ? (159 inlineOpen ? (
146 <aside160 <aside
147 aria-label="Thread details"161 aria-label="Thread details"
148 className="absolute z-20"162 // Slides down to make room for the find bar, but returns in the same frame
163 // the bar closes: a floating preview below moves up at once and would
164 // otherwise overlap the card mid-slide.
165 className={cn(
166 "absolute z-20",
167 topInset > 0 &&
168 "transition-[top] duration-150 ease-out motion-reduce:transition-none",
169 )}
149 style={{170 style={{
150 left: placement.x,171 // Anchored to the right edge like the find bar, so both follow a resizing
172 // canvas in the same frame instead of waiting for its next measurement.
173 right: placement.right,
151 top: placement.y,174 top: placement.y,
152 width: placement.width,175 width: placement.width,
153 maxHeight: height,176 maxHeight: height,
...@@ -162,10 +185,11 @@ export function ThreadDetailsCard({...@@ -162,10 +185,11 @@ export function ThreadDetailsCard({
162 <PopoverPopup185 <PopoverPopup
163 anchor={anchor}186 anchor={anchor}
164 align="end"187 align="end"
165 alignOffset={0}188 // Lines the card up with the find bar: the panel pads its card by 8px.
189 alignOffset={THREAD_DETAILS_CARD_GAP - 8}
166 collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}190 collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}
167 side="bottom"191 side="bottom"
168 sideOffset={0}192 sideOffset={topInset}
169 variant="panel"193 variant="panel"
170 padding="none"194 padding="none"
171 >195 >
apps/web/src/components/chat/ThreadFindBar.tsx+63-57
...@@ -59,65 +59,71 @@ export function ThreadFindBar(props: ThreadFindBarProps) {...@@ -59,65 +59,71 @@ export function ThreadFindBar(props: ThreadFindBarProps) {
59 };59 };
6060
61 return (61 return (
62 <InputGroup62 // Fades in while the details popover slides out from under it.
63 variant="popover"63 <div
64 size="lg"64 data-thread-find-bar
65 onContextMenu={(event) => event.stopPropagation()}65 className="absolute top-3 right-3 z-40 w-[min(var(--thread-details-panel-width),calc(100%-1.5rem))] transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"
66 role="search"
67 aria-label="Find in thread"
68 aria-busy={props.status === "loading" || props.counting === true}
69 className="absolute top-3 right-3 z-40 w-[min(24rem,calc(100%-1.5rem))]"
70 >66 >
71 <InputGroupInput67 <InputGroup
72 ref={inputRef}68 variant="popover"
73 type="search"69 size="lg"
74 size="sm"70 onContextMenu={(event) => event.stopPropagation()}
75 value={props.query}71 role="search"
76 aria-label="Find in thread"72 aria-label="Find in thread"
77 placeholder="Find in thread"73 aria-busy={props.status === "loading" || props.counting === true}
78 maxLength={200}74 className="w-full"
79 spellCheck={false}75 >
80 autoComplete="off"76 <InputGroupInput
81 onChange={(event) => props.onQueryChange(event.target.value)}77 ref={inputRef}
82 onKeyDown={handleKeyDown}78 type="search"
83 />79 size="sm"
84 <InputGroupAddon align="inline-end" inset="pill">80 value={props.query}
85 <span81 aria-label="Find in thread"
86 aria-live="polite"82 placeholder="Find in thread"
87 className={cn(83 maxLength={200}
88 "min-w-10 text-center text-xs tabular-nums",84 spellCheck={false}
89 noResults ? "text-destructive" : "text-muted-foreground",85 autoComplete="off"
90 )}86 onChange={(event) => props.onQueryChange(event.target.value)}
91 >87 onKeyDown={handleKeyDown}
92 {label}88 />
93 </span>89 <InputGroupAddon align="inline-end" inset="pill">
94 {props.status === "error" ? (90 <span
95 <Button size="xs" variant="ghost" onClick={props.onRetry}>91 aria-live="polite"
96 Retry92 className={cn(
93 "min-w-10 text-center text-xs tabular-nums",
94 noResults ? "text-destructive" : "text-muted-foreground",
95 )}
96 >
97 {label}
98 </span>
99 {props.status === "error" ? (
100 <Button size="xs" variant="ghost" onClick={props.onRetry}>
101 Retry
102 </Button>
103 ) : null}
104 <Button
105 size="icon-xs"
106 variant="ghost"
107 aria-label="Previous match"
108 disabled={navigationDisabled}
109 onClick={props.onPrevious}
110 >
111 <ChevronUpIcon />
97 </Button>112 </Button>
98 ) : null}113 <Button
99 <Button114 size="icon-xs"
100 size="icon-xs"115 variant="ghost"
101 variant="ghost"116 aria-label="Next match"
102 aria-label="Previous match"117 disabled={navigationDisabled}
103 disabled={navigationDisabled}118 onClick={props.onNext}
104 onClick={props.onPrevious}119 >
105 >120 <ChevronDownIcon />
106 <ChevronUpIcon />121 </Button>
107 </Button>122 <Button size="icon-xs" variant="ghost" aria-label="Close find" onClick={props.onClose}>
108 <Button123 <XIcon />
109 size="icon-xs"124 </Button>
110 variant="ghost"125 </InputGroupAddon>
111 aria-label="Next match"126 </InputGroup>
112 disabled={navigationDisabled}127 </div>
113 onClick={props.onNext}
114 >
115 <ChevronDownIcon />
116 </Button>
117 <Button size="icon-xs" variant="ghost" aria-label="Close find" onClick={props.onClose}>
118 <XIcon />
119 </Button>
120 </InputGroupAddon>
121 </InputGroup>
122 );128 );
123}129}
apps/web/src/components/chat/ThreadFindProvider.tsx+3-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import {1import {
2 createContext,2 createContext,
3 use,3 use,
4 useEffect,
5 useImperativeHandle,4 useImperativeHandle,
5 useLayoutEffect,
6 type ReactNode,6 type ReactNode,
7 type ComponentProps,7 type ComponentProps,
8 type Ref,8 type Ref,
...@@ -49,7 +49,8 @@ export function ThreadFindProvider({...@@ -49,7 +49,8 @@ export function ThreadFindProvider({
49 find.open,49 find.open,
50 find.close,50 find.close,
51 ]);51 ]);
52 useEffect(() => onOpenChange(find.isOpen), [find.isOpen, onOpenChange]);52 // Before paint, so the details card makes room in the same frame the bar appears.
53 useLayoutEffect(() => onOpenChange(find.isOpen), [find.isOpen, onOpenChange]);
53 return (54 return (
54 <ThreadFindBarContext value={find.barProps}>55 <ThreadFindBarContext value={find.barProps}>
55 <ThreadFindTimelineContext value={find.timelineProps}>{children}</ThreadFindTimelineContext>56 <ThreadFindTimelineContext value={find.timelineProps}>{children}</ThreadFindTimelineContext>
apps/web/src/components/chat/chatCanvasLayout.test.ts+40
...@@ -135,6 +135,46 @@ describe("chat canvas layout", () => {...@@ -135,6 +135,46 @@ describe("chat canvas layout", () => {
135 expect(result.overlapsDetailsCard).toBe(false);135 expect(result.overlapsDetailsCard).toBe(false);
136 expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 });136 expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 });
137 });137 });
138 it("keeps a dragged or resized preview below the open find bar", () => {
139 const findBar = { left: 1292, right: 1572, bottom: 48 };
140 const layout = (lastInteraction: "drag" | "resize") =>
141 resolveChatCanvasLayout({
142 container: { width: 1584, height: 988 },
143 maxChatWidth: 736,
144 composerHeight: 180,
145 findBar,
146 preview: {
147 ...preview,
148 width: 240,
149 source: { width: 240, height: 365 },
150 position: { x: 1332, y: 12 },
151 lastInteraction,
152 },
153 });
154 for (const lastInteraction of ["drag", "resize"] as const) {
155 const result = layout(lastInteraction);
156 expect(result.frame).toEqual({ x: 1332, y: 60, width: 240, height: 365 });
157 expect(result.overlapsDetailsCard).toBe(false);
158 expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 });
159 }
160 });
161 it("keeps a preview clear of a find bar wider than the inline details card", () => {
162 const result = resolveChatCanvasLayout({
163 container: { width: 1800, height: 1000 },
164 findBar: { left: 1362, right: 1782, bottom: 72 },
165 detailsCard: { left: 1508, right: 1788, bottom: 400 },
166 preview: {
167 ...preview,
168 width: 240,
169 source: { width: 240, height: 365 },
170 position: { x: 1200, y: 12 },
171 lastInteraction: "drag",
172 },
173 });
174 const frame = result.frame!;
175 expect(frame.x + frame.width <= 1362 || frame.y >= 84).toBe(true);
176 expect(result.overlapsDetailsCard).toBe(false);
177 });
138 it("uses space beside the full card if a tall preview cannot fit below it", () => {178 it("uses space beside the full card if a tall preview cannot fit below it", () => {
139 const result = resolveChatCanvasLayout({179 const result = resolveChatCanvasLayout({
140 container: { width: 1584, height: 988 },180 container: { width: 1584, height: 988 },
apps/web/src/components/chat/chatCanvasLayout.ts+16-3
...@@ -32,6 +32,7 @@ export function resolveChatCanvasLayout({...@@ -32,6 +32,7 @@ export function resolveChatCanvasLayout({
32 minChatWidth = 640,32 minChatWidth = 640,
33 composerHeight = 0,33 composerHeight = 0,
34 detailsCard = null,34 detailsCard = null,
35 findBar = null,
35}: {36}: {
36 container: PreviewMiniPlayerSize;37 container: PreviewMiniPlayerSize;
37 preview: ChatCanvasPreview | null;38 preview: ChatCanvasPreview | null;
...@@ -40,6 +41,8 @@ export function resolveChatCanvasLayout({...@@ -40,6 +41,8 @@ export function resolveChatCanvasLayout({
40 minChatWidth?: number;41 minChatWidth?: number;
41 composerHeight?: number;42 composerHeight?: number;
42 detailsCard?: PreviewMiniPlayerObstacles["detailsCard"];43 detailsCard?: PreviewMiniPlayerObstacles["detailsCard"];
44 /** The open find bar. It only keeps the floating preview clear; chat stays put. */
45 findBar?: PreviewMiniPlayerObstacles["detailsCard"];
43}) {46}) {
44 const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));47 const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));
45 // A workspace card that does not fit beside the centered chat first moves48 // A workspace card that does not fit beside the centered chat first moves
...@@ -90,13 +93,23 @@ export function resolveChatCanvasLayout({...@@ -90,13 +93,23 @@ export function resolveChatCanvasLayout({
90 });93 });
91 }94 }
92 }95 }
96 const findBarObstacles = findBar ? { detailsCard: findBar, composer: null } : undefined;
93 const preferredFrame = {97 const preferredFrame = {
94 ...frame,98 ...frame,
95 ...clampPreviewMiniPlayerPosition(frame, container, frame, undefined, minimumPreviewX),99 ...clampPreviewMiniPlayerPosition(frame, container, frame, findBarObstacles, minimumPreviewX),
96 };100 };
97 // A resize keeps its anchored edge and consumes card height first. A drag101 // A resize keeps its anchored edge and consumes card height first. A drag
98 // clears the full card whenever it can, so moving alone never folds it.102 // clears the full card whenever it can, so moving alone never folds it.
99 const cardObstacle = preview.lastInteraction === "resize" ? null : detailsCard;103 // The find bar can be wider than the inline card when the root font is enlarged.
104 const dragObstacle =
105 detailsCard && findBar
106 ? {
107 left: Math.min(detailsCard.left, findBar.left),
108 right: Math.max(detailsCard.right, findBar.right),
109 bottom: Math.max(detailsCard.bottom, findBar.bottom),
110 }
111 : (detailsCard ?? findBar);
112 const cardObstacle = preview.lastInteraction === "resize" ? findBar : dragObstacle;
100 frame = {113 frame = {
101 ...frame,114 ...frame,
102 ...clampPreviewMiniPlayerPosition(115 ...clampPreviewMiniPlayerPosition(
...@@ -166,7 +179,7 @@ export function resolveChatCanvasLayout({...@@ -166,7 +179,7 @@ export function resolveChatCanvasLayout({
166 frame,179 frame,
167 container,180 container,
168 frame,181 frame,
169 { composer: obstacles.composer, detailsCard: null },182 { composer: obstacles.composer, detailsCard: findBar },
170 minimumPreviewX,183 minimumPreviewX,
171 ),184 ),
172 };185 };
apps/web/src/components/chat/threadDetailsCardLayout.test.ts+3-2
...@@ -16,6 +16,7 @@ describe("workspace card", () => {...@@ -16,6 +16,7 @@ describe("workspace card", () => {
16 it("pins to the top right at a fixed width", () => {16 it("pins to the top right at a fixed width", () => {
17 expect(resolve(1600, 900)).toEqual({17 expect(resolve(1600, 900)).toEqual({
18 x: 1308,18 x: 1308,
19 right: 12,
19 y: 12,20 y: 12,
20 width: 280,21 width: 280,
21 height: 876,22 height: 876,
...@@ -30,7 +31,7 @@ describe("workspace card", () => {...@@ -30,7 +31,7 @@ describe("workspace card", () => {
30 frame: null,31 frame: null,
31 topInset: 48,32 topInset: 48,
32 }),33 }),
33 ).toEqual({ x: 1308, y: 60, width: 280, height: 828 });34 ).toEqual({ x: 1308, right: 12, y: 60, width: 280, height: 828 });
34 });35 });
35 it("hides when a readable chat lane cannot fit beside it", () => {36 it("hides when a readable chat lane cannot fit beside it", () => {
36 expect(resolve(1012, 900)).toMatchObject({ x: 720 });37 expect(resolve(1012, 900)).toMatchObject({ x: 720 });
...@@ -38,7 +39,7 @@ describe("workspace card", () => {...@@ -38,7 +39,7 @@ describe("workspace card", () => {
38 });39 });
39 it("keeps the card at the top right while the preview is freely dragged vertically", () => {40 it("keeps the card at the top right while the preview is freely dragged vertically", () => {
40 for (const y of [12, 170, 250, 400, 648]) {41 for (const y of [12, 170, 250, 400, 648]) {
41 expect(resolve(1600, 900, y)).toEqual({ x: 1308, y: 12, width: 280, height: 876 });42 expect(resolve(1600, 900, y)).toEqual({ x: 1308, right: 12, y: 12, width: 280, height: 876 });
42 }43 }
43 });44 });
44 it("keeps full height while a preview stays clear of the card", () => {45 it("keeps full height while a preview stays clear of the card", () => {
apps/web/src/components/chat/threadDetailsCardLayout.ts+4-2
...@@ -3,6 +3,8 @@ import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";...@@ -3,6 +3,8 @@ import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";
33
4/** Inset of the card from the canvas edges; the find bar shares it to line up. */4/** Inset of the card from the canvas edges; the find bar shares it to line up. */
5export const THREAD_DETAILS_CARD_GAP = 12;5export const THREAD_DETAILS_CARD_GAP = 12;
6// Keep in sync with --thread-details-panel-width, which sizes the popover and the find bar.
7const THREAD_DETAILS_CARD_WIDTH = 280;
68
7export function resolveThreadDetailsCardDensity(9export function resolveThreadDetailsCardDensity(
8 height: number,10 height: number,
...@@ -32,8 +34,7 @@ export function resolveThreadDetailsCardLayout({...@@ -32,8 +34,7 @@ export function resolveThreadDetailsCardLayout({
32 topInset?: number;34 topInset?: number;
33}) {35}) {
34 const gap = THREAD_DETAILS_CARD_GAP;36 const gap = THREAD_DETAILS_CARD_GAP;
35 // Keep in sync with --thread-details-panel-width, which sizes the popover.37 const width = THREAD_DETAILS_CARD_WIDTH;
36 const width = 280;
37 const x = container.width - width - gap;38 const x = container.width - width - gap;
38 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;39 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;
39 const y = gap + topInset;40 const y = gap + topInset;
...@@ -46,6 +47,7 @@ export function resolveThreadDetailsCardLayout({...@@ -46,6 +47,7 @@ export function resolveThreadDetailsCardLayout({
46 if (height < 160) return null;47 if (height < 160) return null;
47 return {48 return {
48 x,49 x,
50 right: gap,
49 width,51 width,
50 y,52 y,
51 height,53 height,
apps/web/src/components/ui/popover.tsx+6-1
...@@ -72,10 +72,15 @@ function PopoverPopup({...@@ -72,10 +72,15 @@ function PopoverPopup({
72 alignOffset={alignOffset}72 alignOffset={alignOffset}
73 anchor={anchor}73 anchor={anchor}
74 collisionAvoidance={collisionAvoidance}74 collisionAvoidance={collisionAvoidance}
75 // A panel's card already sits inside its own padding, so the panel can reach the edge.
76 collisionPadding={variant === "panel" ? 0 : undefined}
75 className={cn(77 className={cn(
76 "h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none",78 "h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none",
77 variant === "panel"79 variant === "panel"
78 ? "z-(--z-sheet) w-[min(var(--thread-details-panel-width),var(--anchor-width))] transition-none"80 ? // The card inside is the panel width, or the anchor less 12px per side; the viewport
81 // pads it by --spacing(2) per side. Panels slide when their offset changes, e.g. to make
82 // room for a bar below the header, even when opened from the keyboard (data-instant).
83 "z-(--z-sheet) w-[min(calc(var(--thread-details-panel-width)+--spacing(4)),calc(var(--anchor-width)---spacing(2)))] transition-[top] duration-150 ease-out data-instant:transition-[top] motion-reduce:transition-none motion-reduce:data-instant:transition-none"
79 : "z-[130]",84 : "z-[130]",
80 )}85 )}
81 data-slot="popover-positioner"86 data-slot="popover-positioner"