@workflow/web-shared 4.1.6 → 4.1.8

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@workflow/web-shared",
3
3
  "description": "Shared components for Workflow Observability UI",
4
- "version": "4.1.6",
4
+ "version": "4.1.8",
5
5
  "private": false,
6
6
  "files": [
7
7
  "dist",
@@ -52,9 +52,9 @@
52
52
  "streamdown": "2.3.0",
53
53
  "tailwind-merge": "3.5.0",
54
54
  "tailwindcss": "4",
55
- "@workflow/core": "4.2.5",
55
+ "@workflow/core": "4.3.0",
56
56
  "@workflow/utils": "4.1.2",
57
- "@workflow/world": "4.1.2"
57
+ "@workflow/world": "4.1.4"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "^2.4.4",
@@ -3,12 +3,23 @@
3
3
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
4
  import type { Event, WorkflowRun } from '@workflow/world';
5
5
  import { Check, ChevronRight, Copy } from 'lucide-react';
6
- import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
6
+ import type {
7
+ KeyboardEvent as ReactKeyboardEvent,
8
+ MouseEvent as ReactMouseEvent,
9
+ ReactNode,
10
+ } from 'react';
7
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
12
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
9
13
  import { isEncryptedMarker } from '../lib/hydration';
14
+ import {
15
+ parseExactWorkflowSearchId,
16
+ looksLikeWorkflowIdSearchInput,
17
+ type ExactIdSearchResult,
18
+ type ExactWorkflowSearchIdKind,
19
+ } from '../lib/exact-event-search-id';
10
20
  import { DecryptButton } from './ui/decrypt-button';
11
21
  import { formatDuration } from '../lib/utils';
22
+ import { useToast } from '../lib/toast';
12
23
  import { DataInspector, DecryptClickContext } from './ui/data-inspector';
13
24
  import {
14
25
  ErrorStackBlock,
@@ -721,6 +732,12 @@ interface EventsListProps {
721
732
  isDecrypting?: boolean;
722
733
  /** Run-level hint: the run contains encrypted data (from probe). */
723
734
  hasEncryptedData?: boolean;
735
+ /** Fetch events for an exact correlation or event ID. */
736
+ onExactIdSearch?: (
737
+ id: string,
738
+ kind: ExactWorkflowSearchIdKind,
739
+ signal?: AbortSignal
740
+ ) => Promise<ExactIdSearchResult>;
724
741
  }
725
742
 
726
743
  function EventRow({
@@ -743,6 +760,7 @@ function EventRow({
743
760
  onCacheEventData,
744
761
  encryptionKey,
745
762
  onEncryptedDataDetected,
763
+ suppressGroupDimming = false,
746
764
  }: {
747
765
  event: Event;
748
766
  index: number;
@@ -763,6 +781,8 @@ function EventRow({
763
781
  onCacheEventData: (eventId: string, data: unknown) => void;
764
782
  encryptionKey?: Uint8Array;
765
783
  onEncryptedDataDetected?: () => void;
784
+ /** Exact-ID search results should not dim unrelated rows. */
785
+ suppressGroupDimming?: boolean;
766
786
  }) {
767
787
  const [isLoading, setIsLoading] = useState(false);
768
788
  const [loadedEventData, setLoadedEventData] = useState<unknown | null>(
@@ -804,7 +824,7 @@ function EventRow({
804
824
 
805
825
  const hasActive = activeGroupKey !== undefined;
806
826
  const isRelated = rowGroupKey !== undefined && rowGroupKey === activeGroupKey;
807
- const isDimmed = hasActive && !isRelated;
827
+ const isDimmed = hasActive && !isRelated && !suppressGroupDimming;
808
828
  const isPulsing = hasActive && isRelated;
809
829
 
810
830
  // Gutter state derived from selectedGroupRange
@@ -1155,7 +1175,9 @@ export function EventListView({
1155
1175
  onDecrypt,
1156
1176
  isDecrypting = false,
1157
1177
  hasEncryptedData: hasEncryptedDataProp = false,
1178
+ onExactIdSearch,
1158
1179
  }: EventsListProps) {
1180
+ const toast = useToast();
1159
1181
  const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
1160
1182
  'asc'
1161
1183
  );
@@ -1171,25 +1193,42 @@ export function EventListView({
1171
1193
  [onSortOrderChange]
1172
1194
  );
1173
1195
 
1196
+ const [searchQuery, setSearchQuery] = useState('');
1197
+ const [searchResults, setSearchResults] = useState<Event[] | null>(null);
1198
+ const [searchResultsTruncated, setSearchResultsTruncated] = useState(false);
1199
+ const [searchError, setSearchError] = useState<string | null>(null);
1200
+ const [searchLoading, setSearchLoading] = useState(false);
1201
+ const [searchNotFound, setSearchNotFound] = useState(false);
1202
+ const searchRequestRef = useRef(0);
1203
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
1204
+
1205
+ const parsedSearchId = useMemo(
1206
+ () => parseExactWorkflowSearchId(searchQuery),
1207
+ [searchQuery]
1208
+ );
1209
+ const isExactSearchActive = searchResults !== null;
1210
+
1174
1211
  const sortedEvents = useMemo(() => {
1175
- if (!events || events.length === 0) return [];
1212
+ const sourceEvents = isExactSearchActive ? searchResults : (events ?? []);
1213
+ if (sourceEvents.length === 0) return [];
1176
1214
  const dir = effectiveSortOrder === 'desc' ? -1 : 1;
1177
- return [...events].sort(
1215
+ return [...sourceEvents].sort(
1178
1216
  (a, b) =>
1179
1217
  dir *
1180
1218
  (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
1181
1219
  );
1182
- }, [events, effectiveSortOrder]);
1220
+ }, [events, effectiveSortOrder, isExactSearchActive, searchResults]);
1183
1221
 
1184
1222
  // Detect encrypted fields across all loaded events (inline eventData).
1185
1223
  const hasEncryptedInlineData = useMemo(() => {
1186
- if (!events) return false;
1187
- for (const event of events) {
1224
+ const sourceEvents = isExactSearchActive ? searchResults : events;
1225
+ if (!sourceEvents) return false;
1226
+ for (const event of sourceEvents) {
1188
1227
  const ed = (event as Record<string, unknown>).eventData;
1189
1228
  if (hasEncryptedValues(ed)) return true;
1190
1229
  }
1191
1230
  return false;
1192
- }, [events]);
1231
+ }, [events, isExactSearchActive, searchResults]);
1193
1232
 
1194
1233
  // Tracks whether any expanded row's lazy-loaded data contained encrypted markers.
1195
1234
  // Set to true by EventRow via onEncryptedDataDetected; never reset (sticky).
@@ -1203,8 +1242,12 @@ export function EventListView({
1203
1242
  hasEncryptedDataProp || hasEncryptedInlineData || foundEncryptedInLazyData;
1204
1243
 
1205
1244
  const { correlationNameMap, workflowName } = useMemo(
1206
- () => buildNameMaps(events ?? null, run ?? null),
1207
- [events, run]
1245
+ () =>
1246
+ buildNameMaps(
1247
+ isExactSearchActive ? searchResults : (events ?? null),
1248
+ run ?? null
1249
+ ),
1250
+ [events, isExactSearchActive, run, searchResults]
1208
1251
  );
1209
1252
 
1210
1253
  const durationMap = useMemo(
@@ -1294,68 +1337,139 @@ export function EventListView({
1294
1337
  return first >= 0 ? { first, last } : null;
1295
1338
  }, [activeGroupKey, sortedEvents]);
1296
1339
 
1297
- const [searchQuery, setSearchQuery] = useState('');
1298
- const virtuosoRef = useRef<VirtuosoHandle>(null);
1299
-
1300
- const searchIndex = useMemo(() => {
1301
- const entries: {
1302
- fields: string[];
1303
- groupKey?: string;
1304
- eventId: string;
1305
- index: number;
1306
- }[] = [];
1307
- for (let i = 0; i < sortedEvents.length; i++) {
1308
- const ev = sortedEvents[i];
1309
- const isRun = isRunLevel(ev.eventType);
1310
- const name = isRun
1311
- ? (workflowName ?? '')
1312
- : ev.correlationId
1313
- ? (correlationNameMap.get(ev.correlationId) ?? '')
1314
- : '';
1315
- entries.push({
1316
- fields: [
1317
- ev.eventId,
1318
- ev.correlationId ?? '',
1319
- ev.eventType,
1320
- formatEventType(ev.eventType),
1321
- name,
1322
- ].map((f) => f.toLowerCase()),
1323
- groupKey: ev.correlationId ?? (isRun ? '__run__' : undefined),
1324
- eventId: ev.eventId,
1325
- index: i,
1326
- });
1327
- }
1328
- return entries;
1329
- }, [sortedEvents, correlationNameMap, workflowName]);
1330
-
1331
1340
  useEffect(() => {
1332
- const q = searchQuery.trim().toLowerCase();
1333
- if (!q) {
1341
+ const trimmed = searchQuery.trim();
1342
+ if (!trimmed) {
1343
+ searchRequestRef.current += 1;
1344
+ setSearchResults(null);
1345
+ setSearchResultsTruncated(false);
1346
+ setSearchError(null);
1347
+ setSearchLoading(false);
1348
+ setSearchNotFound(false);
1334
1349
  setSelectedGroupKey(undefined);
1335
1350
  return;
1336
1351
  }
1337
- let bestMatch: (typeof searchIndex)[number] | null = null;
1338
- let bestScore = 0;
1339
- for (const entry of searchIndex) {
1340
- for (const field of entry.fields) {
1341
- if (field && field.includes(q)) {
1342
- const score = q.length / field.length;
1343
- if (score > bestScore) {
1344
- bestScore = score;
1345
- bestMatch = entry;
1352
+
1353
+ const parsed = parseExactWorkflowSearchId(trimmed);
1354
+ if (!parsed || !onExactIdSearch) {
1355
+ setSearchResults(null);
1356
+ setSearchLoading(false);
1357
+ setSearchNotFound(false);
1358
+ return;
1359
+ }
1360
+
1361
+ const requestId = ++searchRequestRef.current;
1362
+ setSearchLoading(true);
1363
+ setSearchNotFound(false);
1364
+ setSearchError(null);
1365
+
1366
+ const abortController = new AbortController();
1367
+
1368
+ const timer = setTimeout(() => {
1369
+ void (async () => {
1370
+ try {
1371
+ const results = await onExactIdSearch(
1372
+ parsed.id,
1373
+ parsed.kind,
1374
+ abortController.signal
1375
+ );
1376
+ if (
1377
+ abortController.signal.aborted ||
1378
+ searchRequestRef.current !== requestId
1379
+ ) {
1380
+ return;
1381
+ }
1382
+
1383
+ if (results.status === 'error') {
1384
+ setSearchResults([]);
1385
+ setSearchResultsTruncated(false);
1386
+ setSearchNotFound(false);
1387
+ setSearchError(results.message);
1388
+ setSelectedGroupKey(undefined);
1389
+ return;
1390
+ }
1391
+
1392
+ if (
1393
+ results.status === 'not_found' ||
1394
+ (results.status === 'ok' && results.events.length === 0)
1395
+ ) {
1396
+ setSearchResults([]);
1397
+ setSearchResultsTruncated(false);
1398
+ setSearchNotFound(true);
1399
+ setSearchError(null);
1400
+ setSelectedGroupKey(undefined);
1401
+ return;
1402
+ }
1403
+
1404
+ setSearchResults(results.events);
1405
+ setSearchResultsTruncated(Boolean(results.truncated));
1406
+ setSearchNotFound(false);
1407
+ setSearchError(null);
1408
+ setSelectedGroupKey(
1409
+ parsed.kind === 'event'
1410
+ ? (() => {
1411
+ const first = results.events[0];
1412
+ if (!first) return undefined;
1413
+ return isRunLevel(first.eventType)
1414
+ ? '__run__'
1415
+ : (first.correlationId ?? undefined);
1416
+ })()
1417
+ : parsed.id
1418
+ );
1419
+ virtuosoRef.current?.scrollToIndex({
1420
+ index: 0,
1421
+ align: 'start',
1422
+ behavior: 'smooth',
1423
+ });
1424
+ } catch {
1425
+ if (
1426
+ abortController.signal.aborted ||
1427
+ searchRequestRef.current !== requestId
1428
+ ) {
1429
+ return;
1430
+ }
1431
+ setSearchResults([]);
1432
+ setSearchResultsTruncated(false);
1433
+ setSearchNotFound(false);
1434
+ setSearchError('Failed to search events. Try again.');
1435
+ setSelectedGroupKey(undefined);
1436
+ } finally {
1437
+ if (
1438
+ searchRequestRef.current === requestId &&
1439
+ !abortController.signal.aborted
1440
+ ) {
1441
+ setSearchLoading(false);
1346
1442
  }
1347
1443
  }
1444
+ })();
1445
+ }, 300);
1446
+
1447
+ return () => {
1448
+ clearTimeout(timer);
1449
+ abortController.abort();
1450
+ };
1451
+ }, [searchQuery, onExactIdSearch]);
1452
+
1453
+ const handleSearchKeyDown = useCallback(
1454
+ (event: ReactKeyboardEvent<HTMLInputElement>) => {
1455
+ if (event.key !== 'Enter') {
1456
+ return;
1457
+ }
1458
+
1459
+ const trimmed = searchQuery.trim();
1460
+ if (
1461
+ !trimmed ||
1462
+ parseExactWorkflowSearchId(trimmed) ||
1463
+ !onExactIdSearch ||
1464
+ !looksLikeWorkflowIdSearchInput(trimmed)
1465
+ ) {
1466
+ return;
1348
1467
  }
1349
- }
1350
- if (bestMatch) {
1351
- setSelectedGroupKey(bestMatch.groupKey);
1352
- virtuosoRef.current?.scrollToIndex({
1353
- index: bestMatch.index,
1354
- align: 'center',
1355
- behavior: 'smooth',
1356
- });
1357
- }
1358
- }, [searchQuery, searchIndex]);
1468
+
1469
+ toast.info('Enter a full step ID, wait ID, hook ID, or event ID');
1470
+ },
1471
+ [searchQuery, onExactIdSearch, toast]
1472
+ );
1359
1473
 
1360
1474
  // Track whether we've ever had events to distinguish initial load from refetch
1361
1475
  const hasHadEventsRef = useRef(false);
@@ -1401,17 +1515,6 @@ export function EventListView({
1401
1515
  );
1402
1516
  }
1403
1517
 
1404
- if (!isLoading && (!events || events.length === 0)) {
1405
- return (
1406
- <div
1407
- className="flex items-center justify-center h-full text-sm"
1408
- style={{ color: 'var(--ds-gray-700)' }}
1409
- >
1410
- No events found
1411
- </div>
1412
- );
1413
- }
1414
-
1415
1518
  return (
1416
1519
  <DecryptClickContext.Provider
1417
1520
  value={onDecrypt ? { onDecrypt, isDecrypting } : undefined}
@@ -1476,9 +1579,16 @@ export function EventListView({
1476
1579
  </div>
1477
1580
  <input
1478
1581
  type="search"
1479
- placeholder="Search by name, event type, or ID…"
1582
+ placeholder="Search by step ID, wait ID, hook ID, or event ID…"
1480
1583
  value={searchQuery}
1481
1584
  onChange={(e) => setSearchQuery(e.target.value)}
1585
+ onKeyDown={handleSearchKeyDown}
1586
+ disabled={!onExactIdSearch}
1587
+ title={
1588
+ onExactIdSearch
1589
+ ? undefined
1590
+ : 'Exact ID search is unavailable in this view.'
1591
+ }
1482
1592
  style={{
1483
1593
  marginLeft: -16,
1484
1594
  paddingInline: 12,
@@ -1489,6 +1599,8 @@ export function EventListView({
1489
1599
  outline: 'none',
1490
1600
  height: 40,
1491
1601
  width: '100%',
1602
+ opacity: onExactIdSearch ? 1 : 0.5,
1603
+ cursor: onExactIdSearch ? 'text' : 'not-allowed',
1492
1604
  }}
1493
1605
  />
1494
1606
  </label>
@@ -1535,8 +1647,21 @@ export function EventListView({
1535
1647
  </div>
1536
1648
 
1537
1649
  {/* Virtualized event rows or refetching skeleton */}
1538
- {isRefetching ? (
1650
+ {isRefetching || searchLoading ? (
1539
1651
  <RowsSkeleton />
1652
+ ) : sortedEvents.length === 0 ? (
1653
+ <div
1654
+ className="flex flex-1 items-center justify-center px-6 text-center text-sm"
1655
+ style={{ color: 'var(--ds-gray-700)' }}
1656
+ >
1657
+ {searchNotFound && searchQuery.trim()
1658
+ ? `No events found for ${searchQuery.trim()}`
1659
+ : searchError
1660
+ ? searchError
1661
+ : parsedSearchId && searchQuery.trim() && !onExactIdSearch
1662
+ ? 'Exact ID search is unavailable in this view.'
1663
+ : 'No events found'}
1664
+ </div>
1540
1665
  ) : (
1541
1666
  <Virtuoso
1542
1667
  ref={virtuosoRef}
@@ -1544,7 +1669,11 @@ export function EventListView({
1544
1669
  overscan={20}
1545
1670
  defaultItemHeight={40}
1546
1671
  endReached={() => {
1547
- if (!hasMoreEvents || isLoadingMoreEvents) {
1672
+ if (
1673
+ isExactSearchActive ||
1674
+ !hasMoreEvents ||
1675
+ isLoadingMoreEvents
1676
+ ) {
1548
1677
  return;
1549
1678
  }
1550
1679
  void onLoadMoreEvents?.();
@@ -1574,6 +1703,7 @@ export function EventListView({
1574
1703
  onCacheEventData={cacheEventData}
1575
1704
  encryptionKey={encryptionKey}
1576
1705
  onEncryptedDataDetected={handleEncryptedDataDetected}
1706
+ suppressGroupDimming={isExactSearchActive}
1577
1707
  />
1578
1708
  );
1579
1709
  }}
@@ -1591,10 +1721,15 @@ export function EventListView({
1591
1721
  }}
1592
1722
  >
1593
1723
  <span>
1594
- {sortedEvents.length} event
1595
- {sortedEvents.length !== 1 ? 's' : ''} loaded
1724
+ {isExactSearchActive
1725
+ ? searchError
1726
+ ? searchError
1727
+ : searchNotFound
1728
+ ? `No events found for ${searchQuery.trim()}`
1729
+ : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}${searchResultsTruncated ? ' (results may be truncated)' : ''}`
1730
+ : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} loaded`}
1596
1731
  </span>
1597
- {hasMoreEvents && (
1732
+ {!isExactSearchActive && hasMoreEvents && (
1598
1733
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
1599
1734
  <div className="pointer-events-auto">
1600
1735
  <LoadMoreButton
package/src/index.ts CHANGED
@@ -14,6 +14,13 @@ export {
14
14
  waitEventsToWaitEntity,
15
15
  } from './components/workflow-traces/trace-span-construction';
16
16
  export type { EventAnalysis } from './lib/event-analysis';
17
+ export {
18
+ parseExactWorkflowSearchId,
19
+ looksLikeWorkflowIdSearchInput,
20
+ type ExactWorkflowSearchId,
21
+ type ExactWorkflowSearchIdKind,
22
+ type ExactIdSearchResult,
23
+ } from './lib/exact-event-search-id';
17
24
  export {
18
25
  analyzeEvents,
19
26
  hasPendingHooksFromEvents,
@@ -0,0 +1,67 @@
1
+ import type { Event } from '@workflow/world';
2
+
3
+ const WORKFLOW_ULID_BODY = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}';
4
+
5
+ const STEP_ID_PATTERN = new RegExp(`^step_(${WORKFLOW_ULID_BODY})$`, 'i');
6
+ const WAIT_ID_PATTERN = new RegExp(`^wait_(${WORKFLOW_ULID_BODY})$`, 'i');
7
+ const HOOK_ID_PATTERN = new RegExp(`^hook_(${WORKFLOW_ULID_BODY})$`, 'i');
8
+ const EVENT_ID_PATTERN = new RegExp(`^evnt_(${WORKFLOW_ULID_BODY})$`, 'i');
9
+
10
+ const WORKFLOW_ID_PREFIX_PATTERN = /^(step_|wait_|hook_|evnt_|wrun_)/i;
11
+
12
+ export type ExactWorkflowSearchIdKind = 'step' | 'wait' | 'hook' | 'event';
13
+
14
+ export type ExactWorkflowSearchId = {
15
+ kind: ExactWorkflowSearchIdKind;
16
+ id: string;
17
+ };
18
+
19
+ export type ExactIdSearchResult =
20
+ | { status: 'ok'; events: Event[]; truncated?: boolean }
21
+ | { status: 'not_found' }
22
+ | { status: 'error'; message: string };
23
+
24
+ function matchPrefixedId(
25
+ pattern: RegExp,
26
+ prefix: 'step' | 'wait' | 'hook' | 'evnt',
27
+ kind: ExactWorkflowSearchIdKind,
28
+ query: string
29
+ ): ExactWorkflowSearchId | null {
30
+ const match = query.match(pattern);
31
+ if (!match) {
32
+ return null;
33
+ }
34
+ return { kind, id: `${prefix}_${match[1].toUpperCase()}` };
35
+ }
36
+
37
+ /**
38
+ * Returns a parsed workflow ID when `query` is a full step, wait, hook, or event ID.
39
+ * Partial IDs and run IDs (`wrun_`) are ignored. ULID bodies are matched case-insensitively
40
+ * and normalized to uppercase in the returned ID.
41
+ */
42
+ export function parseExactWorkflowSearchId(
43
+ query: string
44
+ ): ExactWorkflowSearchId | null {
45
+ const trimmed = query.trim();
46
+ if (!trimmed) {
47
+ return null;
48
+ }
49
+
50
+ return (
51
+ matchPrefixedId(STEP_ID_PATTERN, 'step', 'step', trimmed) ??
52
+ matchPrefixedId(WAIT_ID_PATTERN, 'wait', 'wait', trimmed) ??
53
+ matchPrefixedId(HOOK_ID_PATTERN, 'hook', 'hook', trimmed) ??
54
+ matchPrefixedId(EVENT_ID_PATTERN, 'evnt', 'event', trimmed)
55
+ );
56
+ }
57
+
58
+ /** True when input looks like the user is attempting an ID search (including partial). */
59
+ export function looksLikeWorkflowIdSearchInput(query: string): boolean {
60
+ const trimmed = query.trim();
61
+ if (!WORKFLOW_ID_PREFIX_PATTERN.test(trimmed)) {
62
+ return false;
63
+ }
64
+ // Distinguish IDs (contain digits) from event-type strings like step_started.
65
+ // Assumes workflow event types do not include digits in their names.
66
+ return /\d/.test(trimmed);
67
+ }