@kopai/ui 0.8.0 → 0.10.0

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 (52) hide show
  1. package/dist/index.cjs +2704 -1288
  2. package/dist/index.d.cts +38 -1
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +38 -1
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2722 -1300
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +13 -13
  9. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
  10. package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +8 -0
  11. package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
  12. package/src/components/observability/LogTimeline/index.tsx +6 -2
  13. package/src/components/observability/MetricHistogram/index.tsx +20 -19
  14. package/src/components/observability/MetricStat/index.tsx +12 -4
  15. package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
  16. package/src/components/observability/ServiceList/shortcuts.ts +1 -1
  17. package/src/components/observability/TraceComparison/index.tsx +332 -0
  18. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
  19. package/src/components/observability/TraceDetail/index.tsx +4 -3
  20. package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
  21. package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
  22. package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
  23. package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
  24. package/src/components/observability/TraceSearch/index.tsx +211 -218
  25. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
  26. package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
  27. package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
  28. package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
  29. package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
  30. package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
  31. package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
  32. package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
  33. package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
  34. package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
  35. package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
  36. package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
  37. package/src/components/observability/TraceTimeline/index.tsx +254 -110
  38. package/src/components/observability/index.ts +15 -0
  39. package/src/components/observability/renderers/OtelMetricStat.tsx +40 -0
  40. package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
  41. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  42. package/src/components/observability/utils/flatten-tree.ts +15 -0
  43. package/src/components/observability/utils/time.ts +9 -0
  44. package/src/hooks/use-kopai-data.test.ts +34 -0
  45. package/src/hooks/use-kopai-data.ts +23 -5
  46. package/src/hooks/use-live-logs.test.ts +4 -0
  47. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  48. package/src/lib/component-catalog.ts +15 -0
  49. package/src/lib/renderer.test.tsx +2 -0
  50. package/src/pages/observability.test.tsx +8 -0
  51. package/src/pages/observability.tsx +397 -236
  52. package/src/providers/kopai-provider.tsx +4 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Minimap - Compressed overview of all spans with a draggable viewport.
3
+ */
4
+
5
+ import { useRef, useCallback, useMemo, useEffect } from "react";
6
+ import type { ParsedTrace } from "../types.js";
7
+ import { getSpanBarColor } from "../utils/colors.js";
8
+ import { flattenAllSpans } from "../utils/flatten-tree.js";
9
+
10
+ export interface MinimapProps {
11
+ trace: ParsedTrace;
12
+ viewStart: number; // 0-1 fraction
13
+ viewEnd: number; // 0-1 fraction
14
+ onViewChange: (viewStart: number, viewEnd: number) => void;
15
+ }
16
+
17
+ const MINIMAP_HEIGHT = 40;
18
+ const SPAN_HEIGHT = 2;
19
+ const SPAN_GAP = 1;
20
+ const MIN_VIEWPORT_WIDTH = 0.02;
21
+ const HANDLE_WIDTH = 6;
22
+
23
+ type DragMode = "pan" | "resize-left" | "resize-right" | null;
24
+
25
+ export function Minimap({
26
+ trace,
27
+ viewStart,
28
+ viewEnd,
29
+ onViewChange,
30
+ }: MinimapProps) {
31
+ const containerRef = useRef<HTMLDivElement>(null);
32
+ const dragRef = useRef<{
33
+ mode: DragMode;
34
+ startX: number;
35
+ origViewStart: number;
36
+ origViewEnd: number;
37
+ } | null>(null);
38
+ const cleanupRef = useRef<(() => void) | null>(null);
39
+
40
+ useEffect(() => {
41
+ return () => {
42
+ cleanupRef.current?.();
43
+ };
44
+ }, []);
45
+
46
+ const allSpans = useMemo(
47
+ () => flattenAllSpans(trace.rootSpans),
48
+ [trace.rootSpans]
49
+ );
50
+
51
+ const traceDuration = trace.maxTimeMs - trace.minTimeMs;
52
+
53
+ const getFraction = useCallback((clientX: number): number => {
54
+ const el = containerRef.current;
55
+ if (!el) return 0;
56
+ const rect = el.getBoundingClientRect();
57
+ if (!rect.width) return 0;
58
+ return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
59
+ }, []);
60
+
61
+ const clampView = useCallback(
62
+ (start: number, end: number): [number, number] => {
63
+ let s = Math.max(0, Math.min(1 - MIN_VIEWPORT_WIDTH, start));
64
+ let e = Math.max(s + MIN_VIEWPORT_WIDTH, Math.min(1, end));
65
+ if (e > 1) {
66
+ e = 1;
67
+ s = Math.max(0, e - Math.max(MIN_VIEWPORT_WIDTH, end - start));
68
+ }
69
+ return [s, e];
70
+ },
71
+ []
72
+ );
73
+
74
+ const handleMouseDown = useCallback(
75
+ (e: React.MouseEvent, mode: DragMode) => {
76
+ e.preventDefault();
77
+ e.stopPropagation();
78
+ cleanupRef.current?.();
79
+ dragRef.current = {
80
+ mode,
81
+ startX: e.clientX,
82
+ origViewStart: viewStart,
83
+ origViewEnd: viewEnd,
84
+ };
85
+
86
+ const handleMouseMove = (ev: MouseEvent) => {
87
+ const drag = dragRef.current;
88
+ if (!drag || !containerRef.current) return;
89
+
90
+ const rect = containerRef.current.getBoundingClientRect();
91
+ if (!rect.width) return;
92
+ const deltaFrac = (ev.clientX - drag.startX) / rect.width;
93
+
94
+ let newStart: number;
95
+ let newEnd: number;
96
+
97
+ if (drag.mode === "pan") {
98
+ const width = drag.origViewEnd - drag.origViewStart;
99
+ newStart = drag.origViewStart + deltaFrac;
100
+ newEnd = newStart + width;
101
+ if (newStart < 0) {
102
+ newStart = 0;
103
+ newEnd = width;
104
+ }
105
+ if (newEnd > 1) {
106
+ newEnd = 1;
107
+ newStart = 1 - width;
108
+ }
109
+ } else if (drag.mode === "resize-left") {
110
+ newStart = drag.origViewStart + deltaFrac;
111
+ newEnd = drag.origViewEnd;
112
+ } else {
113
+ newStart = drag.origViewStart;
114
+ newEnd = drag.origViewEnd + deltaFrac;
115
+ }
116
+
117
+ const [s, e] = clampView(newStart, newEnd);
118
+ onViewChange(s, e);
119
+ };
120
+
121
+ const handleMouseUp = () => {
122
+ dragRef.current = null;
123
+ cleanupRef.current = null;
124
+ window.removeEventListener("mousemove", handleMouseMove);
125
+ window.removeEventListener("mouseup", handleMouseUp);
126
+ };
127
+
128
+ window.addEventListener("mousemove", handleMouseMove);
129
+ window.addEventListener("mouseup", handleMouseUp);
130
+ cleanupRef.current = handleMouseUp;
131
+ },
132
+ [viewStart, viewEnd, onViewChange, clampView]
133
+ );
134
+
135
+ const handleBackgroundClick = useCallback(
136
+ (e: React.MouseEvent) => {
137
+ if (dragRef.current) return;
138
+ if (e.target !== e.currentTarget) return;
139
+ const frac = getFraction(e.clientX);
140
+ const width = viewEnd - viewStart;
141
+ const half = width / 2;
142
+ const [s, eVal] = clampView(frac - half, frac + half);
143
+ onViewChange(s, eVal);
144
+ },
145
+ [viewStart, viewEnd, onViewChange, getFraction, clampView]
146
+ );
147
+
148
+ const handleKeyDown = useCallback(
149
+ (e: React.KeyboardEvent) => {
150
+ const step = 0.05;
151
+ const width = viewEnd - viewStart;
152
+ let newStart: number;
153
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
154
+ newStart = viewStart - step;
155
+ } else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
156
+ newStart = viewStart + step;
157
+ } else {
158
+ return;
159
+ }
160
+ e.preventDefault();
161
+ const [s, eVal] = clampView(newStart, newStart + width);
162
+ onViewChange(s, eVal);
163
+ },
164
+ [viewStart, viewEnd, onViewChange, clampView]
165
+ );
166
+
167
+ const viewStartPct = viewStart * 100;
168
+ const viewEndPct = viewEnd * 100;
169
+ const viewWidthPct = viewEndPct - viewStartPct;
170
+
171
+ // Calculate span row height to fit within minimap
172
+ const totalRows = allSpans.length;
173
+ const availableHeight = MINIMAP_HEIGHT - 4; // 2px padding top/bottom
174
+ const rowHeight =
175
+ totalRows > 0
176
+ ? Math.min(SPAN_HEIGHT + SPAN_GAP, availableHeight / totalRows)
177
+ : SPAN_HEIGHT;
178
+
179
+ return (
180
+ <div
181
+ ref={containerRef}
182
+ className="relative w-full border-b border-border bg-muted/30 select-none"
183
+ style={{ height: MINIMAP_HEIGHT }}
184
+ onClick={handleBackgroundClick}
185
+ onKeyDown={handleKeyDown}
186
+ role="slider"
187
+ tabIndex={0}
188
+ aria-label="Trace minimap viewport"
189
+ aria-valuemin={0}
190
+ aria-valuemax={100}
191
+ aria-valuenow={Math.round(viewStartPct)}
192
+ >
193
+ {/* Span bars */}
194
+ {traceDuration > 0 &&
195
+ allSpans.map(({ span }, i) => {
196
+ const left =
197
+ ((span.startTimeUnixMs - trace.minTimeMs) / traceDuration) * 100;
198
+ const width = Math.max(0.2, (span.durationMs / traceDuration) * 100);
199
+ const color = getSpanBarColor(
200
+ span.serviceName,
201
+ span.status === "ERROR"
202
+ );
203
+ return (
204
+ <div
205
+ key={span.spanId}
206
+ className="absolute pointer-events-none"
207
+ style={{
208
+ left: `${left}%`,
209
+ width: `${width}%`,
210
+ top: 2 + i * rowHeight,
211
+ height: Math.max(1, rowHeight - SPAN_GAP),
212
+ backgroundColor: color,
213
+ opacity: 0.8,
214
+ borderRadius: 1,
215
+ }}
216
+ />
217
+ );
218
+ })}
219
+
220
+ {/* Left overlay (outside viewport) */}
221
+ {viewStartPct > 0 && (
222
+ <div
223
+ className="absolute top-0 left-0 h-full bg-black/30 pointer-events-none"
224
+ style={{ width: `${viewStartPct}%` }}
225
+ />
226
+ )}
227
+
228
+ {/* Right overlay (outside viewport) */}
229
+ {viewEndPct < 100 && (
230
+ <div
231
+ className="absolute top-0 h-full bg-black/30 pointer-events-none"
232
+ style={{ left: `${viewEndPct}%`, right: 0 }}
233
+ />
234
+ )}
235
+
236
+ {/* Viewport rectangle */}
237
+ <div
238
+ className="absolute top-0 h-full border border-blue-500/50 bg-blue-500/10 cursor-grab active:cursor-grabbing"
239
+ style={{
240
+ left: `${viewStartPct}%`,
241
+ width: `${viewWidthPct}%`,
242
+ }}
243
+ onMouseDown={(e) => handleMouseDown(e, "pan")}
244
+ >
245
+ {/* Left resize handle */}
246
+ <div
247
+ className="absolute top-0 left-0 h-full cursor-ew-resize z-10"
248
+ style={{ width: HANDLE_WIDTH, marginLeft: -HANDLE_WIDTH / 2 }}
249
+ onMouseDown={(e) => handleMouseDown(e, "resize-left")}
250
+ />
251
+ {/* Right resize handle */}
252
+ <div
253
+ className="absolute top-0 right-0 h-full cursor-ew-resize z-10"
254
+ style={{ width: HANDLE_WIDTH, marginRight: -HANDLE_WIDTH / 2 }}
255
+ onMouseDown={(e) => handleMouseDown(e, "resize-right")}
256
+ />
257
+ </div>
258
+ </div>
259
+ );
260
+ }
@@ -0,0 +1,184 @@
1
+ import { useState, useCallback } from "react";
2
+ import type { SpanNode } from "../types.js";
3
+ import { formatDuration, formatRelativeTime } from "../utils/time.js";
4
+ import { formatAttributeValue } from "../utils/attributes.js";
5
+ import { getServiceColor } from "../utils/colors.js";
6
+
7
+ interface SpanDetailInlineProps {
8
+ span: SpanNode;
9
+ traceStartMs: number;
10
+ }
11
+
12
+ interface CollapsibleSectionProps {
13
+ title: string;
14
+ count: number;
15
+ children: React.ReactNode;
16
+ }
17
+
18
+ function CollapsibleSection({
19
+ title,
20
+ count,
21
+ children,
22
+ }: CollapsibleSectionProps) {
23
+ const [open, setOpen] = useState(false);
24
+
25
+ if (count === 0) return null;
26
+
27
+ return (
28
+ <div>
29
+ <button
30
+ className="flex items-center gap-1 text-xs font-medium text-foreground hover:text-blue-600 dark:hover:text-blue-400 py-1"
31
+ onClick={(e) => {
32
+ e.stopPropagation();
33
+ setOpen((p) => !p);
34
+ }}
35
+ >
36
+ <span className="w-3 text-center">{open ? "▾" : "▸"}</span>
37
+ {title}
38
+ <span className="text-muted-foreground">({count})</span>
39
+ </button>
40
+ {open && <div className="ml-4 mt-1 space-y-1">{children}</div>}
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function KeyValueRow({ k, v }: { k: string; v: unknown }) {
46
+ const formatted = formatAttributeValue(v);
47
+ return (
48
+ <div className="flex gap-2 text-xs font-mono py-0.5">
49
+ <span className="text-muted-foreground flex-shrink-0">{k}</span>
50
+ <span className="text-foreground">=</span>
51
+ <span className="text-foreground break-all">{formatted}</span>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function SpanDetailInline({
57
+ span,
58
+ traceStartMs,
59
+ }: SpanDetailInlineProps) {
60
+ const [copiedId, setCopiedId] = useState(false);
61
+ const serviceColor = getServiceColor(span.serviceName);
62
+ const relativeStartMs = span.startTimeUnixMs - traceStartMs;
63
+
64
+ const handleCopy = useCallback(async () => {
65
+ try {
66
+ await navigator.clipboard.writeText(span.spanId);
67
+ setCopiedId(true);
68
+ setTimeout(() => setCopiedId(false), 2000);
69
+ } catch {
70
+ /* clipboard unavailable */
71
+ }
72
+ }, [span.spanId]);
73
+
74
+ const spanAttrs = Object.entries(span.attributes).sort(([a], [b]) =>
75
+ a.localeCompare(b)
76
+ );
77
+ const resourceAttrs = Object.entries(span.resourceAttributes).sort(
78
+ ([a], [b]) => a.localeCompare(b)
79
+ );
80
+
81
+ return (
82
+ <div
83
+ className="border-b border-border bg-muted/50 px-4 py-3"
84
+ style={{ borderLeft: `3px solid ${serviceColor}` }}
85
+ onClick={(e) => e.stopPropagation()}
86
+ >
87
+ {/* Header */}
88
+ <div className="mb-2">
89
+ <div className="text-sm font-medium text-foreground">{span.name}</div>
90
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground mt-1">
91
+ <span>
92
+ Service: <span className="text-foreground">{span.serviceName}</span>
93
+ </span>
94
+ <span>
95
+ Duration:{" "}
96
+ <span className="text-foreground">
97
+ {formatDuration(span.durationMs)}
98
+ </span>
99
+ </span>
100
+ <span>
101
+ Start:{" "}
102
+ <span className="text-foreground">
103
+ {formatDuration(relativeStartMs)}
104
+ </span>
105
+ </span>
106
+ <span>
107
+ Kind: <span className="text-foreground">{span.kind}</span>
108
+ </span>
109
+ {span.status !== "UNSET" && (
110
+ <span>
111
+ Status:{" "}
112
+ <span
113
+ className={
114
+ span.status === "ERROR" ? "text-red-500" : "text-foreground"
115
+ }
116
+ >
117
+ {span.status}
118
+ </span>
119
+ </span>
120
+ )}
121
+ </div>
122
+ </div>
123
+
124
+ {/* Collapsible sections */}
125
+ <div className="space-y-1">
126
+ <CollapsibleSection title="Tags" count={spanAttrs.length}>
127
+ {spanAttrs.map(([k, v]) => (
128
+ <KeyValueRow key={k} k={k} v={v} />
129
+ ))}
130
+ </CollapsibleSection>
131
+
132
+ <CollapsibleSection title="Process" count={resourceAttrs.length}>
133
+ {resourceAttrs.map(([k, v]) => (
134
+ <KeyValueRow key={k} k={k} v={v} />
135
+ ))}
136
+ </CollapsibleSection>
137
+
138
+ <CollapsibleSection title="Events" count={span.events.length}>
139
+ {span.events.map((event, i) => (
140
+ <div
141
+ key={i}
142
+ className="text-xs border-l-2 border-border pl-2 py-1.5 space-y-0.5"
143
+ >
144
+ <div className="flex items-center gap-2">
145
+ <span className="font-mono text-muted-foreground flex-shrink-0">
146
+ {formatRelativeTime(event.timeUnixMs, span.startTimeUnixMs)}
147
+ </span>
148
+ <span className="font-medium text-foreground">
149
+ {event.name}
150
+ </span>
151
+ </div>
152
+ {Object.entries(event.attributes).map(([k, v]) => (
153
+ <KeyValueRow key={k} k={k} v={v} />
154
+ ))}
155
+ </div>
156
+ ))}
157
+ </CollapsibleSection>
158
+
159
+ <CollapsibleSection title="Links" count={span.links.length}>
160
+ {span.links.map((link, i) => (
161
+ <div key={i} className="text-xs font-mono py-0.5">
162
+ <span className="text-muted-foreground">trace:</span>{" "}
163
+ {link.traceId}{" "}
164
+ <span className="text-muted-foreground">span:</span> {link.spanId}
165
+ </div>
166
+ ))}
167
+ </CollapsibleSection>
168
+ </div>
169
+
170
+ {/* SpanID + copy */}
171
+ <div className="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-border">
172
+ <span className="text-xs text-muted-foreground">SpanID:</span>
173
+ <code className="text-xs font-mono text-foreground">{span.spanId}</code>
174
+ <button
175
+ onClick={handleCopy}
176
+ className="text-xs text-muted-foreground hover:text-foreground"
177
+ aria-label="Copy span ID"
178
+ >
179
+ {copiedId ? "✓" : "Copy"}
180
+ </button>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
@@ -2,6 +2,8 @@ import { memo } from "react";
2
2
  import type { SpanNode } from "../types.js";
3
3
  import { TimelineBar } from "./TimelineBar.js";
4
4
  import { formatDuration } from "../utils/time.js";
5
+ import { getServiceColor } from "../utils/colors.js";
6
+ import { spanMatchesSearch } from "../utils/flatten-tree.js";
5
7
 
6
8
  export interface SpanRowProps {
7
9
  span: SpanNode;
@@ -16,22 +18,7 @@ export interface SpanRowProps {
16
18
  onToggleCollapse: () => void;
17
19
  onMouseEnter?: () => void;
18
20
  onMouseLeave?: () => void;
19
- }
20
-
21
- function getHttpContext(span: SpanNode): string | null {
22
- const attrs = span.attributes;
23
- const method = attrs["http.method"];
24
- const url = attrs["http.url"] || attrs["http.target"];
25
- const statusCode = attrs["http.status_code"];
26
-
27
- if (!method && !url) return null;
28
-
29
- const parts: string[] = [];
30
- if (method) parts.push(String(method));
31
- if (url) parts.push(String(url));
32
- if (statusCode) parts.push(`[${statusCode}]`);
33
-
34
- return parts.join(" ");
21
+ uiFind?: string;
35
22
  }
36
23
 
37
24
  export const SpanRow = memo(function SpanRow({
@@ -46,10 +33,12 @@ export const SpanRow = memo(function SpanRow({
46
33
  onToggleCollapse,
47
34
  onMouseEnter,
48
35
  onMouseLeave,
36
+ uiFind,
49
37
  }: SpanRowProps) {
50
38
  const hasChildren = span.children.length > 0;
51
39
  const isError = span.status === "ERROR";
52
- const httpContext = getHttpContext(span);
40
+ const serviceColor = getServiceColor(span.serviceName);
41
+ const isDimmed = uiFind ? !spanMatchesSearch(span, uiFind) : false;
53
42
 
54
43
  return (
55
44
  <div
@@ -58,6 +47,10 @@ export const SpanRow = memo(function SpanRow({
58
47
  ? "bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/30"
59
48
  : ""
60
49
  }`}
50
+ style={{
51
+ borderLeft: `3px solid ${serviceColor}`,
52
+ opacity: isDimmed ? 0.4 : 1,
53
+ }}
61
54
  onClick={onClick}
62
55
  onMouseEnter={onMouseEnter}
63
56
  onMouseLeave={onMouseLeave}
@@ -135,7 +128,10 @@ export const SpanRow = memo(function SpanRow({
135
128
  </svg>
136
129
  )}
137
130
 
138
- <span className="text-xs text-muted-foreground flex-shrink-0 mr-2">
131
+ <span
132
+ className="text-xs flex-shrink-0 mr-2 font-medium"
133
+ style={{ color: serviceColor }}
134
+ >
139
135
  {span.serviceName}
140
136
  </span>
141
137
 
@@ -149,12 +145,6 @@ export const SpanRow = memo(function SpanRow({
149
145
  </span>
150
146
  )}
151
147
 
152
- {httpContext && (
153
- <span className="text-xs text-muted-foreground truncate ml-2 flex-shrink-0 max-w-xs">
154
- {httpContext}
155
- </span>
156
- )}
157
-
158
148
  <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
159
149
  {formatDuration(span.durationMs)}
160
150
  </span>
@@ -0,0 +1,76 @@
1
+ export interface SpanSearchProps {
2
+ value: string;
3
+ onChange: (value: string) => void;
4
+ matchCount: number;
5
+ currentMatch: number;
6
+ onPrev: () => void;
7
+ onNext: () => void;
8
+ }
9
+
10
+ export function SpanSearch({
11
+ value,
12
+ onChange,
13
+ matchCount,
14
+ currentMatch,
15
+ onPrev,
16
+ onNext,
17
+ }: SpanSearchProps) {
18
+ return (
19
+ <div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-background">
20
+ <input
21
+ type="text"
22
+ placeholder="Find..."
23
+ value={value}
24
+ onChange={(e) => onChange(e.target.value)}
25
+ className="bg-muted text-foreground text-sm px-2 py-0.5 rounded border border-border outline-none focus:border-blue-500 w-48"
26
+ />
27
+ {value && (
28
+ <>
29
+ <span className="text-xs text-muted-foreground whitespace-nowrap">
30
+ {matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : "0 matches"}
31
+ </span>
32
+ <button
33
+ onClick={onPrev}
34
+ disabled={matchCount === 0}
35
+ className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
36
+ aria-label="Previous match"
37
+ >
38
+ <svg
39
+ className="w-3.5 h-3.5"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ viewBox="0 0 24 24"
43
+ >
44
+ <path
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ strokeWidth={2}
48
+ d="M5 15l7-7 7 7"
49
+ />
50
+ </svg>
51
+ </button>
52
+ <button
53
+ onClick={onNext}
54
+ disabled={matchCount === 0}
55
+ className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
56
+ aria-label="Next match"
57
+ >
58
+ <svg
59
+ className="w-3.5 h-3.5"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ viewBox="0 0 24 24"
63
+ >
64
+ <path
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ strokeWidth={2}
68
+ d="M19 9l-7 7-7-7"
69
+ />
70
+ </svg>
71
+ </button>
72
+ </>
73
+ )}
74
+ </div>
75
+ );
76
+ }