1import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import { ScrollArea } from "../ui/scroll-area";
4import { cn } from "../../lib/utils";
5import { Popover, PopoverPopup, PopoverCreateHandle } from "../ui/popover";
6import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore";
7import type { ThreadPanelPresentation } from "../../rightPanelLayout";
8import { useChatCanvas } from "./ChatCanvasContext";
9import {
10 THREAD_DETAILS_CARD_GAP,
11 resolveThreadDetailsCardDensity,
12 resolveThreadDetailsCardLayout,
13} from "./threadDetailsCardLayout";
14import { observeResize } from "../../lib/observeResize";
15
16/** One card owns its placement and folds content only when that content cannot fit. */
17export function ThreadDetailsCard({
18 threadRef,
19 anchor,
20 handle,
21 onPresentationChange,
22 children,
23}: {
24 threadRef: ScopedThreadRef;
25 anchor: RefObject<Element | null>;
26 handle: ReturnType<typeof PopoverCreateHandle>;
27 onPresentationChange: (presentation: ThreadPanelPresentation) => void;
28 children: (density: "full" | "compact" | "essential") => ReactNode;
29}) {
30 const canvas = useChatCanvas();
31 const preferredPlacement = canvas
32 ? resolveThreadDetailsCardLayout({
33 container: canvas.container,
34 lane: canvas.lane,
35 frame: null,
36 topInset: canvas.detailsCardTopInset,
37 })
38 : null;
39 const placement = canvas
40 ? resolveThreadDetailsCardLayout({
41 container: canvas.container,
42 lane: canvas.lane,
43 frame: canvas.layout.frame,
44 overlapsDetailsCard: canvas.layout.overlapsDetailsCard,
45 topInset: canvas.detailsCardTopInset,
46 })
47 : null;
48 const mode = placement ? "inline" : "popover";
49 const topInset = canvas?.detailsCardTopInset ?? 0;
50 const inlineOpen = useRightPanelStore((state) =>
51 selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),
52 );
53 const popoverOpen = useRightPanelStore((state) =>
54 selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"),
55 );
56 const [contentElement, setContentElement] = useState<HTMLDivElement | null>(null);
57 const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`;
58 const [measurements, setMeasurements] = useState({
59 key: measurementKey,
60 heights: { full: 0, compact: 0 },
61 fullContentHeight: 0,
62 });
63 const contentHeights =
64 measurements.key === measurementKey ? measurements.heights : { full: 0, compact: 0 };
65 const height = placement?.height ?? Math.max(0, (canvas?.container.height ?? 0) - 52);
66 const density = resolveThreadDetailsCardDensity(height, contentHeights);
67 const reportDetailsCard = canvas?.reportDetailsCard;
68 const cardLeft = preferredPlacement?.x;
69 const cardRight = preferredPlacement
70 ? preferredPlacement.x + preferredPlacement.width
71 : undefined;
72 const cardBottom =
73 preferredPlacement && measurements.key === measurementKey && measurements.fullContentHeight > 0
74 ? preferredPlacement.y + Math.min(measurements.fullContentHeight, preferredPlacement.height)
75 : undefined;
76 useLayoutEffect(() => {
77 reportDetailsCard?.(
78 inlineOpen && cardLeft !== undefined && cardRight !== undefined && cardBottom !== undefined
79 ? { left: cardLeft, right: cardRight, bottom: cardBottom }
80 : null,
81 );
82 }, [reportDetailsCard, inlineOpen, cardLeft, cardRight, cardBottom]);
83 useLayoutEffect(() => () => reportDetailsCard?.(null), [reportDetailsCard]);
84 useLayoutEffect(() => {
85 onPresentationChange(mode);
86 if (mode === "inline" && popoverOpen)
87 useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", false);
88 }, [mode, onPresentationChange, threadRef, popoverOpen]);
89 useLayoutEffect(() => {
90 const element = contentElement;
91 if (!element || density === "essential") return;
92 // Measure the single content tree before the scroll viewport clips it. Retain each
93 // observed height so increasing available space restores the detail it can hold.
94 const measure = () => {
95 const frame = element.closest<HTMLElement>("[data-thread-details-card]");
96 const next = element.offsetHeight + (frame ? frame.offsetHeight - frame.clientHeight : 0);
97 // Lineage scrolls as it expands. Counting it toward density would hide
98 // the section and workspace controls when the user asks to see more rows.
99 const lineage = element.querySelector<HTMLElement>("[data-thread-relationships-panel]");
100 const fittingHeight = next - (lineage?.offsetHeight ?? 0);
101 setMeasurements((current) => {
102 const heights = current.key === measurementKey ? current.heights : { full: 0, compact: 0 };
103 const fullContentHeight =
104 density === "full"
105 ? next
106 : current.key === measurementKey
107 ? current.fullContentHeight
108 : 0;
109 return current.key === measurementKey &&
110 heights[density] === fittingHeight &&
111 current.fullContentHeight === fullContentHeight
112 ? current
113 : {
114 key: measurementKey,
115 heights: { ...heights, [density]: fittingHeight },
116 fullContentHeight,
117 };
118 });
119 };
120 measure();
121 return observeResize(element, measure);
122 }, [contentElement, density, measurementKey]);
123 const card = (
124 <div
125 className={cn(
126 "dropdown-glass isolate contain-paint grid max-h-full grid-rows-[minmax(0,1fr)] overflow-hidden rounded-3xl",
127 mode === "popover" &&
128 "max-h-[min(calc(100dvh-6.5rem),calc(var(--available-height,100dvh)-1rem))]",
129 )}
130 style={placement ? { maxHeight: height } : undefined}
131 data-thread-details-card
132 >
133 <ScrollArea scrollFade className="min-h-0">
134 <div ref={setContentElement}>{children(density)}</div>
135 </ScrollArea>
136 </div>
137 );
138 return (
139 <Popover
140 handle={handle}
141 open={mode === "popover" && popoverOpen}
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 }}
157 >
158 {placement ? (
159 inlineOpen ? (
160 <aside
161 aria-label="Thread details"
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 )}
170 style={{
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,
174 top: placement.y,
175 width: placement.width,
176 maxHeight: height,
177 }}
178 data-density={density}
179 data-thread-details-panel="inline"
180 >
181 {card}
182 </aside>
183 ) : null
184 ) : (
185 <PopoverPopup
186 anchor={anchor}
187 align="end"
188 // Lines the card up with the find bar: the panel pads its card by 8px.
189 alignOffset={THREAD_DETAILS_CARD_GAP - 8}
190 collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}
191 side="bottom"
192 sideOffset={topInset}
193 variant="panel"
194 padding="none"
195 >
196 <div data-density={density} data-thread-details-panel="popover">
197 {card}
198 </div>
199 </PopoverPopup>
200 )}
201 </Popover>
202 );
203}