1import {
2 createContext,
3 use,
4 useLayoutEffect,
5 useRef,
6 type ComponentProps,
7 type ReactNode,
8} from "react";
9import { cn } from "../../lib/utils";
10
11const GroupedRows = createContext(false);
12
13/** Groups may span virtualized timeline items. Each part owns its trailing space. */
14export function WorkLogBlock({
15 layout = "standalone",
16 continues = false,
17 children,
18}: {
19 layout?: "standalone" | "group-header" | "group-content";
20 continues?: boolean | undefined;
21 children: ReactNode;
22}) {
23 return (
24 <div
25 className={
26 continues || layout === "group-header"
27 ? "pb-0"
28 : layout === "group-content"
29 ? "pb-1"
30 : "pb-2"
31 }
32 >
33 {children}
34 </div>
35 );
36}
37
38/** Expanded members align with the header and use the same compact row geometry. */
39export function WorkLogList({ children }: { children: ReactNode }) {
40 return (
41 <GroupedRows value>
42 <div className="flex min-w-0 flex-col">{children}</div>
43 </GroupedRows>
44 );
45}
46
47type RowContent = {
48 icon?: ReactNode;
49 label: ReactNode;
50 trailing?: ReactNode;
51 wrapLabel?: boolean;
52};
53
54function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) {
55 return (
56 <div
57 className={cn(
58 "flex min-h-6 min-w-0 gap-1.5 text-sm leading-relaxed select-none [&_*]:select-none",
59 wrapLabel ? "items-start" : "items-center",
60 )}
61 >
62 <div className="flex min-w-0 flex-1 items-baseline gap-1.5">
63 {icon ? (
64 // Align with the first line's capital letters. Centering line boxes drifts
65 // when font ascent/descent rounding changes at fractional browser zoom.
66 <span className="relative w-6 shrink-0 leading-0">
67 <span className="inline-block size-6 overflow-hidden align-[calc(0.5cap_-_0.75rem)]">
68 <span className="flex size-full items-center justify-center">{icon}</span>
69 </span>
70 </span>
71 ) : null}
72 <div
73 className={cn(
74 "min-w-0 flex-1 text-secondary-label",
75 !wrapLabel && "truncate [&_*]:whitespace-nowrap",
76 )}
77 >
78 {label}
79 </div>
80 </div>
81 {trailing}
82 </div>
83 );
84}
85
86const interactionClassName =
87 "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70";
88
89function useRowClassName(interactive: boolean) {
90 const grouped = use(GroupedRows);
91 return cn(
92 "group/timeline-row relative w-full min-w-0 rounded-md px-0.5 text-left transition-colors",
93 grouped ? "py-0" : "py-0.5",
94 interactive && interactionClassName,
95 );
96}
97
98// No className/style escape hatch: consumers supply content and behavior, not geometry.
99export function WorkLogButton({
100 icon,
101 label,
102 trailing,
103 wrapLabel = false,
104 ...buttonProps
105}: RowContent & Omit<ComponentProps<"button">, "className" | "style" | "children">) {
106 const className = useRowClassName(true);
107 return (
108 <button {...buttonProps} type="button" className={className}>
109 <WorkLogLine icon={icon} label={label} trailing={trailing} wrapLabel={wrapLabel} />
110 </button>
111 );
112}
113
114/** Div-based disclosure supports selectable detail and nested actions in an entry. */
115export function WorkLogRow({
116 icon,
117 label,
118 trailing,
119 wrapLabel = false,
120 children,
121 ...rowProps
122}: RowContent & Omit<ComponentProps<"div">, "className" | "style">) {
123 const className = useRowClassName(rowProps.onClick !== undefined);
124 return (
125 <div {...rowProps} className={className}>
126 <WorkLogLine icon={icon} label={label} trailing={trailing} wrapLabel={wrapLabel} />
127 {children}
128 </div>
129 );
130}
131
132/** Distance from the end, in px, that still counts as reading the newest text. */
133const LIVE_EDGE_PX = 4;
134
135export function WorkLogDetails({
136 children,
137 kind = "text",
138 followLive = false,
139}: {
140 children: ReactNode;
141 kind?: "text" | "panel" | "media";
142 /** Keep streamed text in view. Scrolling up pauses this until the reader returns to the end. */
143 followLive?: boolean;
144}) {
145 const scrollerRef = useRef<HTMLDivElement>(null);
146 const atLiveEdgeRef = useRef(true);
147 // No deps: each streamed chunk re-renders this, and pinning before paint
148 // shows the new text without a visible jump.
149 useLayoutEffect(() => {
150 const scroller = scrollerRef.current;
151 if (followLive && atLiveEdgeRef.current && scroller) {
152 scroller.scrollTop = scroller.scrollHeight;
153 }
154 });
155 return (
156 <div
157 ref={scrollerRef}
158 onScroll={(event) => {
159 const { scrollHeight, scrollTop, clientHeight } = event.currentTarget;
160 atLiveEdgeRef.current = scrollHeight - scrollTop - clientHeight <= LIVE_EDGE_PX;
161 }}
162 className={cn(
163 "cursor-auto",
164 kind === "text"
165 ? "ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text"
166 : kind === "panel"
167 ? "mt-0.5 mb-1.5 pb-2"
168 : "mt-1",
169 )}
170 onClick={(event) => event.stopPropagation()}
171 onPointerDown={(event) => event.stopPropagation()}
172 >
173 {children}
174 </div>
175 );
176}