@workflow/web-shared 4.1.0-beta.64 → 4.1.0-beta.65

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 (45) hide show
  1. package/dist/components/event-list-view.d.ts +5 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +40 -19
  4. package/dist/components/event-list-view.js.map +1 -1
  5. package/dist/components/index.d.ts +3 -0
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/components/index.js +3 -0
  8. package/dist/components/index.js.map +1 -1
  9. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  10. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  11. package/dist/components/sidebar/entity-detail-panel.js +4 -14
  12. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  13. package/dist/components/stream-viewer.d.ts +3 -1
  14. package/dist/components/stream-viewer.d.ts.map +1 -1
  15. package/dist/components/stream-viewer.js +23 -28
  16. package/dist/components/stream-viewer.js.map +1 -1
  17. package/dist/components/ui/decrypt-button.d.ts +15 -0
  18. package/dist/components/ui/decrypt-button.d.ts.map +1 -0
  19. package/dist/components/ui/decrypt-button.js +12 -0
  20. package/dist/components/ui/decrypt-button.js.map +1 -0
  21. package/dist/components/ui/load-more-button.d.ts +13 -0
  22. package/dist/components/ui/load-more-button.d.ts.map +1 -0
  23. package/dist/components/ui/load-more-button.js +12 -0
  24. package/dist/components/ui/load-more-button.js.map +1 -0
  25. package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
  26. package/dist/components/ui/menu-dropdown.js +2 -6
  27. package/dist/components/ui/menu-dropdown.js.map +1 -1
  28. package/dist/components/ui/spinner.d.ts +9 -0
  29. package/dist/components/ui/spinner.d.ts.map +1 -0
  30. package/dist/components/ui/spinner.js +57 -0
  31. package/dist/components/ui/spinner.js.map +1 -0
  32. package/dist/components/workflow-trace-view.d.ts +3 -1
  33. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  34. package/dist/components/workflow-trace-view.js +4 -3
  35. package/dist/components/workflow-trace-view.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/components/event-list-view.tsx +153 -113
  38. package/src/components/index.ts +3 -0
  39. package/src/components/sidebar/entity-detail-panel.tsx +9 -25
  40. package/src/components/stream-viewer.tsx +52 -63
  41. package/src/components/ui/decrypt-button.tsx +69 -0
  42. package/src/components/ui/load-more-button.tsx +38 -0
  43. package/src/components/ui/menu-dropdown.tsx +3 -6
  44. package/src/components/ui/spinner.tsx +76 -0
  45. package/src/components/workflow-trace-view.tsx +6 -19
@@ -6,12 +6,15 @@ import { Check, ChevronRight, Copy } from 'lucide-react';
6
6
  import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
9
+ import { isEncryptedMarker } from '../lib/hydration';
10
+ import { DecryptButton } from './ui/decrypt-button';
9
11
  import { formatDuration } from '../lib/utils';
10
12
  import { DataInspector } from './ui/data-inspector';
11
13
  import {
12
14
  ErrorStackBlock,
13
15
  isStructuredErrorWithStack,
14
16
  } from './ui/error-stack-block';
17
+ import { LoadMoreButton } from './ui/load-more-button';
15
18
  import { MenuDropdown } from './ui/menu-dropdown';
16
19
  import { Skeleton } from './ui/skeleton';
17
20
 
@@ -596,6 +599,30 @@ const SORT_OPTIONS = [
596
599
  { value: 'asc' as const, label: 'Oldest' },
597
600
  ];
598
601
 
602
+ function RowsSkeleton() {
603
+ return (
604
+ <div className="flex-1 overflow-hidden">
605
+ {Array.from({ length: 8 }, (_, i) => (
606
+ <div
607
+ key={i}
608
+ className="flex items-center gap-3 px-4"
609
+ style={{ height: 40 }}
610
+ >
611
+ <Skeleton
612
+ className="h-2 w-2 flex-shrink-0"
613
+ style={{ borderRadius: '50%' }}
614
+ />
615
+ <Skeleton className="h-3" style={{ width: 90 }} />
616
+ <Skeleton className="h-3" style={{ width: 100 }} />
617
+ <Skeleton className="h-3" style={{ width: 80 }} />
618
+ <Skeleton className="h-3 flex-1" />
619
+ <Skeleton className="h-3 flex-1" />
620
+ </div>
621
+ ))}
622
+ </div>
623
+ );
624
+ }
625
+
599
626
  // ──────────────────────────────────────────────────────────────────────────
600
627
  // Event row
601
628
  // ──────────────────────────────────────────────────────────────────────────
@@ -616,6 +643,10 @@ interface EventsListProps {
616
643
  /** Called when the user changes sort order. When provided, the sort dropdown is shown
617
644
  * and the parent is expected to refetch from the API with the new order. */
618
645
  onSortOrderChange?: (order: 'asc' | 'desc') => void;
646
+ /** Called when the user clicks the Decrypt button. */
647
+ onDecrypt?: () => void;
648
+ /** Whether the encryption key is currently being fetched. */
649
+ isDecrypting?: boolean;
619
650
  }
620
651
 
621
652
  function EventRow({
@@ -1020,6 +1051,8 @@ export function EventListView({
1020
1051
  isLoading = false,
1021
1052
  sortOrder: sortOrderProp,
1022
1053
  onSortOrderChange,
1054
+ onDecrypt,
1055
+ isDecrypting = false,
1023
1056
  }: EventsListProps) {
1024
1057
  const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
1025
1058
  'asc'
@@ -1046,6 +1079,22 @@ export function EventListView({
1046
1079
  );
1047
1080
  }, [events, effectiveSortOrder]);
1048
1081
 
1082
+ // Detect encrypted fields across all loaded events.
1083
+ // Only checks top-level eventData values (input, output, result, etc.) —
1084
+ // the current data model guarantees encrypted markers appear at this level.
1085
+ const hasEncryptedData = useMemo(() => {
1086
+ if (!events) return false;
1087
+ for (const event of events) {
1088
+ const ed = (event as Record<string, unknown>).eventData;
1089
+ if (!ed || typeof ed !== 'object') continue;
1090
+ const data = ed as Record<string, unknown>;
1091
+ for (const val of Object.values(data)) {
1092
+ if (isEncryptedMarker(val)) return true;
1093
+ }
1094
+ }
1095
+ return false;
1096
+ }, [events]);
1097
+
1049
1098
  const { correlationNameMap, workflowName } = useMemo(
1050
1099
  () => buildNameMaps(events ?? null, run ?? null),
1051
1100
  [events, run]
@@ -1201,52 +1250,43 @@ export function EventListView({
1201
1250
  }
1202
1251
  }, [searchQuery, searchIndex]);
1203
1252
 
1204
- if (!events || events.length === 0) {
1205
- if (isLoading) {
1206
- return (
1207
- <div className="h-full flex flex-col overflow-hidden">
1208
- {/* Skeleton search bar */}
1209
- <div style={{ padding: 6 }}>
1210
- <Skeleton style={{ height: 40, borderRadius: 6 }} />
1211
- </div>
1212
- {/* Skeleton header */}
1213
- <div
1214
- className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1215
- style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1216
- >
1217
- <Skeleton className="h-3" style={{ width: 60 }} />
1218
- <div style={{ flex: 1 }} />
1219
- <Skeleton className="h-3" style={{ width: 80 }} />
1220
- <div style={{ flex: 1 }} />
1221
- <Skeleton className="h-3" style={{ width: 50 }} />
1222
- <div style={{ flex: 1 }} />
1223
- <Skeleton className="h-3" style={{ width: 90 }} />
1224
- <div style={{ flex: 1 }} />
1225
- <Skeleton className="h-3" style={{ width: 70 }} />
1226
- </div>
1227
- {/* Skeleton rows */}
1228
- <div className="flex-1 overflow-hidden">
1229
- {Array.from({ length: 8 }, (_, i) => (
1230
- <div
1231
- key={i}
1232
- className="flex items-center gap-3 px-4"
1233
- style={{ height: 40 }}
1234
- >
1235
- <Skeleton
1236
- className="h-2 w-2 flex-shrink-0"
1237
- style={{ borderRadius: '50%' }}
1238
- />
1239
- <Skeleton className="h-3" style={{ width: 90 }} />
1240
- <Skeleton className="h-3" style={{ width: 100 }} />
1241
- <Skeleton className="h-3" style={{ width: 80 }} />
1242
- <Skeleton className="h-3 flex-1" />
1243
- <Skeleton className="h-3 flex-1" />
1244
- </div>
1245
- ))}
1246
- </div>
1253
+ // Track whether we've ever had events to distinguish initial load from refetch
1254
+ const hasHadEventsRef = useRef(false);
1255
+ if (sortedEvents.length > 0) {
1256
+ hasHadEventsRef.current = true;
1257
+ }
1258
+ const isInitialLoad = isLoading && !hasHadEventsRef.current;
1259
+ const isRefetching =
1260
+ isLoading && hasHadEventsRef.current && sortedEvents.length === 0;
1261
+
1262
+ if (isInitialLoad) {
1263
+ return (
1264
+ <div className="h-full flex flex-col overflow-hidden">
1265
+ {/* Skeleton search bar */}
1266
+ <div style={{ padding: 6 }}>
1267
+ <Skeleton style={{ height: 40, borderRadius: 6 }} />
1247
1268
  </div>
1248
- );
1249
- }
1269
+ {/* Skeleton header */}
1270
+ <div
1271
+ className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1272
+ style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1273
+ >
1274
+ <Skeleton className="h-3" style={{ width: 60 }} />
1275
+ <div style={{ flex: 1 }} />
1276
+ <Skeleton className="h-3" style={{ width: 80 }} />
1277
+ <div style={{ flex: 1 }} />
1278
+ <Skeleton className="h-3" style={{ width: 50 }} />
1279
+ <div style={{ flex: 1 }} />
1280
+ <Skeleton className="h-3" style={{ width: 90 }} />
1281
+ <div style={{ flex: 1 }} />
1282
+ <Skeleton className="h-3" style={{ width: 70 }} />
1283
+ </div>
1284
+ <RowsSkeleton />
1285
+ </div>
1286
+ );
1287
+ }
1288
+
1289
+ if (!isLoading && (!events || events.length === 0)) {
1250
1290
  return (
1251
1291
  <div
1252
1292
  className="flex items-center justify-center h-full text-sm"
@@ -1339,6 +1379,13 @@ export function EventListView({
1339
1379
  value={effectiveSortOrder}
1340
1380
  onChange={handleSortOrderChange}
1341
1381
  />
1382
+ {(hasEncryptedData || encryptionKey) && onDecrypt && (
1383
+ <DecryptButton
1384
+ decrypted={!!encryptionKey}
1385
+ loading={isDecrypting}
1386
+ onClick={onDecrypt}
1387
+ />
1388
+ )}
1342
1389
  </div>
1343
1390
 
1344
1391
  {/* Header */}
@@ -1369,82 +1416,75 @@ export function EventListView({
1369
1416
  </div>
1370
1417
  </div>
1371
1418
 
1372
- {/* Virtualized event rows */}
1373
- <Virtuoso
1374
- ref={virtuosoRef}
1375
- totalCount={sortedEvents.length}
1376
- overscan={20}
1377
- defaultItemHeight={40}
1378
- endReached={() => {
1379
- if (!hasMoreEvents || isLoadingMoreEvents) {
1380
- return;
1381
- }
1382
- void onLoadMoreEvents?.();
1383
- }}
1384
- itemContent={(index: number) => {
1385
- const ev = sortedEvents[index];
1386
- return (
1387
- <EventRow
1388
- event={ev}
1389
- index={index}
1390
- isFirst={index === 0}
1391
- isLast={index === sortedEvents.length - 1}
1392
- isExpanded={expandedEventIds.has(ev.eventId)}
1393
- onToggleExpand={toggleEventExpanded}
1394
- activeGroupKey={activeGroupKey}
1395
- selectedGroupKey={selectedGroupKey}
1396
- selectedGroupRange={selectedGroupRange}
1397
- correlationNameMap={correlationNameMap}
1398
- workflowName={workflowName}
1399
- durationMap={durationMap}
1400
- onSelectGroup={onSelectGroup}
1401
- onHoverGroup={onHoverGroup}
1402
- onLoadEventData={onLoadEventData}
1403
- cachedEventData={
1404
- eventDataCacheRef.current.get(ev.eventId) ?? null
1405
- }
1406
- onCacheEventData={cacheEventData}
1407
- encryptionKey={encryptionKey}
1408
- />
1409
- );
1410
- }}
1411
- components={{
1412
- Footer: hasMoreEvents
1413
- ? () => (
1414
- <div className="px-3 pt-3 flex justify-center">
1415
- <button
1416
- type="button"
1417
- onClick={() => void onLoadMoreEvents?.()}
1418
- disabled={isLoadingMoreEvents}
1419
- className="h-8 px-3 text-xs rounded-md border transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
1420
- style={{
1421
- borderColor: 'var(--ds-gray-alpha-400)',
1422
- color: 'var(--ds-gray-900)',
1423
- backgroundColor: 'var(--ds-background-100)',
1424
- }}
1425
- >
1426
- {isLoadingMoreEvents
1427
- ? 'Loading more events...'
1428
- : 'Load more'}
1429
- </button>
1430
- </div>
1431
- )
1432
- : undefined,
1433
- }}
1434
- style={{ flex: 1, minHeight: 0 }}
1435
- />
1419
+ {/* Virtualized event rows or refetching skeleton */}
1420
+ {isRefetching ? (
1421
+ <RowsSkeleton />
1422
+ ) : (
1423
+ <Virtuoso
1424
+ ref={virtuosoRef}
1425
+ totalCount={sortedEvents.length}
1426
+ overscan={20}
1427
+ defaultItemHeight={40}
1428
+ endReached={() => {
1429
+ if (!hasMoreEvents || isLoadingMoreEvents) {
1430
+ return;
1431
+ }
1432
+ void onLoadMoreEvents?.();
1433
+ }}
1434
+ itemContent={(index: number) => {
1435
+ const ev = sortedEvents[index];
1436
+ return (
1437
+ <EventRow
1438
+ event={ev}
1439
+ index={index}
1440
+ isFirst={index === 0}
1441
+ isLast={index === sortedEvents.length - 1}
1442
+ isExpanded={expandedEventIds.has(ev.eventId)}
1443
+ onToggleExpand={toggleEventExpanded}
1444
+ activeGroupKey={activeGroupKey}
1445
+ selectedGroupKey={selectedGroupKey}
1446
+ selectedGroupRange={selectedGroupRange}
1447
+ correlationNameMap={correlationNameMap}
1448
+ workflowName={workflowName}
1449
+ durationMap={durationMap}
1450
+ onSelectGroup={onSelectGroup}
1451
+ onHoverGroup={onHoverGroup}
1452
+ onLoadEventData={onLoadEventData}
1453
+ cachedEventData={
1454
+ eventDataCacheRef.current.get(ev.eventId) ?? null
1455
+ }
1456
+ onCacheEventData={cacheEventData}
1457
+ encryptionKey={encryptionKey}
1458
+ />
1459
+ );
1460
+ }}
1461
+ style={{ flex: 1, minHeight: 0 }}
1462
+ />
1463
+ )}
1436
1464
 
1437
- {/* Fixed footer — always at the bottom of the visible area */}
1465
+ {/* Fixed footer — count + load more */}
1438
1466
  <div
1439
- className="flex-shrink-0 border-t text-xs px-3 py-2"
1467
+ className="relative flex-shrink-0 flex items-center h-10 border-t px-4 text-xs"
1440
1468
  style={{
1441
1469
  borderColor: 'var(--ds-gray-alpha-200)',
1442
1470
  color: 'var(--ds-gray-900)',
1443
1471
  backgroundColor: 'var(--ds-background-100)',
1444
1472
  }}
1445
1473
  >
1446
- {sortedEvents.length} event
1447
- {sortedEvents.length !== 1 ? 's' : ''} total
1474
+ <span>
1475
+ {sortedEvents.length} event
1476
+ {sortedEvents.length !== 1 ? 's' : ''} loaded
1477
+ </span>
1478
+ {hasMoreEvents && (
1479
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
1480
+ <div className="pointer-events-auto">
1481
+ <LoadMoreButton
1482
+ loading={isLoadingMoreEvents}
1483
+ onClick={() => void onLoadMoreEvents?.()}
1484
+ />
1485
+ </div>
1486
+ </div>
1487
+ )}
1448
1488
  </div>
1449
1489
  </div>
1450
1490
  );
@@ -22,5 +22,8 @@ export type {
22
22
  export { type StreamChunk, StreamViewer } from './stream-viewer';
23
23
  export type { Span, SpanEvent } from './trace-viewer/types';
24
24
  export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
25
+ export { DecryptButton } from './ui/decrypt-button';
26
+ export { LoadMoreButton } from './ui/load-more-button';
25
27
  export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
28
+ export { Spinner } from './ui/spinner';
26
29
  export { WorkflowTraceViewer } from './workflow-trace-view';
@@ -2,10 +2,11 @@
2
2
 
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
- import { Lock, Send, Unlock, Zap } from 'lucide-react';
5
+ import { Send, Zap } from 'lucide-react';
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { toast } from 'sonner';
8
8
  import { isEncryptedMarker } from '../../lib/hydration';
9
+ import { DecryptButton } from '../ui/decrypt-button';
9
10
  import { AttributePanel } from './attribute-panel';
10
11
  import { EventsList } from './events-list';
11
12
  import { ResolveHookModal } from './resolve-hook-modal';
@@ -64,6 +65,7 @@ export function EntityDetailPanel({
64
65
  onResolveHook,
65
66
  encryptionKey,
66
67
  onDecrypt,
68
+ isDecrypting = false,
67
69
  selectedSpan,
68
70
  }: {
69
71
  run: WorkflowRun;
@@ -97,6 +99,8 @@ export function EntityDetailPanel({
97
99
  encryptionKey?: Uint8Array;
98
100
  /** Callback to initiate decryption of encrypted run data */
99
101
  onDecrypt?: () => void;
102
+ /** Whether the encryption key is currently being fetched */
103
+ isDecrypting?: boolean;
100
104
  /** Info about the currently selected span from the trace viewer */
101
105
  selectedSpan: SelectedSpanInfo | null;
102
106
  }): React.JSX.Element | null {
@@ -381,31 +385,11 @@ export function EntityDetailPanel({
381
385
  </p>
382
386
  </div>
383
387
  {(hasEncryptedFields || encryptionKey) && onDecrypt && (
384
- <button
385
- type="button"
388
+ <DecryptButton
389
+ decrypted={!!encryptionKey}
390
+ loading={isDecrypting}
386
391
  onClick={onDecrypt}
387
- disabled={!!encryptionKey}
388
- className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
389
- style={{
390
- borderColor: encryptionKey
391
- ? 'var(--ds-green-400)'
392
- : 'var(--ds-gray-300)',
393
- color: encryptionKey
394
- ? 'var(--ds-green-900)'
395
- : 'var(--ds-gray-900)',
396
- backgroundColor: encryptionKey
397
- ? 'var(--ds-green-100)'
398
- : 'var(--ds-background-100)',
399
- cursor: encryptionKey ? 'default' : 'pointer',
400
- }}
401
- >
402
- {encryptionKey ? (
403
- <Unlock className="h-3 w-3" />
404
- ) : (
405
- <Lock className="h-3 w-3" />
406
- )}
407
- {encryptionKey ? 'Decrypted' : 'Decrypt'}
408
- </button>
392
+ />
409
393
  )}
410
394
  </div>
411
395
  </div>
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useEffect, useRef, useState } from 'react';
3
+ import React, { useEffect, useRef } from 'react';
4
+ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
4
5
  import { DataInspector } from './ui/data-inspector';
5
6
  import { Skeleton } from './ui/skeleton';
6
7
 
@@ -46,6 +47,8 @@ interface StreamViewerProps {
46
47
  error?: string | null;
47
48
  /** True while the initial stream connection is being established */
48
49
  isLoading?: boolean;
50
+ /** Called when the user scrolls near the bottom, for triggering pagination */
51
+ onScrollEnd?: () => void;
49
52
  }
50
53
 
51
54
  // ──────────────────────────────────────────────────────────────────────────
@@ -114,45 +117,41 @@ function StreamSkeleton() {
114
117
  * of complex types (Map, Set, Date, custom classes, etc.).
115
118
  */
116
119
  export function StreamViewer({
117
- streamId,
120
+ streamId: _streamId,
118
121
  chunks,
119
122
  isLive,
120
123
  error,
121
124
  isLoading,
125
+ onScrollEnd,
122
126
  }: StreamViewerProps) {
123
- const [hasMoreBelow, setHasMoreBelow] = useState(false);
124
- const scrollRef = useRef<HTMLDivElement>(null);
125
-
126
- const checkScrollPosition = useCallback(() => {
127
- if (scrollRef.current) {
128
- const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
129
- const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
130
- setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight);
131
- }
132
- }, []);
127
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
128
+ const prevChunkCountRef = useRef(0);
133
129
 
134
- // biome-ignore lint/correctness/useExhaustiveDependencies: chunks.length triggers scroll on new chunks
130
+ // Auto-scroll to bottom when new chunks arrive (live streaming)
135
131
  useEffect(() => {
136
- if (scrollRef.current) {
137
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
132
+ if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
133
+ virtuosoRef.current?.scrollToIndex({
134
+ index: chunks.length - 1,
135
+ align: 'end',
136
+ });
138
137
  }
139
- checkScrollPosition();
140
- }, [chunks.length, checkScrollPosition]);
138
+ prevChunkCountRef.current = chunks.length;
139
+ }, [chunks.length]);
141
140
 
142
141
  // Show skeleton when loading and no chunks have arrived yet
143
142
  if (isLoading && chunks.length === 0) {
144
143
  return (
145
- <div className="flex flex-col h-full pb-4">
144
+ <div className="flex flex-col h-full">
146
145
  <StreamSkeleton />
147
146
  </div>
148
147
  );
149
148
  }
150
149
 
151
150
  return (
152
- <div className="flex flex-col h-full pb-4">
151
+ <div className="flex flex-col h-full">
153
152
  {/* Live indicator */}
154
153
  {isLive && (
155
- <div className="flex items-center gap-1.5 mb-3 px-1">
154
+ <div className="flex items-center gap-1.5 mb-2 px-1">
156
155
  <span
157
156
  className="inline-block w-2 h-2 rounded-full"
158
157
  style={{ backgroundColor: 'var(--ds-green-600)' }}
@@ -182,52 +181,42 @@ export function StreamViewer({
182
181
  )}
183
182
 
184
183
  {/* Content */}
185
- <div className="relative flex-1 min-h-[200px]">
186
- <div
187
- ref={scrollRef}
188
- onScroll={checkScrollPosition}
189
- className="absolute inset-0 overflow-auto flex flex-col gap-2"
190
- >
191
- {error ? (
192
- <div
193
- className="text-[11px] rounded-md border p-3"
194
- style={{
195
- borderColor: 'var(--ds-red-300)',
196
- backgroundColor: 'var(--ds-red-100)',
197
- color: 'var(--ds-red-700)',
198
- }}
199
- >
200
- <div>Error reading stream:</div>
201
- <div>{error}</div>
202
- </div>
203
- ) : chunks.length === 0 ? (
204
- <div
205
- className="text-[11px] rounded-md border p-3"
206
- style={{
207
- borderColor: 'var(--ds-gray-300)',
208
- backgroundColor: 'var(--ds-gray-100)',
209
- color: 'var(--ds-gray-600)',
210
- }}
211
- >
212
- {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
213
- </div>
214
- ) : (
215
- chunks.map((chunk, index) => (
216
- <ChunkRow
217
- key={`${streamId}-chunk-${chunk.id}`}
218
- chunk={chunk}
219
- index={index}
220
- />
221
- ))
222
- )}
223
- </div>
224
- {hasMoreBelow && (
184
+ <div className="flex-1 min-h-0">
185
+ {error ? (
186
+ <div
187
+ className="text-[11px] rounded-md border p-3"
188
+ style={{
189
+ borderColor: 'var(--ds-red-300)',
190
+ backgroundColor: 'var(--ds-red-100)',
191
+ color: 'var(--ds-red-700)',
192
+ }}
193
+ >
194
+ <div>Error reading stream:</div>
195
+ <div>{error}</div>
196
+ </div>
197
+ ) : chunks.length === 0 ? (
225
198
  <div
226
- className="absolute bottom-0 left-0 right-0 h-8 pointer-events-none"
199
+ className="text-[11px] rounded-md border p-3"
227
200
  style={{
228
- background:
229
- 'linear-gradient(to top, var(--ds-background-100), transparent)',
201
+ borderColor: 'var(--ds-gray-300)',
202
+ backgroundColor: 'var(--ds-gray-100)',
203
+ color: 'var(--ds-gray-600)',
230
204
  }}
205
+ >
206
+ {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
207
+ </div>
208
+ ) : (
209
+ <Virtuoso
210
+ ref={virtuosoRef}
211
+ totalCount={chunks.length}
212
+ overscan={10}
213
+ endReached={() => onScrollEnd?.()}
214
+ itemContent={(index) => (
215
+ <div style={{ paddingBottom: 8 }}>
216
+ <ChunkRow chunk={chunks[index]} index={index} />
217
+ </div>
218
+ )}
219
+ style={{ flex: 1, minHeight: 0 }}
231
220
  />
232
221
  )}
233
222
  </div>
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { Spinner } from './spinner';
4
+
5
+ const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
6
+
7
+ interface DecryptButtonProps {
8
+ /** Whether an encryption key has been obtained (decryption is active). */
9
+ decrypted?: boolean;
10
+ /** Whether the key is currently being fetched. */
11
+ loading?: boolean;
12
+ /** Called when the user clicks to initiate decryption. */
13
+ onClick?: () => void;
14
+ }
15
+
16
+ /**
17
+ * Decrypt/Decrypted button using Geist secondary style.
18
+ * Three states: idle (secondary gray), decrypting (spinner), decrypted (green success).
19
+ */
20
+ export function DecryptButton({
21
+ decrypted = false,
22
+ loading = false,
23
+ onClick,
24
+ }: DecryptButtonProps) {
25
+ return (
26
+ <>
27
+ <style dangerouslySetInnerHTML={{ __html: STYLES }} />
28
+ <button
29
+ type="button"
30
+ onClick={decrypted ? undefined : onClick}
31
+ disabled={decrypted || loading}
32
+ className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
33
+ >
34
+ {loading ? (
35
+ <Spinner size={14} />
36
+ ) : decrypted ? (
37
+ <svg
38
+ width={14}
39
+ height={14}
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth={2}
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ >
47
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
48
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
49
+ </svg>
50
+ ) : (
51
+ <svg
52
+ width={14}
53
+ height={14}
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth={2}
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ >
61
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
62
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
63
+ </svg>
64
+ )}
65
+ {loading ? 'Decrypting…' : decrypted ? 'Decrypted' : 'Decrypt'}
66
+ </button>
67
+ </>
68
+ );
69
+ }