| 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 | 7998 | const [isThreadFindActive, setIsThreadFindActive] = useState(false); |
| 7999 | 7999 | const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []); |
| 8000 | 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 | 8002 | useEffect(() => { |
| 8008 | 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 | 47 | minChatWidth: 640, |
| 48 | 48 | composerHeight: 0, |
| 49 | 49 | timelineGutter: 0, |
| 50 | findBarLeft: 0, | |
| 51 | findBarRight: 0, | |
| 52 | findBarBottom: 0, | |
| 50 | 53 | }); |
| 51 | 54 | const reportPreview = useCallback((next: ChatCanvasPreview) => { |
| 52 | 55 | setPreview((current) => |
| ... | ... | @@ -69,9 +72,16 @@ export function ChatCanvas({ |
| 69 | 72 | const element = elementRef.current; |
| 70 | 73 | const probe = widthProbeRef.current; |
| 71 | 74 | if (!element || !probe) return; |
| 75 | const findBar = | |
| 76 | detailsCardTopInset > 0 ? element.querySelector<HTMLElement>("[data-thread-find-bar]") : null; | |
| 72 | 77 | const measure = () => { |
| 78 | const canvasBounds = element.getBoundingClientRect(); | |
| 79 | const findBarBounds = findBar?.getBoundingClientRect(); | |
| 73 | 80 | const styles = getComputedStyle(probe); |
| 74 | 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 | 85 | width: element.clientWidth, |
| 76 | 86 | height: element.clientHeight, |
| 77 | 87 | padding: Number.parseFloat(styles.paddingLeft), |
| ... | ... | @@ -92,22 +102,37 @@ export function ChatCanvas({ |
| 92 | 102 | }; |
| 93 | 103 | measure(); |
| 94 | 104 | const observed: Element[] = [element, probe]; |
| 105 | if (findBar) observed.push(findBar); | |
| 95 | 106 | if (composerOverlayElement) observed.push(composerOverlayElement); |
| 96 | 107 | if (timelineElement) observed.push(timelineElement); |
| 97 | 108 | return observeResize(observed, measure); |
| 98 | }, [composerOverlayElement, timelineElement]); | |
| 109 | }, [composerOverlayElement, timelineElement, detailsCardTopInset]); | |
| 99 | 110 | const context = useMemo(() => { |
| 100 | 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 | 120 | return { |
| 102 | 121 | container, |
| 103 | 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 | 130 | previewKey: preview?.key ?? null, |
| 106 | 131 | reportPreview, |
| 107 | 132 | clearPreview, |
| 108 | 133 | registerTimeline, |
| 109 | 134 | reportDetailsCard, |
| 110 | detailsCardTopInset, | |
| 135 | detailsCardTopInset: findBar?.bottom ?? 0, | |
| 111 | 136 | }; |
| 112 | 137 | }, [ |
| 113 | 138 | measurements, |
apps/web/src/components/chat/ThreadDetailsCard.tsx+31-7| ... | ... | @@ -7,6 +7,7 @@ import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore |
| 7 | 7 | import type { ThreadPanelPresentation } from "../../rightPanelLayout"; |
| 8 | 8 | import { useChatCanvas } from "./ChatCanvasContext"; |
| 9 | 9 | import { |
| 10 | THREAD_DETAILS_CARD_GAP, | |
| 10 | 11 | resolveThreadDetailsCardDensity, |
| 11 | 12 | resolveThreadDetailsCardLayout, |
| 12 | 13 | } from "./threadDetailsCardLayout"; |
| ... | ... | @@ -45,6 +46,7 @@ export function ThreadDetailsCard({ |
| 45 | 46 | }) |
| 46 | 47 | : null; |
| 47 | 48 | const mode = placement ? "inline" : "popover"; |
| 49 | const topInset = canvas?.detailsCardTopInset ?? 0; | |
| 48 | 50 | const inlineOpen = useRightPanelStore((state) => |
| 49 | 51 | selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), |
| 50 | 52 | ); |
| ... | ... | @@ -137,17 +139,38 @@ export function ThreadDetailsCard({ |
| 137 | 139 | <Popover |
| 138 | 140 | handle={handle} |
| 139 | 141 | open={mode === "popover" && popoverOpen} |
| 140 | onOpenChange={(open) => | |
| 141 | useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open) | |
| 142 | } | |
| 142 | onOpenChange={(open, details) => { | |
| 143 | // The find bar sits beside the card, so clicking or focusing it does not | |
| 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 | 158 | {placement ? ( |
| 145 | 159 | inlineOpen ? ( |
| 146 | 160 | <aside |
| 147 | 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 | 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 | 174 | top: placement.y, |
| 152 | 175 | width: placement.width, |
| 153 | 176 | maxHeight: height, |
| ... | ... | @@ -162,10 +185,11 @@ export function ThreadDetailsCard({ |
| 162 | 185 | <PopoverPopup |
| 163 | 186 | anchor={anchor} |
| 164 | 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 | 190 | collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }} |
| 167 | 191 | side="bottom" |
| 168 | sideOffset={0} | |
| 192 | sideOffset={topInset} | |
| 169 | 193 | variant="panel" |
| 170 | 194 | padding="none" |
| 171 | 195 | > |
apps/web/src/components/chat/ThreadFindBar.tsx+63-57| ... | ... | @@ -59,65 +59,71 @@ export function ThreadFindBar(props: ThreadFindBarProps) { |
| 59 | 59 | }; |
| 60 | 60 | |
| 61 | 61 | return ( |
| 62 | <InputGroup | |
| 63 | variant="popover" | |
| 64 | size="lg" | |
| 65 | onContextMenu={(event) => event.stopPropagation()} | |
| 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))]" | |
| 62 | // Fades in while the details popover slides out from under it. | |
| 63 | <div | |
| 64 | data-thread-find-bar | |
| 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" | |
| 70 | 66 | > |
| 71 | <InputGroupInput | |
| 72 | ref={inputRef} | |
| 73 | type="search" | |
| 74 | size="sm" | |
| 75 | value={props.query} | |
| 67 | <InputGroup | |
| 68 | variant="popover" | |
| 69 | size="lg" | |
| 70 | onContextMenu={(event) => event.stopPropagation()} | |
| 71 | role="search" | |
| 76 | 72 | aria-label="Find in thread" |
| 77 | placeholder="Find in thread" | |
| 78 | maxLength={200} | |
| 79 | spellCheck={false} | |
| 80 | autoComplete="off" | |
| 81 | onChange={(event) => props.onQueryChange(event.target.value)} | |
| 82 | onKeyDown={handleKeyDown} | |
| 83 | /> | |
| 84 | <InputGroupAddon align="inline-end" inset="pill"> | |
| 85 | <span | |
| 86 | aria-live="polite" | |
| 87 | className={cn( | |
| 88 | "min-w-10 text-center text-xs tabular-nums", | |
| 89 | noResults ? "text-destructive" : "text-muted-foreground", | |
| 90 | )} | |
| 91 | > | |
| 92 | {label} | |
| 93 | </span> | |
| 94 | {props.status === "error" ? ( | |
| 95 | <Button size="xs" variant="ghost" onClick={props.onRetry}> | |
| 96 | Retry | |
| 73 | aria-busy={props.status === "loading" || props.counting === true} | |
| 74 | className="w-full" | |
| 75 | > | |
| 76 | <InputGroupInput | |
| 77 | ref={inputRef} | |
| 78 | type="search" | |
| 79 | size="sm" | |
| 80 | value={props.query} | |
| 81 | aria-label="Find in thread" | |
| 82 | placeholder="Find in thread" | |
| 83 | maxLength={200} | |
| 84 | spellCheck={false} | |
| 85 | autoComplete="off" | |
| 86 | onChange={(event) => props.onQueryChange(event.target.value)} | |
| 87 | onKeyDown={handleKeyDown} | |
| 88 | /> | |
| 89 | <InputGroupAddon align="inline-end" inset="pill"> | |
| 90 | <span | |
| 91 | aria-live="polite" | |
| 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 | 112 | </Button> |
| 98 | ) : null} | |
| 99 | <Button | |
| 100 | size="icon-xs" | |
| 101 | variant="ghost" | |
| 102 | aria-label="Previous match" | |
| 103 | disabled={navigationDisabled} | |
| 104 | onClick={props.onPrevious} | |
| 105 | > | |
| 106 | <ChevronUpIcon /> | |
| 107 | </Button> | |
| 108 | <Button | |
| 109 | size="icon-xs" | |
| 110 | variant="ghost" | |
| 111 | aria-label="Next match" | |
| 112 | disabled={navigationDisabled} | |
| 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> | |
| 113 | <Button | |
| 114 | size="icon-xs" | |
| 115 | variant="ghost" | |
| 116 | aria-label="Next match" | |
| 117 | disabled={navigationDisabled} | |
| 118 | onClick={props.onNext} | |
| 119 | > | |
| 120 | <ChevronDownIcon /> | |
| 121 | </Button> | |
| 122 | <Button size="icon-xs" variant="ghost" aria-label="Close find" onClick={props.onClose}> | |
| 123 | <XIcon /> | |
| 124 | </Button> | |
| 125 | </InputGroupAddon> | |
| 126 | </InputGroup> | |
| 127 | </div> | |
| 122 | 128 | ); |
| 123 | 129 | } |
apps/web/src/components/chat/ThreadFindProvider.tsx+3-2| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import { |
| 2 | 2 | createContext, |
| 3 | 3 | use, |
| 4 | useEffect, | |
| 5 | 4 | useImperativeHandle, |
| 5 | useLayoutEffect, | |
| 6 | 6 | type ReactNode, |
| 7 | 7 | type ComponentProps, |
| 8 | 8 | type Ref, |
| ... | ... | @@ -49,7 +49,8 @@ export function ThreadFindProvider({ |
| 49 | 49 | find.open, |
| 50 | 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 | 54 | return ( |
| 54 | 55 | <ThreadFindBarContext value={find.barProps}> |
| 55 | 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 | 135 | expect(result.overlapsDetailsCard).toBe(false); |
| 136 | 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 | 178 | it("uses space beside the full card if a tall preview cannot fit below it", () => { |
| 139 | 179 | const result = resolveChatCanvasLayout({ |
| 140 | 180 | container: { width: 1584, height: 988 }, |
apps/web/src/components/chat/chatCanvasLayout.ts+16-3| ... | ... | @@ -32,6 +32,7 @@ export function resolveChatCanvasLayout({ |
| 32 | 32 | minChatWidth = 640, |
| 33 | 33 | composerHeight = 0, |
| 34 | 34 | detailsCard = null, |
| 35 | findBar = null, | |
| 35 | 36 | }: { |
| 36 | 37 | container: PreviewMiniPlayerSize; |
| 37 | 38 | preview: ChatCanvasPreview | null; |
| ... | ... | @@ -40,6 +41,8 @@ export function resolveChatCanvasLayout({ |
| 40 | 41 | minChatWidth?: number; |
| 41 | 42 | composerHeight?: number; |
| 42 | 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 | 47 | const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2)); |
| 45 | 48 | // A workspace card that does not fit beside the centered chat first moves |
| ... | ... | @@ -90,13 +93,23 @@ export function resolveChatCanvasLayout({ |
| 90 | 93 | }); |
| 91 | 94 | } |
| 92 | 95 | } |
| 96 | const findBarObstacles = findBar ? { detailsCard: findBar, composer: null } : undefined; | |
| 93 | 97 | const preferredFrame = { |
| 94 | 98 | ...frame, |
| 95 | ...clampPreviewMiniPlayerPosition(frame, container, frame, undefined, minimumPreviewX), | |
| 99 | ...clampPreviewMiniPlayerPosition(frame, container, frame, findBarObstacles, minimumPreviewX), | |
| 96 | 100 | }; |
| 97 | 101 | // A resize keeps its anchored edge and consumes card height first. A drag |
| 98 | 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 | 113 | frame = { |
| 101 | 114 | ...frame, |
| 102 | 115 | ...clampPreviewMiniPlayerPosition( |
| ... | ... | @@ -166,7 +179,7 @@ export function resolveChatCanvasLayout({ |
| 166 | 179 | frame, |
| 167 | 180 | container, |
| 168 | 181 | frame, |
| 169 | { composer: obstacles.composer, detailsCard: null }, | |
| 182 | { composer: obstacles.composer, detailsCard: findBar }, | |
| 170 | 183 | minimumPreviewX, |
| 171 | 184 | ), |
| 172 | 185 | }; |
apps/web/src/components/chat/threadDetailsCardLayout.test.ts+3-2| ... | ... | @@ -16,6 +16,7 @@ describe("workspace card", () => { |
| 16 | 16 | it("pins to the top right at a fixed width", () => { |
| 17 | 17 | expect(resolve(1600, 900)).toEqual({ |
| 18 | 18 | x: 1308, |
| 19 | right: 12, | |
| 19 | 20 | y: 12, |
| 20 | 21 | width: 280, |
| 21 | 22 | height: 876, |
| ... | ... | @@ -30,7 +31,7 @@ describe("workspace card", () => { |
| 30 | 31 | frame: null, |
| 31 | 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 | 36 | it("hides when a readable chat lane cannot fit beside it", () => { |
| 36 | 37 | expect(resolve(1012, 900)).toMatchObject({ x: 720 }); |
| ... | ... | @@ -38,7 +39,7 @@ describe("workspace card", () => { |
| 38 | 39 | }); |
| 39 | 40 | it("keeps the card at the top right while the preview is freely dragged vertically", () => { |
| 40 | 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 | 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 | 3 | |
| 4 | 4 | /** Inset of the card from the canvas edges; the find bar shares it to line up. */ |
| 5 | 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 | 9 | export function resolveThreadDetailsCardDensity( |
| 8 | 10 | height: number, |
| ... | ... | @@ -32,8 +34,7 @@ export function resolveThreadDetailsCardLayout({ |
| 32 | 34 | topInset?: number; |
| 33 | 35 | }) { |
| 34 | 36 | const gap = THREAD_DETAILS_CARD_GAP; |
| 35 | // Keep in sync with --thread-details-panel-width, which sizes the popover. | |
| 36 | const width = 280; | |
| 37 | const width = THREAD_DETAILS_CARD_WIDTH; | |
| 37 | 38 | const x = container.width - width - gap; |
| 38 | 39 | if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null; |
| 39 | 40 | const y = gap + topInset; |
| ... | ... | @@ -46,6 +47,7 @@ export function resolveThreadDetailsCardLayout({ |
| 46 | 47 | if (height < 160) return null; |
| 47 | 48 | return { |
| 48 | 49 | x, |
| 50 | right: gap, | |
| 49 | 51 | width, |
| 50 | 52 | y, |
| 51 | 53 | height, |
apps/web/src/components/ui/popover.tsx+6-1| ... | ... | @@ -72,10 +72,15 @@ function PopoverPopup({ |
| 72 | 72 | alignOffset={alignOffset} |
| 73 | 73 | anchor={anchor} |
| 74 | 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 | 77 | className={cn( |
| 76 | 78 | "h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none", |
| 77 | 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 | 84 | : "z-[130]", |
| 80 | 85 | )} |
| 81 | 86 | data-slot="popover-positioner" |