@vuer-ai/vuer-uikit 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/{chunk-CP7QYV4N.mjs → chunk-44KW6PTD.mjs} +1 -0
  2. package/dist/{chunk-KQAHAGUG.js → chunk-AOUGWGQR.js} +2 -2
  3. package/dist/{chunk-J7PTSP4Q.mjs → chunk-EAZQH4IX.mjs} +8 -0
  4. package/dist/chunk-EOFYA6CS.js +53 -0
  5. package/dist/{chunk-O4TK3AKI.mjs → chunk-EYIFKSU2.mjs} +1 -1
  6. package/dist/{chunk-WMXX2UMD.mjs → chunk-FEOONYZ2.mjs} +2 -2
  7. package/dist/{chunk-66DYOZI3.js → chunk-FORVP7XH.js} +6 -5
  8. package/dist/{chunk-7X6N54LX.mjs → chunk-G5KMD72J.mjs} +6 -5
  9. package/dist/{chunk-5MZCQW4U.js → chunk-IXHMIR5P.js} +4 -4
  10. package/dist/{chunk-CCGUGCCV.js → chunk-JAJNLRY5.js} +9 -9
  11. package/dist/{chunk-WOZWC6KO.mjs → chunk-JMPVDU2C.mjs} +35 -4
  12. package/dist/{chunk-E5KWKBH3.mjs → chunk-LEPXSM36.mjs} +10 -24
  13. package/dist/{chunk-3XVIJY3H.mjs → chunk-MRVMXKZO.mjs} +1 -1
  14. package/dist/{chunk-UMIQYHO6.mjs → chunk-OOI3SJDU.mjs} +10 -4
  15. package/dist/{chunk-B5EMQBVL.js → chunk-OTPUWWEB.js} +8 -3
  16. package/dist/{chunk-FSMKJAZT.js → chunk-PVRQNCSJ.js} +9 -23
  17. package/dist/chunk-Q5KEB4UK.mjs +51 -0
  18. package/dist/{chunk-XUQ5A7ZA.js → chunk-QEY7BWIR.js} +10 -4
  19. package/dist/{chunk-QZASHJNJ.js → chunk-QJR6MCTR.js} +8 -0
  20. package/dist/{chunk-IOI3YFTY.js → chunk-U2ZLDPOE.js} +8 -8
  21. package/dist/{chunk-ZJXBC33M.js → chunk-UFHMEEGL.js} +1 -0
  22. package/dist/{chunk-5P3KAS53.js → chunk-WJ7T7FAH.js} +34 -3
  23. package/dist/{chunk-6L4KPPOD.mjs → chunk-XMG3IVGZ.mjs} +8 -3
  24. package/dist/{chunk-XCCC7HVN.mjs → chunk-ZVG2UKKC.mjs} +1 -1
  25. package/dist/highlight-cursor/cursor-provider.js +2 -2
  26. package/dist/highlight-cursor/cursor-provider.mjs +1 -1
  27. package/dist/highlight-cursor/enhanced-components.js +11 -10
  28. package/dist/highlight-cursor/enhanced-components.mjs +6 -5
  29. package/dist/highlight-cursor/index.js +15 -14
  30. package/dist/highlight-cursor/index.mjs +7 -6
  31. package/dist/highlight-cursor/tabs-cursor-context.js +3 -3
  32. package/dist/highlight-cursor/tabs-cursor-context.mjs +1 -1
  33. package/dist/highlight-cursor/with-cursor.js +2 -2
  34. package/dist/highlight-cursor/with-cursor.mjs +1 -1
  35. package/dist/index.d.mts +1 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +64 -59
  38. package/dist/index.mjs +15 -14
  39. package/dist/ui/avatar.js +2 -1
  40. package/dist/ui/avatar.mjs +2 -1
  41. package/dist/ui/badge.js +2 -1
  42. package/dist/ui/badge.mjs +2 -1
  43. package/dist/ui/button.js +2 -1
  44. package/dist/ui/button.mjs +2 -1
  45. package/dist/ui/card.js +2 -1
  46. package/dist/ui/card.mjs +2 -1
  47. package/dist/ui/checkbox.js +2 -1
  48. package/dist/ui/checkbox.mjs +2 -1
  49. package/dist/ui/collapsible.js +2 -1
  50. package/dist/ui/collapsible.mjs +2 -1
  51. package/dist/ui/dropdown.js +2 -1
  52. package/dist/ui/dropdown.mjs +2 -1
  53. package/dist/ui/index.js +49 -48
  54. package/dist/ui/index.mjs +12 -11
  55. package/dist/ui/input-numbers.js +4 -3
  56. package/dist/ui/input-numbers.mjs +3 -2
  57. package/dist/ui/input.js +2 -1
  58. package/dist/ui/input.mjs +2 -1
  59. package/dist/ui/layout.js +2 -1
  60. package/dist/ui/layout.mjs +2 -1
  61. package/dist/ui/modal.js +2 -1
  62. package/dist/ui/modal.mjs +2 -1
  63. package/dist/ui/popover.js +2 -1
  64. package/dist/ui/popover.mjs +2 -1
  65. package/dist/ui/radio-group.js +2 -1
  66. package/dist/ui/radio-group.mjs +2 -1
  67. package/dist/ui/select.js +2 -1
  68. package/dist/ui/select.mjs +2 -1
  69. package/dist/ui/slider.js +2 -1
  70. package/dist/ui/slider.mjs +2 -1
  71. package/dist/ui/switch.js +2 -1
  72. package/dist/ui/switch.mjs +2 -1
  73. package/dist/ui/tabs.js +8 -7
  74. package/dist/ui/tabs.mjs +4 -3
  75. package/dist/ui/textarea.js +2 -1
  76. package/dist/ui/textarea.mjs +2 -1
  77. package/dist/ui/theme-context.js +6 -3
  78. package/dist/ui/theme-context.mjs +4 -1
  79. package/dist/ui/timeline.js +6 -5
  80. package/dist/ui/timeline.mjs +5 -4
  81. package/dist/ui/toast.d.mts +1 -1
  82. package/dist/ui/toast.d.ts +1 -1
  83. package/dist/ui/toast.js +3 -3
  84. package/dist/ui/toast.mjs +1 -1
  85. package/dist/ui/toggle-group.js +2 -1
  86. package/dist/ui/toggle-group.mjs +2 -1
  87. package/dist/ui/toggle.js +2 -1
  88. package/dist/ui/toggle.mjs +2 -1
  89. package/dist/ui/tooltip.d.mts +3 -3
  90. package/dist/ui/tooltip.d.ts +3 -3
  91. package/dist/ui/tooltip.js +7 -6
  92. package/dist/ui/tooltip.mjs +3 -2
  93. package/dist/ui/tree-view-v2.js +5 -4
  94. package/dist/ui/tree-view-v2.mjs +4 -3
  95. package/dist/ui/version-badge.js +2 -1
  96. package/dist/ui/version-badge.mjs +2 -1
  97. package/dist/utils/index.d.mts +1 -0
  98. package/dist/utils/index.d.ts +1 -0
  99. package/dist/utils/index.js +6 -1
  100. package/dist/utils/index.mjs +2 -1
  101. package/dist/utils/use-local-storage.d.mts +3 -0
  102. package/dist/utils/use-local-storage.d.ts +3 -0
  103. package/dist/utils/use-local-storage.js +10 -0
  104. package/dist/utils/use-local-storage.mjs +1 -0
  105. package/package.json +6 -4
  106. package/src/highlight-cursor/cursor-context.tsx +23 -0
  107. package/src/highlight-cursor/cursor-provider.tsx +264 -0
  108. package/src/highlight-cursor/enhanced-components.tsx +16 -0
  109. package/src/highlight-cursor/index.ts +21 -0
  110. package/src/highlight-cursor/tabs-cursor-context.tsx +121 -0
  111. package/src/highlight-cursor/types.ts +40 -0
  112. package/src/highlight-cursor/with-cursor.tsx +144 -0
  113. package/src/index.css +5 -0
  114. package/src/index.ts +5 -0
  115. package/src/styles/theme.css +80 -0
  116. package/src/styles/toast.css +64 -0
  117. package/src/styles/variables.css +194 -0
  118. package/src/ui/avatar.tsx +92 -0
  119. package/src/ui/badge.tsx +68 -0
  120. package/src/ui/button.tsx +100 -0
  121. package/src/ui/card.tsx +88 -0
  122. package/src/ui/checkbox.tsx +76 -0
  123. package/src/ui/collapsible.tsx +36 -0
  124. package/src/ui/dropdown.tsx +375 -0
  125. package/src/ui/index.ts +31 -0
  126. package/src/ui/input-numbers.tsx +201 -0
  127. package/src/ui/input.tsx +141 -0
  128. package/src/ui/layout.tsx +41 -0
  129. package/src/ui/modal.tsx +198 -0
  130. package/src/ui/popover.tsx +59 -0
  131. package/src/ui/radio-group.tsx +56 -0
  132. package/src/ui/select.tsx +292 -0
  133. package/src/ui/sheet.tsx +131 -0
  134. package/src/ui/slider.tsx +189 -0
  135. package/src/ui/switch.tsx +43 -0
  136. package/src/ui/tabs.tsx +128 -0
  137. package/src/ui/textarea.tsx +52 -0
  138. package/src/ui/theme-context.tsx +80 -0
  139. package/src/ui/timeline.tsx +717 -0
  140. package/src/ui/toast.tsx +27 -0
  141. package/src/ui/toggle-group.tsx +82 -0
  142. package/src/ui/toggle.tsx +88 -0
  143. package/src/ui/tooltip.tsx +132 -0
  144. package/src/ui/tree-view-v2.tsx +300 -0
  145. package/src/ui/tree-view.tsx +550 -0
  146. package/src/ui/version-badge.tsx +65 -0
  147. package/src/utils/cn.ts +21 -0
  148. package/src/utils/index.ts +2 -0
  149. package/src/utils/use-local-storage.ts +59 -0
  150. package/dist/chunk-OBWGRG45.js +0 -21
  151. package/dist/chunk-Z6XRPTIQ.mjs +0 -15
  152. /package/dist/{chunk-QS7WS6DD.mjs → chunk-M4IVLZIH.mjs} +0 -0
  153. /package/dist/{chunk-ST4P6AN6.js → chunk-XWS4SIB5.js} +0 -0
@@ -0,0 +1,717 @@
1
+ import { ChevronLeft, ChevronRight, Magnet } from "lucide-react";
2
+ import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
3
+
4
+ import { TreeViewV2, type TreeDataItemV2 } from "./tree-view-v2";
5
+ import { cn } from "../utils/cn";
6
+
7
+ type LogItemData = TreeDataItemV2 & {
8
+ type: "task" | "attempt" | "info" | "step";
9
+ icon?: "history" | "file-code" | "bot" | "check-circle" | "pause-circle";
10
+ createTime?: number;
11
+ startTime?: number;
12
+ duration?: number;
13
+ time?: number;
14
+ color?: "blue" | "green" | "orange" | "gray-light" | "gray-medium" | "purple";
15
+ hasStripes?: boolean;
16
+ isHalted?: boolean;
17
+ };
18
+
19
+ type TimelineProps = {
20
+ data: LogItemData[];
21
+ totalDuration: number;
22
+ getIcon: (item: LogItemData) => React.ReactNode;
23
+ };
24
+
25
+ const formatDuration = (seconds: number): string => {
26
+ const sign = seconds < 0 ? "-" : "";
27
+ const absSeconds = Math.abs(seconds);
28
+
29
+ if (absSeconds < 1) {
30
+ if (absSeconds < 0.0005) return "0s";
31
+ return `${sign}${Math.round(absSeconds * 1000)}ms`;
32
+ }
33
+ if (absSeconds < 60) {
34
+ return `${sign}${absSeconds.toFixed(3)}s`;
35
+ }
36
+
37
+ const SECONDS_IN_MINUTE = 60;
38
+ const SECONDS_IN_HOUR = 3600;
39
+ const SECONDS_IN_DAY = 86400;
40
+ const SECONDS_IN_MONTH = 2592000; // 30 days
41
+ const SECONDS_IN_YEAR = 31536000; // 365 days
42
+
43
+ if (absSeconds < SECONDS_IN_HOUR) {
44
+ const m = Math.floor(absSeconds / SECONDS_IN_MINUTE);
45
+ const s = absSeconds % SECONDS_IN_MINUTE;
46
+ return `${sign}${m}m ${s.toFixed(2).padStart(5, "0")}s`;
47
+ }
48
+
49
+ if (absSeconds < SECONDS_IN_DAY) {
50
+ const h = Math.floor(absSeconds / SECONDS_IN_HOUR);
51
+ const m = Math.floor((absSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE);
52
+ const s = Math.floor(absSeconds % SECONDS_IN_MINUTE);
53
+ return `${sign}${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
54
+ }
55
+
56
+ if (absSeconds < SECONDS_IN_MONTH) {
57
+ const d = Math.floor(absSeconds / SECONDS_IN_DAY);
58
+ const h = Math.floor((absSeconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
59
+ const m = Math.floor((absSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE);
60
+ return `${sign}${d}d ${String(h).padStart(2, "0")}h ${String(m).padStart(2, "0")}m`;
61
+ }
62
+
63
+ if (absSeconds < SECONDS_IN_YEAR) {
64
+ const mo = Math.floor(absSeconds / SECONDS_IN_MONTH);
65
+ const d = Math.floor((absSeconds % SECONDS_IN_MONTH) / SECONDS_IN_DAY);
66
+ const h = Math.floor((absSeconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
67
+ return `${sign}${mo}mo ${String(d).padStart(3, "0")}d ${String(h).padStart(2, "0")}h`;
68
+ }
69
+
70
+ const y = Math.floor(absSeconds / SECONDS_IN_YEAR);
71
+ const mo = Math.floor((absSeconds % SECONDS_IN_YEAR) / SECONDS_IN_MONTH);
72
+ const d = Math.floor((absSeconds % SECONDS_IN_MONTH) / SECONDS_IN_DAY);
73
+ return `${sign}${y}y ${String(mo).padStart(2, "0")}mo ${String(d).padStart(3, "0")}d`;
74
+ };
75
+
76
+ const borderColorClasses = {
77
+ blue: "border-blue-500/85",
78
+ green: "border-green-500/85",
79
+ orange: "border-orange-500/85",
80
+ purple: "border-purple-500/85",
81
+ "gray-light": "border-line-secondary/20",
82
+ "gray-medium": "border-slate-500/85",
83
+ };
84
+
85
+ const colorClasses = {
86
+ blue: "bg-blue-500/85",
87
+ green: "bg-green-500/85",
88
+ orange: "bg-orange-500/85",
89
+ purple: "bg-purple-500/85",
90
+ "gray-light": "bg-line-secondary/20",
91
+ "gray-medium": "bg-slate-500/85",
92
+ };
93
+
94
+ const leftWedgeClasses = {
95
+ blue: "border-l-blue-500/85",
96
+ green: "border-l-green-500/85",
97
+ orange: "border-l-orange-500/85",
98
+ purple: "border-l-purple-500/85",
99
+ "gray-light": "border-l-slate-300",
100
+ "gray-medium": "border-l-slate-500/85",
101
+ };
102
+
103
+ const rightWedgeClasses = {
104
+ blue: "border-r-blue-500/85",
105
+ green: "border-r-green-500/85",
106
+ orange: "border-r-orange-500/85",
107
+ purple: "border-r-purple-500/85",
108
+ "gray-light": "border-r-slate-300",
109
+ "gray-medium": "border-r-slate-500/85",
110
+ };
111
+
112
+ function Timeline({ data, totalDuration, getIcon }: TimelineProps) {
113
+ const [visibleData, setVisibleData] = useState<LogItemData[]>([]);
114
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
115
+
116
+ const timelineContainerRef = useRef<HTMLDivElement>(null);
117
+ const [viewStart, setViewStart] = useState(-totalDuration * 0.25);
118
+ const [viewDuration, setViewDuration] = useState(totalDuration * 1.5);
119
+
120
+ const timeToPercent = useCallback(
121
+ (time: number) => ((time - viewStart) / viewDuration) * 100,
122
+ [viewStart, viewDuration],
123
+ );
124
+
125
+ const handlePan = (direction: "left" | "right") => {
126
+ const panAmount = viewDuration * 0.1;
127
+ if (direction === "left") {
128
+ setViewStart((s) => s - panAmount);
129
+ } else {
130
+ setViewStart((s) => s + panAmount);
131
+ }
132
+ };
133
+
134
+ const timeMarkers = useMemo(() => {
135
+ const markers: { time: number; label: string }[] = [];
136
+ const niceIntervals = [
137
+ 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100,
138
+ ];
139
+ const targetMarkerCount = 10;
140
+ const rawInterval = viewDuration / targetMarkerCount;
141
+ const interval =
142
+ niceIntervals.find((i) => i > rawInterval) || niceIntervals[niceIntervals.length - 1];
143
+
144
+ const viewEnd = viewStart + viewDuration;
145
+ const shownSeconds = new Set<number>();
146
+
147
+ const formatTickLabel = (seconds: number) => {
148
+ const sign = seconds < 0 ? "-" : "";
149
+ const absSeconds = Math.abs(seconds);
150
+
151
+ let s = Math.floor(absSeconds);
152
+ let ms = Math.round((absSeconds - s) * 1000);
153
+
154
+ if (ms >= 1000) {
155
+ s += 1;
156
+ ms -= 1000;
157
+ }
158
+
159
+ if (absSeconds < 1 && absSeconds > -1) {
160
+ return `${sign}${ms}ms`;
161
+ }
162
+
163
+ const baseSecond = s * (sign === "-" ? -1 : 1);
164
+ if (shownSeconds.has(baseSecond)) {
165
+ return ms > 0 ? `+${ms}ms` : "";
166
+ }
167
+
168
+ shownSeconds.add(baseSecond);
169
+ if (ms === 0) {
170
+ return `${sign}${s}s`;
171
+ }
172
+
173
+ return `${sign}${s}s`;
174
+ };
175
+
176
+ const firstMarkerTime = Math.floor(viewStart / interval) * interval;
177
+ const lastMarkerTime = Math.ceil(viewEnd / interval) * interval;
178
+
179
+ for (let time = firstMarkerTime; time <= lastMarkerTime; time += interval) {
180
+ const roundedTime = Number.parseFloat(time.toPrecision(15));
181
+ const label = formatTickLabel(roundedTime);
182
+ if (label) {
183
+ markers.push({ time: roundedTime, label });
184
+ }
185
+ }
186
+ return markers;
187
+ }, [viewStart, viewDuration]);
188
+
189
+ const keyEventTimes = useMemo(() => {
190
+ const events: { time: number; type: string }[] = [];
191
+ const timeSet = new Set<string>();
192
+
193
+ const addEvent = (time: number, type: string) => {
194
+ const key = `${time.toFixed(6)}-${type}`;
195
+ if (!timeSet.has(key)) {
196
+ events.push({ time, type });
197
+ timeSet.add(key);
198
+ }
199
+ };
200
+
201
+ visibleData.forEach((item) => {
202
+ if (item.createTime !== undefined) addEvent(item.createTime, "create");
203
+ if (item.startTime !== undefined) addEvent(item.startTime, "start");
204
+ if (item.startTime !== undefined && item.duration !== undefined) {
205
+ addEvent(item.startTime + item.duration, "end");
206
+ }
207
+ if (item.time !== undefined) addEvent(item.time, "event");
208
+ });
209
+ return events.sort((a, b) => a.time - b.time);
210
+ }, [visibleData]);
211
+
212
+ const cursorContainerRef = useRef<HTMLDivElement>(null);
213
+ const lastClientX = useRef(0);
214
+ const isMouseOver = useRef(false);
215
+ const animationFrameRef = useRef<number | null>(null);
216
+
217
+ const updateCursor = useCallback(() => {
218
+ if (!isMouseOver.current) return;
219
+
220
+ const timelineEl = timelineContainerRef.current;
221
+ const cursorEl = cursorContainerRef.current;
222
+ if (!timelineEl || !cursorEl) return;
223
+
224
+ const rect = timelineEl.getBoundingClientRect();
225
+ const cursorX = lastClientX.current - rect.left;
226
+ const rawHoverTime = viewStart + (cursorX / timelineEl.offsetWidth) * viewDuration;
227
+
228
+ // Snapping logic
229
+ const snapThresholdInPixels = 8;
230
+ const snapThresholdInTime = (snapThresholdInPixels / timelineEl.offsetWidth) * viewDuration;
231
+
232
+ let closestSnap: { time: number; type: string } | null = null;
233
+ let minDistance = Number.POSITIVE_INFINITY;
234
+
235
+ for (const event of keyEventTimes) {
236
+ const distance = Math.abs(event.time - rawHoverTime);
237
+ if (distance < minDistance && distance < snapThresholdInTime) {
238
+ minDistance = distance;
239
+ closestSnap = event;
240
+ }
241
+ }
242
+
243
+ const displayTime = closestSnap?.time ?? rawHoverTime;
244
+ const percent = timeToPercent(displayTime);
245
+
246
+ cursorEl.style.setProperty("--cursor-left", `${percent}%`);
247
+ cursorEl.style.setProperty("--readout-text", `"${formatDuration(displayTime)}"`);
248
+ cursorEl.style.setProperty("--magnet-opacity", closestSnap ? "1" : "0");
249
+ cursorEl.classList.remove("opacity-0");
250
+ }, [viewStart, viewDuration, keyEventTimes, timeToPercent]);
251
+
252
+ useEffect(() => {
253
+ const timelineEl = timelineContainerRef.current;
254
+ if (!timelineEl) return;
255
+
256
+ const handleMouseMove = (e: MouseEvent) => {
257
+ lastClientX.current = e.clientX;
258
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
259
+ animationFrameRef.current = requestAnimationFrame(updateCursor);
260
+ };
261
+
262
+ const handleMouseEnter = () => {
263
+ isMouseOver.current = true;
264
+ };
265
+
266
+ const handleMouseLeave = () => {
267
+ isMouseOver.current = false;
268
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
269
+ cursorContainerRef.current?.classList.add("opacity-0");
270
+ };
271
+
272
+ timelineEl.addEventListener("mousemove", handleMouseMove);
273
+ timelineEl.addEventListener("mouseenter", handleMouseEnter);
274
+ timelineEl.addEventListener("mouseleave", handleMouseLeave);
275
+
276
+ return () => {
277
+ timelineEl.removeEventListener("mousemove", handleMouseMove);
278
+ timelineEl.removeEventListener("mouseenter", handleMouseEnter);
279
+ timelineEl.removeEventListener("mouseleave", handleMouseLeave);
280
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
281
+ };
282
+ }, [updateCursor]);
283
+
284
+ // Handle wheel events with passive: false to allow preventDefault
285
+ useEffect(() => {
286
+ const timelineEl = timelineContainerRef.current;
287
+ if (!timelineEl) return;
288
+
289
+ const wheelHandler = (e: WheelEvent) => {
290
+ e.preventDefault();
291
+ const container = timelineContainerRef.current;
292
+ if (!container) return;
293
+
294
+ if (e.ctrlKey || e.altKey) {
295
+ // Zooming
296
+ const rect = container.getBoundingClientRect();
297
+ const cursorX = e.clientX - rect.left;
298
+ const timeAtCursor = viewStart + (cursorX / container.offsetWidth) * viewDuration;
299
+ const zoomFactor = 1.1;
300
+ const newDuration = e.deltaY < 0 ? viewDuration / zoomFactor : viewDuration * zoomFactor;
301
+ const minDuration = 0.01; // 10ms
302
+ const maxDuration = totalDuration * 10;
303
+ if (newDuration < minDuration || newDuration > maxDuration) return;
304
+ const newViewStart = timeAtCursor - (cursorX / container.offsetWidth) * newDuration;
305
+ setViewDuration(newDuration);
306
+ setViewStart(newViewStart);
307
+ } else {
308
+ // Panning
309
+ const panX = (e.deltaX / container.offsetWidth) * viewDuration;
310
+ const panY = (e.deltaY / container.offsetWidth) * viewDuration;
311
+ setViewStart((s) => s + panX + panY);
312
+ }
313
+ };
314
+
315
+ timelineEl.addEventListener("wheel", wheelHandler, { passive: false });
316
+
317
+ return () => {
318
+ timelineEl.removeEventListener("wheel", wheelHandler);
319
+ };
320
+ }, [viewStart, viewDuration, totalDuration]);
321
+
322
+ // This effect handles cursor updates on pan/zoom
323
+ useEffect(() => {
324
+ if (isMouseOver.current) {
325
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
326
+ animationFrameRef.current = requestAnimationFrame(updateCursor);
327
+ }
328
+ }, [viewStart, viewDuration, updateCursor]);
329
+
330
+ return (
331
+ <div className="bg-bg-primary text-text-primary border-line-secondary/20 rounded-uk-md mx-auto w-full max-w-7xl overflow-hidden border font-sans shadow-2xl">
332
+ <div className="grid grid-cols-[minmax(300px,30%)_1fr]">
333
+ <TreeViewV2
334
+ data={data}
335
+ getIcon={getIcon}
336
+ onVisibleDataChange={setVisibleData}
337
+ onItemHover={setHoveredId}
338
+ hoveredId={hoveredId}
339
+ />
340
+
341
+ {/* Timeline */}
342
+ <div className="relative">
343
+ <div
344
+ className="border-line-secondary/20 scrollbar-hide relative cursor-crosshair overflow-x-auto border-l active:cursor-grabbing"
345
+ ref={timelineContainerRef}
346
+ >
347
+ {/* Time Ruler timeline */}
348
+ <div className="bg-bg-primary sticky top-0 z-10">
349
+ <div className="border-line-secondary/20 relative h-8 border-b">
350
+ {/* These dots on the ruler indicate the positions of key events that the cursor can snap to. */}
351
+ {keyEventTimes.map(({ time }, tickIndex) => {
352
+ const percent = timeToPercent(time);
353
+ if (percent < 0 || percent > 100) return null;
354
+ return (
355
+ <div
356
+ key={`snap-${time}-${tickIndex}`}
357
+ className="bg-line-secondary/20 absolute top-1/2 z-0 h-1 w-1 -translate-y-1/2 rounded-full"
358
+ style={{ left: `${percent}%` }}
359
+ />
360
+ );
361
+ })}
362
+
363
+ {timeMarkers.map((marker, ind) => {
364
+ const naturalCenterPercent = timeToPercent(marker.time);
365
+
366
+ // Filter out markers that are too far off-screen to improve performance
367
+ if (naturalCenterPercent < -20 || naturalCenterPercent > 120) {
368
+ return null;
369
+ }
370
+
371
+ // This is the offset from the edge, in percent.
372
+ // It ensures the label doesn't sit flush against the edge.
373
+ const labelHalfWidthPercent = 3;
374
+
375
+ // Clamp the label's center position to keep it within the viewport
376
+ const clampedCenterPercent = Math.min(
377
+ 100 - labelHalfWidthPercent,
378
+ Math.max(labelHalfWidthPercent, naturalCenterPercent),
379
+ );
380
+
381
+ const zInd = ind < timeMarkers.length - 1 ? "10" : "0";
382
+
383
+ return (
384
+ <React.Fragment key={marker.time}>
385
+ {/* The tick line, always at its natural position */}
386
+ <div
387
+ className="bg-border bg-line-secondary/20 absolute top-0 h-full w-px"
388
+ style={{ left: `${naturalCenterPercent}%` }}
389
+ />
390
+ {/* The label, with its position clamped to the viewport edges */}
391
+ <div
392
+ className={cn(
393
+ "bg-bg-primary text-text-secondary rounded-uk-xs text-uk-sm pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2 px-1 backdrop-blur-sm",
394
+ "z-" + zInd,
395
+ )}
396
+ style={{ left: `${clampedCenterPercent}%` }}
397
+ >
398
+ {marker.label}
399
+ </div>
400
+ </React.Fragment>
401
+ );
402
+ })}
403
+ </div>
404
+ </div>
405
+
406
+ {/* Timeline Bars */}
407
+ <div className="relative pb-12">
408
+ {visibleData.map((item) => {
409
+ const isHaltedStep = item.isHalted;
410
+ const barStart = item.startTime;
411
+ const barEnd =
412
+ item.startTime !== undefined && item.duration !== undefined
413
+ ? item.startTime + item.duration
414
+ : undefined;
415
+
416
+ const viewEnd = viewStart + viewDuration;
417
+
418
+ return (
419
+ <div
420
+ key={item.id}
421
+ className={cn(
422
+ "relative h-[32px]",
423
+ hoveredId === item.id && "bg-bg-secondary/80",
424
+ )}
425
+ onMouseEnter={() => setHoveredId(item.id)}
426
+ onMouseLeave={() => setHoveredId(null)}
427
+ >
428
+ {/* Launch Wait Line */}
429
+ {item.createTime !== undefined &&
430
+ item.startTime !== undefined &&
431
+ item.createTime < item.startTime &&
432
+ item.color && (
433
+ <div
434
+ className="absolute top-1/2 h-2 -translate-y-1/2"
435
+ style={{
436
+ left: `${timeToPercent(item.createTime)}%`,
437
+ width: `${((item.startTime - item.createTime) / viewDuration) * 100}%`,
438
+ }}
439
+ >
440
+ <div
441
+ className={cn(
442
+ "absolute top-1/2 left-0 h-2 w-px -translate-y-1/2",
443
+ colorClasses[item.color],
444
+ )}
445
+ />
446
+ <div
447
+ className={cn(
448
+ "absolute top-1/2 w-full -translate-y-1/2 border-t",
449
+ borderColorClasses[item.color],
450
+ )}
451
+ />
452
+ <div
453
+ className={cn(
454
+ "absolute top-1/2 right-0 h-2 w-px -translate-y-1/2",
455
+ colorClasses[item.color],
456
+ )}
457
+ />
458
+ </div>
459
+ )}
460
+
461
+ {/* Execution Bar */}
462
+ {item.startTime !== undefined &&
463
+ item.duration !== undefined &&
464
+ !isHaltedStep && (
465
+ <div
466
+ className={cn(
467
+ "rounded-uk-xs absolute top-1/2 flex h-5 -translate-y-1/2 items-center justify-center overflow-hidden",
468
+ item.color && colorClasses[item.color],
469
+ item.hasStripes &&
470
+ "bg-[repeating-linear-gradient(-45deg,transparent,transparent_4px,rgba(0,0,0,0.1)_4px,rgba(0,0,0,0.1)_8px)]",
471
+ )}
472
+ style={{
473
+ left: `${timeToPercent(item.startTime)}%`,
474
+ width: `${(item.duration / viewDuration) * 100}%`,
475
+ }}
476
+ />
477
+ )}
478
+
479
+ {/* Visible Label for Execution Bar */}
480
+ {item.startTime !== undefined &&
481
+ item.duration !== undefined &&
482
+ !isHaltedStep &&
483
+ (() => {
484
+ const visibleStart = Math.max(barStart || 0, viewStart);
485
+ const visibleEnd = Math.min(barEnd || 0, viewEnd);
486
+
487
+ if (visibleEnd <= visibleStart) return null;
488
+
489
+ const visibleDuration = visibleEnd - visibleStart;
490
+ const visibleWidthPercent = (visibleDuration / viewDuration) * 100;
491
+
492
+ if (visibleWidthPercent < 4) return null;
493
+
494
+ return (
495
+ <div
496
+ className="pointer-events-none absolute top-1/2 flex h-5 -translate-y-1/2 items-center justify-center"
497
+ style={{
498
+ left: `${timeToPercent(visibleStart)}%`,
499
+ width: `${visibleWidthPercent}%`,
500
+ }}
501
+ >
502
+ <span
503
+ className={cn(
504
+ "text-uk-sm font-medium whitespace-nowrap",
505
+ item.color === "gray-light" || item.color === "gray-medium"
506
+ ? "text-slate-600 dark:text-slate-300"
507
+ : "text-text-highlight",
508
+ )}
509
+ >
510
+ {formatDuration(item.duration)}
511
+ </span>
512
+ </div>
513
+ );
514
+ })()}
515
+
516
+ {/* Start Circle */}
517
+ {item.startTime !== undefined &&
518
+ item.duration !== undefined &&
519
+ !isHaltedStep &&
520
+ item.color && (
521
+ <div
522
+ className={cn(
523
+ "bg-bg-primary absolute top-1/2 z-10 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2",
524
+ borderColorClasses[item.color],
525
+ )}
526
+ style={{
527
+ left: `${timeToPercent(item.startTime)}%`,
528
+ }}
529
+ />
530
+ )}
531
+
532
+ {/* Special Halted Step Visualization */}
533
+ {isHaltedStep &&
534
+ item.startTime !== undefined &&
535
+ item.duration !== undefined && (
536
+ <div
537
+ className="absolute top-1/2 flex h-full -translate-y-1/2 items-center"
538
+ style={{
539
+ left: `${timeToPercent(item.startTime)}%`,
540
+ width: `${(item.duration / viewDuration) * 100}%`,
541
+ }}
542
+ >
543
+ <div className="relative flex h-full w-full items-center justify-center">
544
+ <div className="bg-line-secondary absolute top-1/2 left-0 h-2 w-px -translate-y-1/2" />
545
+ <div className="border-line-secondary w-full border-t border-dashed" />
546
+ <div className="bg-line-secondary absolute top-1/2 right-0 h-2 w-px -translate-y-1/2" />
547
+ </div>
548
+ </div>
549
+ )}
550
+
551
+ {/* Halted Step Label */}
552
+ {isHaltedStep &&
553
+ item.startTime !== undefined &&
554
+ item.duration !== undefined &&
555
+ (() => {
556
+ const visibleStart = Math.max(barStart || 0, viewStart);
557
+ const visibleEnd = Math.min(barEnd || 0, viewEnd);
558
+
559
+ if (visibleEnd <= visibleStart) return null;
560
+
561
+ const visibleDuration = visibleEnd - visibleStart;
562
+ const visibleWidthPercent = (visibleDuration / viewDuration) * 100;
563
+
564
+ if (visibleWidthPercent < 4) return null;
565
+
566
+ return (
567
+ <div
568
+ className="pointer-events-none absolute top-1/2 flex h-5 -translate-y-1/2 items-center justify-center"
569
+ style={{
570
+ left: `${timeToPercent(visibleStart)}%`,
571
+ width: `${visibleWidthPercent}%`,
572
+ }}
573
+ >
574
+ <div className="text-text-highlight text-uk-sm flex h-5 items-center rounded-full bg-orange-500/85 px-2 font-medium whitespace-nowrap">
575
+ {formatDuration(item.duration)}
576
+ </div>
577
+ </div>
578
+ );
579
+ })()}
580
+
581
+ {/* Point in Time Event */}
582
+ {item.time !== undefined && (
583
+ <div
584
+ className="absolute top-1/2 size-4 -translate-y-1/2 rounded-full"
585
+ style={{
586
+ left: `calc(${timeToPercent(item.time)}% - 4px)`,
587
+ }}
588
+ >
589
+ {getIcon(item)}
590
+ </div>
591
+ )}
592
+ </div>
593
+ );
594
+ })}
595
+ </div>
596
+
597
+ {/* Left Wedges */}
598
+ <div className="pointer-events-none absolute top-0 left-0 z-10 h-full w-2">
599
+ {visibleData.map((item, index) => {
600
+ const barEnd =
601
+ item.startTime !== undefined && item.duration !== undefined
602
+ ? item.startTime + item.duration
603
+ : undefined;
604
+ const isOffscreenLeft =
605
+ (barEnd !== undefined && barEnd < viewStart) ||
606
+ (item.time !== undefined && item.time < viewStart);
607
+
608
+ if (isOffscreenLeft && item.color) {
609
+ return (
610
+ <div
611
+ key={`left-wedge-${item.id}`}
612
+ className={cn(
613
+ "absolute h-0 w-0 border-y-[6px] border-r-[5px] border-y-transparent",
614
+ rightWedgeClasses[item.color],
615
+ )}
616
+ style={{
617
+ top: `${32 + index * 32 + 16}px`,
618
+ transform: "translateY(-50%)",
619
+ }}
620
+ />
621
+ );
622
+ }
623
+ return null;
624
+ })}
625
+ </div>
626
+
627
+ {/* Right Wedges */}
628
+ <div className="pointer-events-none absolute top-0 right-0 z-10 h-full w-2">
629
+ {visibleData.map((item, index) => {
630
+ const barStart = item.startTime;
631
+ const viewEnd = viewStart + viewDuration;
632
+ const isOffscreenRight =
633
+ (barStart !== undefined && barStart > viewEnd) ||
634
+ (item.time !== undefined && item.time > viewEnd);
635
+
636
+ if (isOffscreenRight && item.color) {
637
+ return (
638
+ <div
639
+ key={`right-wedge-${item.id}`}
640
+ className={cn(
641
+ "absolute h-0 w-0 border-y-[6px] border-l-[5px] border-y-transparent",
642
+ leftWedgeClasses[item.color],
643
+ )}
644
+ style={{
645
+ top: `${32 + index * 32 + 16}px`,
646
+ transform: "translateY(-50%)",
647
+ }}
648
+ />
649
+ );
650
+ }
651
+ return null;
652
+ })}
653
+ </div>
654
+
655
+ {/* Imperative Cursor and Readout */}
656
+ <div
657
+ ref={cursorContainerRef}
658
+ className="pointer-events-none absolute top-0 left-0 z-10 h-full w-full opacity-0 transition-opacity duration-150"
659
+ style={
660
+ {
661
+ "--cursor-left": "0%",
662
+ "--readout-text": '""',
663
+ "--magnet-opacity": "0",
664
+ } as React.CSSProperties
665
+ }
666
+ >
667
+ {/* Cursor Line */}
668
+ <div
669
+ className="absolute top-0 h-full w-px bg-red-500"
670
+ style={{ left: "var(--cursor-left)" }}
671
+ />
672
+
673
+ {/* Time Readout */}
674
+ <div
675
+ className="bg-bg-primary border-line-secondary/20 rounded-uk-sm text-uk-sm absolute top-1 flex items-center justify-center border px-2 py-0.5 whitespace-nowrap shadow-lg"
676
+ style={{
677
+ left: "var(--cursor-left)",
678
+ transform: "translateX(-50%)",
679
+ minWidth: "11ch",
680
+ }}
681
+ >
682
+ <Magnet
683
+ className="text-text-secondary mr-1.5 size-3 transition-opacity"
684
+ style={{ opacity: "var(--magnet-opacity)" }}
685
+ />
686
+ <span className="font-mono tabular-nums after:content-[var(--readout-text)]" />
687
+ </div>
688
+ </div>
689
+ </div>
690
+
691
+ {/* Timeline Controls */}
692
+ <div className="absolute bottom-4 left-1/2 z-20 w-max -translate-x-1/2">
693
+ <div className="bg-bg-primary/75 flex items-center gap-2 rounded-full p-1 shadow-lg backdrop-blur-[2px]">
694
+ <button
695
+ onClick={() => handlePan("left")}
696
+ className="hover:bg-bg-secondary rounded-full p-1"
697
+ >
698
+ <ChevronLeft className="size-4" />
699
+ </button>
700
+ <span className="text-uk-sm w-24 text-center font-mono">
701
+ {formatDuration(viewDuration)}
702
+ </span>
703
+ <button
704
+ onClick={() => handlePan("right")}
705
+ className="hover:bg-bg-secondary rounded-full p-1"
706
+ >
707
+ <ChevronRight className="size-4" />
708
+ </button>
709
+ </div>
710
+ </div>
711
+ </div>
712
+ </div>
713
+ </div>
714
+ );
715
+ }
716
+
717
+ export { Timeline, type LogItemData };