| author | |
| committer | |
| log | f92afe79c921dd7de20204e83d62fed0491b4e54 |
| tree | 3725a671eafc1b6eec57367f90da8b7621000afc |
| parent | a8bdfdb54181fb5c9d43f2747112c137a804d016 |
| signature | Signed by PGP key B5690EEEBB952194 |
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]); | ||
| 8006 | 8001 | ||
| 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 | |||
| 3 | import { act, useLayoutEffect } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { ChatCanvas } from "./ChatCanvas"; | ||
| 7 | import { useChatCanvas } from "./ChatCanvasContext"; | ||
| 8 | |||
| 9 | const resize = vi.hoisted(() => ({ callbacks: new Set<() => void>() })); | ||
| 10 | vi.mock("../../lib/observeResize", () => ({ | ||
| 11 | observeResize: (_elements: unknown, callback: () => void) => { | ||
| 12 | resize.callbacks.add(callback); | ||
| 13 | return () => resize.callbacks.delete(callback); | ||
| 14 | }, | ||
| 15 | })); | ||
| 16 | |||
| 17 | let root: Root; | ||
| 18 | let container: HTMLDivElement; | ||
| 19 | let fontSize = 16; | ||
| 20 | let canvas: ReturnType<typeof useChatCanvas>; | ||
| 21 | |||
| 22 | function 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 | |||
| 40 | beforeEach(() => { | ||
| 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 | |||
| 70 | afterEach(async () => { | ||
| 71 | await act(() => root.unmount()); | ||
| 72 | container.remove(); | ||
| 73 | resize.callbacks.clear(); | ||
| 74 | vi.restoreAllMocks(); | ||
| 75 | vi.unstubAllGlobals(); | ||
| 76 | }); | ||
| 77 | |||
| 78 | async 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 | |||
| 89 | it.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 | |||
| 109 | it("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 |
| 7 | import type { ThreadPanelPresentation } from "../../rightPanelLayout"; | 7 | import type { ThreadPanelPresentation } from "../../rightPanelLayout"; |
| 8 | import { useChatCanvas } from "./ChatCanvasContext"; | 8 | import { useChatCanvas } from "./ChatCanvasContext"; |
| 9 | import { | 9 | import { |
| 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 | <Popover | 139 | <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 | <aside | 160 | <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 | <PopoverPopup | 185 | <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 | }; |
| 60 | 60 | ||
| 61 | return ( | 61 | return ( |
| 62 | <InputGroup | 62 | // 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 | <InputGroupInput | 67 | <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 | <span | 81 | 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 | Retry | 92 | 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 | <Button | 114 | 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 | <Button | 123 | <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 @@ |
| 1 | import { | 1 | import { |
| 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 moves | 48 | // 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 drag | 101 | // 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"; |
| 3 | 3 | ||
| 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. */ |
| 5 | export const THREAD_DETAILS_CARD_GAP = 12; | 5 | export const THREAD_DETAILS_CARD_GAP = 12; |
| 6 | // Keep in sync with --thread-details-panel-width, which sizes the popover and the find bar. | ||
| 7 | const THREAD_DETAILS_CARD_WIDTH = 280; | ||
| 6 | 8 | ||
| 7 | export function resolveThreadDetailsCardDensity( | 9 | export 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" |