@workflow/web-shared 5.0.0-beta.15 → 5.0.0-beta.16

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 (51) hide show
  1. package/dist/components/event-list-view.d.ts +1 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +20 -3
  4. package/dist/components/new-trace-viewer/components/timeline.d.ts +1 -1
  5. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  6. package/dist/components/new-trace-viewer/components/timeline.js +40 -4
  7. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  8. package/dist/components/new-trace-viewer/trace-viewer.js +8 -9
  9. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  10. package/dist/components/sidebar/attribute-panel.js +26 -32
  11. package/dist/components/sidebar/attributes-block.d.ts +28 -0
  12. package/dist/components/sidebar/attributes-block.d.ts.map +1 -0
  13. package/dist/components/sidebar/attributes-block.js +83 -0
  14. package/dist/components/sidebar/entity-detail-panel.js +2 -2
  15. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  16. package/dist/components/sidebar/events-list.js +8 -1
  17. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  18. package/dist/components/trace-viewer/components/node.js +2 -1
  19. package/dist/components/ui/context-card.d.ts +47 -0
  20. package/dist/components/ui/context-card.d.ts.map +1 -0
  21. package/dist/components/ui/context-card.js +471 -0
  22. package/dist/components/ui/timestamp-tooltip.d.ts +34 -1
  23. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
  24. package/dist/components/ui/timestamp-tooltip.js +80 -132
  25. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  26. package/dist/components/workflow-trace-view.js +8 -10
  27. package/dist/components/workflow-traces/event-colors.d.ts +1 -0
  28. package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
  29. package/dist/components/workflow-traces/event-colors.js +12 -1
  30. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  31. package/dist/components/workflow-traces/trace-span-construction.js +2 -1
  32. package/dist/lib/trace-builder.d.ts +15 -0
  33. package/dist/lib/trace-builder.d.ts.map +1 -1
  34. package/dist/lib/trace-builder.js +27 -2
  35. package/dist/styles.css +9 -0
  36. package/package.json +5 -3
  37. package/src/components/event-list-view.tsx +25 -2
  38. package/src/components/new-trace-viewer/components/timeline.tsx +82 -2
  39. package/src/components/new-trace-viewer/trace-viewer.tsx +17 -14
  40. package/src/components/sidebar/attribute-panel.tsx +117 -119
  41. package/src/components/sidebar/attributes-block.tsx +182 -0
  42. package/src/components/sidebar/entity-detail-panel.tsx +1 -1
  43. package/src/components/sidebar/events-list.tsx +8 -0
  44. package/src/components/trace-viewer/components/node.tsx +3 -2
  45. package/src/components/ui/context-card.tsx +784 -0
  46. package/src/components/ui/timestamp-tooltip.tsx +132 -194
  47. package/src/components/workflow-trace-view.tsx +18 -10
  48. package/src/components/workflow-traces/event-colors.ts +12 -0
  49. package/src/components/workflow-traces/trace-span-construction.ts +1 -0
  50. package/src/lib/trace-builder.ts +32 -1
  51. package/src/styles.css +9 -0
@@ -3,6 +3,7 @@
3
3
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
4
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
5
5
  import type { ModelMessage } from 'ai';
6
+ import { format } from 'date-fns';
6
7
  import type { KeyboardEvent, ReactNode } from 'react';
7
8
  import { useCallback, useContext, useMemo, useState } from 'react';
8
9
  import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
@@ -10,6 +11,7 @@ import { useToast } from '../../lib/toast';
10
11
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
11
12
  import { CopyButton } from '../new-trace-viewer/components/copy-button';
12
13
  import { MiddleTruncate } from '../new-trace-viewer/components/middle-truncate/middle-truncate';
14
+ import { ContextCardProvider } from '../ui/context-card';
13
15
  import {
14
16
  DecryptClickContext,
15
17
  RunClickContext,
@@ -19,6 +21,7 @@ import { ErrorCard } from '../ui/error-card';
19
21
  import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
20
22
  import { Skeleton } from '../ui/skeleton';
21
23
  import { TimestampTooltip } from '../ui/timestamp-tooltip';
24
+ import { RunAttributesCard } from './attributes-block';
22
25
  import { ConversationView } from './conversation-view';
23
26
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
24
27
  import { DetailCard } from './detail-card';
@@ -339,15 +342,7 @@ const parseDateValue = (value: unknown): Date | null => {
339
342
  };
340
343
 
341
344
  const formatLocalMillisecondTime = (date: Date): string =>
342
- date.toLocaleString(undefined, {
343
- year: 'numeric',
344
- month: 'numeric',
345
- day: 'numeric',
346
- hour: 'numeric',
347
- minute: 'numeric',
348
- second: 'numeric',
349
- fractionalSecondDigits: 3,
350
- });
345
+ format(date, 'MMM dd HH:mm:ss.SS OO').toUpperCase();
351
346
 
352
347
  export const localMillisecondTime = (value: unknown): string => {
353
348
  const date = parseDateValue(value);
@@ -355,7 +350,6 @@ export const localMillisecondTime = (value: unknown): string => {
355
350
  return '-';
356
351
  }
357
352
 
358
- // e.g. 12/17/2025, 9:08:55.182 AM
359
353
  return formatLocalMillisecondTime(date);
360
354
  };
361
355
 
@@ -416,12 +410,12 @@ const attributeToDisplayFn: Record<
416
410
  projectId: (_value: unknown) => null,
417
411
  environment: (_value: unknown) => null,
418
412
  executionContext: (_value: unknown) => null,
419
- // Attributes MVP — string-string metadata attached to the run.
420
- // Rendered in its own collapsible DetailCard; if empty/missing, hidden
421
- // by the hasDisplayContent gate.
413
+ // Attributes — string-string metadata attached to the run.
414
+ // Rendered as key-value rows in its own collapsible DetailCard;
415
+ // if empty/missing, hidden by the hasDisplayContent gate.
422
416
  attributes: (value: unknown) => {
423
417
  if (!hasDisplayContent(value)) return null;
424
- return <DetailCard summary="Attributes">{JsonBlock(value)}</DetailCard>;
418
+ return <RunAttributesCard attributes={value as Record<string, string>} />;
425
419
  },
426
420
  // Dates — wrapped with TimestampTooltip showing UTC/local + relative time
427
421
  createdAt: timestampWithTooltipOrNull,
@@ -846,113 +840,117 @@ export const AttributePanel = ({
846
840
  : outerDecryptCtx;
847
841
 
848
842
  return (
849
- <RunClickContext.Provider value={onRunClick}>
850
- <StreamClickContext.Provider value={onStreamClick}>
851
- <DecryptClickContext.Provider value={decryptValue}>
852
- {visibleBasicAttributes.length > 0 && (
853
- <div className="flex flex-col overflow-hidden divide-y divide-gray-alpha-400 mb-3">
854
- {orderedBasicAttributes.map((attribute) => {
855
- const displayValue = attributeToDisplayFn[
856
- attribute as keyof typeof attributeToDisplayFn
857
- ]?.(displayData[attribute as keyof typeof displayData]);
858
- const isModuleSpecifier = attribute === 'moduleSpecifier';
859
- const isCopyableBasicAttribute =
860
- copyableBasicAttributes.has(attribute as AttributeKey) &&
861
- typeof displayValue === 'string';
862
- const moduleSpecifierValue =
863
- typeof displayValue === 'string'
864
- ? displayValue
865
- : String(displayValue ?? displayData.moduleSpecifier ?? '');
866
-
867
- return (
868
- <div
869
- className="flex items-center justify-between py-2"
870
- key={attribute}
871
- >
872
- <span className="text-label-14 text-gray-900">
873
- {getAttributeDisplayName(attribute)}
874
- </span>
875
- {isModuleSpecifier ? (
876
- <button
877
- type="button"
878
- className="min-w-0 max-w-[70%] truncate text-right text-label-13 font-mono"
879
- style={{
880
- color: 'var(--ds-gray-1000)',
881
- background: 'transparent',
882
- border: 'none',
883
- padding: 0,
884
- }}
885
- title={moduleSpecifierValue}
886
- onClick={() =>
887
- handleCopyModuleSpecifier(moduleSpecifierValue)
888
- }
889
- >
890
- {moduleSpecifierValue}
891
- </button>
892
- ) : isCopyableBasicAttribute ? (
893
- <div
894
- className="flex min-w-0 max-w-[70%] items-center justify-end gap-1 text-right text-[13px] font-mono"
895
- style={{
896
- color: 'var(--ds-gray-1000)',
897
- }}
898
- title={displayValue}
843
+ <ContextCardProvider>
844
+ <RunClickContext.Provider value={onRunClick}>
845
+ <StreamClickContext.Provider value={onStreamClick}>
846
+ <DecryptClickContext.Provider value={decryptValue}>
847
+ {visibleBasicAttributes.length > 0 && (
848
+ <div className="flex flex-col overflow-hidden divide-y divide-gray-alpha-400 mb-3">
849
+ {orderedBasicAttributes.map((attribute) => {
850
+ const displayValue = attributeToDisplayFn[
851
+ attribute as keyof typeof attributeToDisplayFn
852
+ ]?.(displayData[attribute as keyof typeof displayData]);
853
+ const isModuleSpecifier = attribute === 'moduleSpecifier';
854
+ const isCopyableBasicAttribute =
855
+ copyableBasicAttributes.has(attribute as AttributeKey) &&
856
+ typeof displayValue === 'string';
857
+ const moduleSpecifierValue =
858
+ typeof displayValue === 'string'
859
+ ? displayValue
860
+ : String(
861
+ displayValue ?? displayData.moduleSpecifier ?? ''
862
+ );
863
+
864
+ return (
865
+ <div
866
+ className="flex items-center justify-between py-2"
867
+ key={attribute}
868
+ >
869
+ <span className="text-label-14 text-gray-900">
870
+ {getAttributeDisplayName(attribute)}
871
+ </span>
872
+ {isModuleSpecifier ? (
873
+ <button
874
+ type="button"
875
+ className="min-w-0 max-w-[70%] truncate text-right text-label-13 font-mono"
876
+ style={{
877
+ color: 'var(--ds-gray-1000)',
878
+ background: 'transparent',
879
+ border: 'none',
880
+ padding: 0,
881
+ }}
882
+ title={moduleSpecifierValue}
883
+ onClick={() =>
884
+ handleCopyModuleSpecifier(moduleSpecifierValue)
885
+ }
886
+ >
887
+ {moduleSpecifierValue}
888
+ </button>
889
+ ) : isCopyableBasicAttribute ? (
890
+ <div
891
+ className="flex min-w-0 max-w-[70%] items-center justify-end gap-1 text-right text-[13px] font-mono"
892
+ style={{
893
+ color: 'var(--ds-gray-1000)',
894
+ }}
895
+ title={displayValue}
896
+ >
897
+ <MiddleTruncate
898
+ value={displayValue}
899
+ className="flex-1"
900
+ />
901
+ <CopyButton
902
+ copyText={displayValue}
903
+ ariaLabel={`Copy ${getAttributeDisplayName(attribute)}`}
904
+ className="shrink-0 -mr-1"
905
+ />
906
+ </div>
907
+ ) : (
908
+ <span className="text-right text-label-13 font-mono">
909
+ {displayValue}
910
+ </span>
911
+ )}
912
+ </div>
913
+ );
914
+ })}
915
+ {isLoading && resource === 'sleep' && !displayData.resumeAt && (
916
+ <div className="py-1">
917
+ <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
918
+ <span
919
+ className="text-[14px] first-letter:uppercase"
920
+ style={{ color: 'var(--ds-gray-700)' }}
899
921
  >
900
- <MiddleTruncate
901
- value={displayValue}
902
- className="flex-1"
903
- />
904
- <CopyButton
905
- copyText={displayValue}
906
- ariaLabel={`Copy ${getAttributeDisplayName(attribute)}`}
907
- className="shrink-0 -mr-1"
908
- />
909
- </div>
910
- ) : (
911
- <span className="text-right text-label-13 font-mono">
912
- {displayValue}
922
+ resumeAt
913
923
  </span>
914
- )}
915
- </div>
916
- );
917
- })}
918
- {isLoading && resource === 'sleep' && !displayData.resumeAt && (
919
- <div className="py-1">
920
- <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
921
- <span
922
- className="text-[14px] first-letter:uppercase"
923
- style={{ color: 'var(--ds-gray-700)' }}
924
- >
925
- resumeAt
926
- </span>
927
- <Skeleton className="h-4 w-[55%]" />
924
+ <Skeleton className="h-4 w-[55%]" />
925
+ </div>
928
926
  </div>
929
- </div>
930
- )}
931
- </div>
932
- )}
933
- {error ? (
934
- <ErrorCard
935
- title="Failed to load resource details"
936
- details={error.message}
937
- className="my-4"
938
- />
939
- ) : hasExpired ? (
940
- <ExpiredDataMessage />
941
- ) : resolvedAttributes.length > 0 ? (
942
- <>
943
- {resolvedAttributes.map((attribute) => (
944
- <AttributeBlock
945
- isLoading={isLoading}
946
- key={attribute}
947
- attribute={attribute}
948
- value={displayData[attribute as keyof typeof displayData]}
949
- context={displayContext}
950
- />
951
- ))}
952
- </>
953
- ) : null}
954
- </DecryptClickContext.Provider>
955
- </StreamClickContext.Provider>
956
- </RunClickContext.Provider>
927
+ )}
928
+ </div>
929
+ )}
930
+ {error ? (
931
+ <ErrorCard
932
+ title="Failed to load resource details"
933
+ details={error.message}
934
+ className="my-4"
935
+ />
936
+ ) : hasExpired ? (
937
+ <ExpiredDataMessage />
938
+ ) : resolvedAttributes.length > 0 ? (
939
+ <>
940
+ {resolvedAttributes.map((attribute) => (
941
+ <AttributeBlock
942
+ isLoading={isLoading}
943
+ key={attribute}
944
+ attribute={attribute}
945
+ value={displayData[attribute as keyof typeof displayData]}
946
+ context={displayContext}
947
+ />
948
+ ))}
949
+ </>
950
+ ) : null}
951
+ </DecryptClickContext.Provider>
952
+ </StreamClickContext.Provider>
953
+ </RunClickContext.Provider>
954
+ </ContextCardProvider>
957
955
  );
958
956
  };
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import {
4
+ type AttributeChange,
5
+ RESERVED_ATTRIBUTE_KEY_PREFIX,
6
+ } from '@workflow/world';
7
+ import { CopyableDataBlock } from './copyable-data-block';
8
+ import { DetailCard } from './detail-card';
9
+
10
+ /**
11
+ * Check whether an attribute key is in the reserved (`$`-prefixed)
12
+ * namespace used by framework/library code.
13
+ */
14
+ export const isReservedAttributeKey = (key: string): boolean =>
15
+ key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX);
16
+
17
+ function ReservedBadge() {
18
+ return (
19
+ <span
20
+ className="shrink-0 rounded border px-1 py-px text-[10px] font-medium leading-3"
21
+ style={{
22
+ borderColor: 'var(--ds-gray-alpha-400)',
23
+ backgroundColor: 'var(--ds-gray-100)',
24
+ color: 'var(--ds-gray-700)',
25
+ }}
26
+ title={`Keys prefixed with "${RESERVED_ATTRIBUTE_KEY_PREFIX}" are reserved for framework and library code`}
27
+ >
28
+ Reserved
29
+ </span>
30
+ );
31
+ }
32
+
33
+ function AttributeRow({
34
+ attributeKey,
35
+ value,
36
+ removed = false,
37
+ }: {
38
+ attributeKey: string;
39
+ value?: string;
40
+ removed?: boolean;
41
+ }) {
42
+ const reserved = isReservedAttributeKey(attributeKey);
43
+ return (
44
+ <div className="flex items-center justify-between gap-3 py-1.5">
45
+ <span
46
+ className="flex min-w-0 items-center gap-1.5 text-label-12 font-mono"
47
+ style={{
48
+ color: reserved ? 'var(--ds-gray-700)' : 'var(--ds-gray-900)',
49
+ }}
50
+ >
51
+ <span className="truncate">{attributeKey}</span>
52
+ {reserved && <ReservedBadge />}
53
+ </span>
54
+ {removed ? (
55
+ <span
56
+ className="shrink-0 text-label-12 italic"
57
+ style={{ color: 'var(--ds-gray-700)' }}
58
+ >
59
+ removed
60
+ </span>
61
+ ) : (
62
+ <span
63
+ className="max-w-[60%] truncate text-right text-label-12 font-mono text-gray-1000"
64
+ title={value}
65
+ >
66
+ {value}
67
+ </span>
68
+ )}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Sort attribute keys for display: user keys first (alphabetical), then
75
+ * reserved `$`-prefixed keys (alphabetical), so framework metadata doesn't
76
+ * crowd out user-set attributes.
77
+ */
78
+ export function sortAttributeKeys(keys: string[]): string[] {
79
+ return [...keys].sort((a, b) => {
80
+ const aReserved = isReservedAttributeKey(a);
81
+ const bReserved = isReservedAttributeKey(b);
82
+ if (aReserved !== bReserved) return aReserved ? 1 : -1;
83
+ return a.localeCompare(b);
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Collapsible card showing a run's materialized attributes as key-value
89
+ * rows. Reserved (`$`-prefixed) keys are visually de-emphasized with a
90
+ * badge and sorted after user keys.
91
+ */
92
+ export function RunAttributesCard({
93
+ attributes,
94
+ }: {
95
+ attributes: Record<string, string>;
96
+ }) {
97
+ const keys = sortAttributeKeys(Object.keys(attributes));
98
+ if (keys.length === 0) return null;
99
+
100
+ return (
101
+ <DetailCard
102
+ summary={`Attributes (${keys.length})`}
103
+ defaultOpen
104
+ contentClassName="mb-0"
105
+ >
106
+ <div className="flex flex-col divide-y divide-gray-alpha-400">
107
+ {keys.map((key) => (
108
+ <AttributeRow attributeKey={key} key={key} value={attributes[key]} />
109
+ ))}
110
+ </div>
111
+ </DetailCard>
112
+ );
113
+ }
114
+
115
+ interface AttrSetEventData {
116
+ changes: AttributeChange[];
117
+ writer?:
118
+ | { type: 'workflow' }
119
+ | { type: 'step'; stepId: string; attempt: number };
120
+ }
121
+
122
+ function isAttrSetEventData(data: unknown): data is AttrSetEventData {
123
+ if (data === null || typeof data !== 'object') return false;
124
+ const record = data as Record<string, unknown>;
125
+ return (
126
+ Array.isArray(record.changes) &&
127
+ record.changes.every(
128
+ (c) =>
129
+ c !== null &&
130
+ typeof c === 'object' &&
131
+ typeof (c as Record<string, unknown>).key === 'string'
132
+ )
133
+ );
134
+ }
135
+
136
+ function describeWriter(writer: AttrSetEventData['writer']): string | null {
137
+ if (!writer) return null;
138
+ if (writer.type === 'workflow') return 'Set by workflow';
139
+ if (writer.type === 'step') {
140
+ return `Set by step ${writer.stepId} (attempt ${writer.attempt})`;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Renders the payload of an `attr_set` event: the list of attribute
147
+ * changes (sets and removals) plus which writer (workflow or step)
148
+ * made them. Falls back to the generic JSON block for unexpected shapes.
149
+ */
150
+ export function AttrSetEventBlock({ data }: { data: unknown }) {
151
+ if (!isAttrSetEventData(data)) {
152
+ return <CopyableDataBlock data={data} />;
153
+ }
154
+
155
+ const writerLabel = describeWriter(data.writer);
156
+
157
+ return (
158
+ <div className="flex flex-col px-3 py-1">
159
+ <div className="flex flex-col divide-y divide-gray-alpha-400">
160
+ {data.changes.map((change, index) => (
161
+ <AttributeRow
162
+ attributeKey={change.key}
163
+ key={`${change.key}:${index}`}
164
+ removed={change.value === null}
165
+ value={change.value ?? undefined}
166
+ />
167
+ ))}
168
+ </div>
169
+ {writerLabel && (
170
+ <div
171
+ className="border-t py-1.5 text-label-12"
172
+ style={{
173
+ borderColor: 'var(--ds-gray-alpha-400)',
174
+ color: 'var(--ds-gray-700)',
175
+ }}
176
+ >
177
+ {writerLabel}
178
+ </div>
179
+ )}
180
+ </div>
181
+ );
182
+ }
@@ -444,7 +444,7 @@ export function EntityDetailPanel({
444
444
  resource={resource}
445
445
  />
446
446
 
447
- {resource !== 'run' && rawEvents && (
447
+ {rawEvents && (
448
448
  <EventsList
449
449
  events={rawEvents}
450
450
  onLoadEventData={onLoadEventData}
@@ -7,6 +7,7 @@ import { RunClickContext, StreamClickContext } from '../ui/data-inspector';
7
7
  import { ErrorCard } from '../ui/error-card';
8
8
  import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
9
9
  import { Skeleton } from '../ui/skeleton';
10
+ import { AttrSetEventBlock } from './attributes-block';
10
11
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
11
12
  import { DetailCard } from './detail-card';
12
13
 
@@ -30,6 +31,7 @@ const DATA_EVENT_TYPES = new Set([
30
31
  'run_failed',
31
32
  'wait_created',
32
33
  'wait_completed',
34
+ 'attr_set',
33
35
  ]);
34
36
 
35
37
  /**
@@ -226,6 +228,12 @@ function EventDataBlock({
226
228
  return <EncryptedDataBlock />;
227
229
  }
228
230
 
231
+ // Attribute changes — render the changed keys and the writer instead of
232
+ // the raw JSON payload.
233
+ if (eventType === 'attr_set') {
234
+ return <AttrSetEventBlock data={data} />;
235
+ }
236
+
229
237
  // For error events (step_failed, step_retrying), the eventData has the shape
230
238
  // { error: StructuredError, stack?: string, ... }. Check both the top-level
231
239
  // value and the nested `error` field for a stack trace.
@@ -18,10 +18,10 @@ import { formatDuration, getHighResInMs } from '../util/timing';
18
18
  import { SpanContent } from './span-content';
19
19
  import { computeSegments } from './span-segments';
20
20
  import {
21
- type ResourceType,
22
- type SpanLayout,
23
21
  getResourceType,
24
22
  getSpanLayout,
23
+ type ResourceType,
24
+ type SpanLayout,
25
25
  } from './span-strategies';
26
26
 
27
27
  export const getSpanColorClassName = (node: SpanNode): string => {
@@ -362,6 +362,7 @@ const BOUNDARY_LABELS: Record<string, string> = {
362
362
  run_completed: 'Completed',
363
363
  run_failed: 'Run failed',
364
364
  step_completed: 'Completed',
365
+ attr_set: 'Attributes set',
365
366
  };
366
367
 
367
368
  export const SpanEventComponent = memo(function SpanEventComponent({