ai-design-system 0.1.86 → 0.1.91

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 (35) hide show
  1. package/components/ai-elements/ExpoAppPreview/ExpoAppPreview.tsx +8 -3
  2. package/components/ai-elements/prompt-input.tsx +92 -77
  3. package/components/blocks/AIConversation/AIConversation.stories.tsx +11 -1
  4. package/components/blocks/AIConversation/AIConversation.tsx +5 -30
  5. package/components/blocks/PreviewCanvas/preview-layout.ts +2 -1
  6. package/components/blocks/WorkflowCanvas/WorkflowCanvas.tsx +111 -1
  7. package/components/composites/AgentAnnotation/AgentAnnotation.stories.tsx +95 -0
  8. package/components/composites/AgentAnnotation/AgentAnnotation.tsx +127 -0
  9. package/components/composites/AgentAnnotation/index.ts +2 -0
  10. package/components/composites/ApprovalCard/ApprovalCard.tsx +125 -65
  11. package/components/composites/DevicePreviewNode/DevicePreviewNode.tsx +131 -68
  12. package/components/composites/DevicePreviewNode/interfaces.ts +42 -0
  13. package/components/composites/DevicePreviewToolbar/DevicePreviewToolbar.stories.tsx +12 -14
  14. package/components/composites/DevicePreviewToolbar/DevicePreviewToolbar.tsx +16 -55
  15. package/components/composites/DevicePreviewToolbar/index.ts +0 -1
  16. package/components/composites/DevicePreviewToolbar/interfaces.ts +5 -16
  17. package/components/composites/PromptInput/PromptInput.tsx +61 -5
  18. package/components/composites/PromptInput/index.ts +15 -1
  19. package/components/composites/UserMessage/UserMessage.tsx +97 -3
  20. package/components/composites/UserMessage/index.ts +1 -1
  21. package/components/composites/WorkflowToolbar/WorkflowToolbar.tsx +57 -22
  22. package/components/composites/index.ts +19 -3
  23. package/components/features/ChatPanel/ChatPanel.tsx +9 -2
  24. package/components/features/ChatPanel/index.ts +2 -1
  25. package/components/features/ChatPanel/useChatPanel.d.ts +14 -2
  26. package/components/features/NodeEditor/NodeEditor.tsx +5 -0
  27. package/components/index.ts +35 -3
  28. package/dist/index.cjs +727 -420
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.css +112 -33
  31. package/dist/index.d.ts +206 -65
  32. package/dist/index.js +717 -416
  33. package/dist/index.js.map +1 -1
  34. package/lib/workflow/interfaces.ts +11 -1
  35. package/package.json +3 -2
@@ -29,11 +29,16 @@ export const ExpoAppPreview = React.memo<ExpoAppPreviewProps>(
29
29
  sandbox = "allow-scripts allow-same-origin allow-forms allow-popups",
30
30
  allow,
31
31
  loading = false,
32
- loadingLabel = "Loading app…",
32
+ loadingLabel = "Loading your app…",
33
33
  isEmpty = false,
34
- emptyLabel = "No preview available",
34
+ emptyLabel = "Nothing to preview yet",
35
35
  error = null,
36
- errorLabel = "Preview unavailable",
36
+ // Addressed to the person using the app, not to whoever is debugging it.
37
+ // "Preview unavailable" described the system's state; this says what it
38
+ // means for them. The specific cause still comes through in `error`
39
+ // underneath, and the case that used to dominate here — no sandbox — is
40
+ // now handled by the Wake screen before this component is ever rendered.
41
+ errorLabel = "Nothing to preview",
37
42
  onLoad,
38
43
  onError,
39
44
  onMessage,
@@ -37,6 +37,7 @@ import {
37
37
  import { cn } from "@/lib/utils";
38
38
  import type { ChatStatus, FileUIPart } from "ai";
39
39
  import {
40
+ FileIcon,
40
41
  Loader2Icon,
41
42
  SendIcon,
42
43
  SquareIcon,
@@ -111,7 +112,7 @@ export const usePromptInputController = () => {
111
112
  };
112
113
 
113
114
  // Optional variants (do NOT throw). Useful for dual-mode components.
114
- const useOptionalPromptInputController = () =>
115
+ export const useOptionalPromptInputController = () =>
115
116
  useContext(PromptInputController);
116
117
 
117
118
  export const useProviderAttachments = () => {
@@ -265,81 +266,81 @@ export function PromptInputAttachment({
265
266
  data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
266
267
  const isImage = mediaType === "image";
267
268
 
268
- const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
269
-
270
- return (
271
- <PromptInputHoverCard>
272
- <HoverCardTrigger asChild>
273
- <div
274
- className={cn(
275
- "group relative flex h-8 cursor-default select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
276
- className
277
- )}
278
- key={data.id}
279
- {...props}
269
+ // For non-image files, truncate filename to max 20 characters followed by '..'
270
+ const attachmentLabel = filename
271
+ ? filename.length > 20
272
+ ? `${filename.slice(0, 20)}..`
273
+ : filename
274
+ : isImage
275
+ ? "Image"
276
+ : "Attachment";
277
+
278
+ if (isImage) {
279
+ return (
280
+ <div
281
+ className={cn(
282
+ "group relative size-10 shrink-0 overflow-hidden rounded-md border border-border bg-muted transition-all",
283
+ className
284
+ )}
285
+ key={data.id}
286
+ title={filename || undefined}
287
+ {...props}
288
+ >
289
+ <img
290
+ alt={filename || "attachment"}
291
+ className="size-full object-cover"
292
+ src={data.url}
293
+ />
294
+ <Button
295
+ aria-label="Remove attachment"
296
+ className="absolute inset-0 size-full cursor-pointer rounded-none bg-black/40 p-0 text-white opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-black/60 [&>svg]:size-3.5"
297
+ onClick={(e) => {
298
+ e.stopPropagation();
299
+ attachments.remove(data.id);
300
+ }}
301
+ type="button"
302
+ variant="ghost"
280
303
  >
281
- <div className="relative size-5 shrink-0">
282
- <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
283
- {isImage ? (
284
- <img
285
- alt={filename || "attachment"}
286
- className="size-5 object-cover"
287
- height={20}
288
- src={data.url}
289
- width={20}
290
- />
291
- ) : (
292
- <div className="flex size-5 items-center justify-center text-muted-foreground">
293
- <div className="size-3" />
294
- </div>
295
- )}
296
- </div>
297
- <Button
298
- aria-label="Remove attachment"
299
- className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
300
- onClick={(e) => {
301
- e.stopPropagation();
302
- attachments.remove(data.id);
303
- }}
304
- type="button"
305
- variant="ghost"
306
- >
307
- <XIcon />
308
- <span className="sr-only">Remove</span>
309
- </Button>
310
- </div>
304
+ <XIcon />
305
+ <span className="sr-only">Remove</span>
306
+ </Button>
307
+ </div>
308
+ );
309
+ }
311
310
 
312
- <span className="flex-1 truncate">{attachmentLabel}</span>
313
- </div>
314
- </HoverCardTrigger>
315
- <PromptInputHoverCardContent className="w-auto p-2">
316
- <div className="w-auto space-y-3">
317
- {isImage && (
318
- <div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
319
- <img
320
- alt={filename || "attachment preview"}
321
- className="max-h-full max-w-full object-contain"
322
- height={384}
323
- src={data.url}
324
- width={448}
325
- />
326
- </div>
327
- )}
328
- <div className="flex items-center gap-2.5">
329
- <div className="min-w-0 flex-1 space-y-1 px-0.5">
330
- <h4 className="truncate font-semibold text-sm leading-none">
331
- {filename || (isImage ? "Image" : "Attachment")}
332
- </h4>
333
- {data.mediaType && (
334
- <p className="truncate font-mono text-muted-foreground text-xs">
335
- {data.mediaType}
336
- </p>
337
- )}
338
- </div>
311
+ return (
312
+ <div
313
+ className={cn(
314
+ "group relative flex h-8 cursor-default select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
315
+ className
316
+ )}
317
+ key={data.id}
318
+ title={filename || undefined}
319
+ {...props}
320
+ >
321
+ <div className="relative size-5 shrink-0">
322
+ <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
323
+ <div className="flex size-5 items-center justify-center text-muted-foreground">
324
+ <FileIcon className="size-3.5" />
339
325
  </div>
340
326
  </div>
341
- </PromptInputHoverCardContent>
342
- </PromptInputHoverCard>
327
+ <Button
328
+ aria-label="Remove attachment"
329
+ className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
330
+ onClick={(e) => {
331
+ e.stopPropagation();
332
+ attachments.remove(data.id);
333
+ }}
334
+ type="button"
335
+ variant="ghost"
336
+ >
337
+ <XIcon />
338
+ <span className="sr-only">Remove</span>
339
+ </Button>
340
+ </div>
341
+
342
+ <span className="flex-1 truncate">{attachmentLabel}</span>
343
+ </div>
343
344
  );
344
345
  }
345
346
 
@@ -351,7 +352,9 @@ export type PromptInputAttachmentsProps = Omit<
351
352
  };
352
353
 
353
354
  export function PromptInputAttachments({
355
+ className,
354
356
  children,
357
+ ...props
355
358
  }: PromptInputAttachmentsProps) {
356
359
  const attachments = usePromptInputAttachments();
357
360
 
@@ -359,9 +362,19 @@ export function PromptInputAttachments({
359
362
  return null;
360
363
  }
361
364
 
362
- return attachments.files.map((file) => (
363
- <React.Fragment key={file.id}>{children(file)}</React.Fragment>
364
- ));
365
+ return (
366
+ <div
367
+ className={cn(
368
+ "flex w-full flex-wrap items-center justify-start self-start gap-1.5 px-3 pt-2",
369
+ className
370
+ )}
371
+ {...props}
372
+ >
373
+ {attachments.files.map((file) => (
374
+ <React.Fragment key={file.id}>{children(file)}</React.Fragment>
375
+ ))}
376
+ </div>
377
+ );
365
378
  }
366
379
 
367
380
  export type PromptInputActionAddAttachmentsProps = ComponentProps<
@@ -674,7 +687,9 @@ export const PromptInput = ({
674
687
 
675
688
  // Convert blob URLs to data URLs asynchronously
676
689
  Promise.all(
677
- files.map(async ({ id, ...item }) => {
690
+ files.map(async (file) => {
691
+ const item = { ...file };
692
+ delete (item as { id?: string }).id;
678
693
  if (item.url && item.url.startsWith("blob:")) {
679
694
  return {
680
695
  ...item,
@@ -706,7 +721,7 @@ export const PromptInput = ({
706
721
  controller.textInput.clear();
707
722
  }
708
723
  }
709
- } catch (error) {
724
+ } catch {
710
725
  // Don't clear on error - user may want to retry
711
726
  }
712
727
  });
@@ -268,7 +268,6 @@ export const WithCheckpointRestore: Story = {
268
268
  content: 'React is a JavaScript library for building user interfaces. It was developed by Facebook and is now maintained by Meta and a community of developers.',
269
269
  avatarSrc: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face',
270
270
  avatarName: 'Coordinator',
271
- checkpointId: 'cp-step-1',
272
271
  },
273
272
  {
274
273
  id: '3',
@@ -277,6 +276,9 @@ export const WithCheckpointRestore: Story = {
277
276
  content: 'How does component state work?',
278
277
  avatarSrc: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=32&h=32&fit=crop&crop=face',
279
278
  avatarName: 'User',
279
+ // Rewinds to the state before this question was asked. The first user
280
+ // message deliberately carries none — there is nothing before it.
281
+ checkpointId: 'cp-step-1',
280
282
  },
281
283
  {
282
284
  id: '4',
@@ -285,6 +287,14 @@ export const WithCheckpointRestore: Story = {
285
287
  content: 'State in React represents values that can change over time and trigger component re-renders when updated.',
286
288
  avatarSrc: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face',
287
289
  avatarName: 'Coordinator',
290
+ },
291
+ {
292
+ id: '5',
293
+ type: 'human',
294
+ role: 'user',
295
+ content: 'Show me an example.',
296
+ avatarSrc: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=32&h=32&fit=crop&crop=face',
297
+ avatarName: 'User',
288
298
  checkpointId: 'cp-step-2',
289
299
  },
290
300
  ],
@@ -6,13 +6,8 @@ import {
6
6
  ConversationScrollButton,
7
7
  type ConversationProps,
8
8
  } from "@/components/ai-elements/conversation"
9
- import {
10
- Checkpoint,
11
- CheckpointIcon,
12
- CheckpointTrigger,
13
- } from "@/components/ai-elements/checkpoint"
14
9
  import type { ToolCall, SubAgent } from "@/components/composites"
15
- import { UserMessage } from "@/components/composites/UserMessage"
10
+ import { UserMessage, type UserMessageAttachment } from "@/components/composites/UserMessage"
16
11
  import { SpecialistMessage } from "@/components/composites/SpecialistMessage"
17
12
  import { OrchestratorMessage } from "@/components/composites/OrchestratorMessage"
18
13
  import { SystemMessage } from "@/components/composites/SystemMessage"
@@ -61,6 +56,7 @@ interface AIMessage {
61
56
  isLoading?: boolean;
62
57
  blocks?: AIMessageBlock[];
63
58
  checkpointId?: string;
59
+ attachments?: UserMessageAttachment[];
64
60
  }
65
61
 
66
62
  export interface AIConversationProps
@@ -293,8 +289,11 @@ export const AIConversation = React.memo<AIConversationProps>(
293
289
  content: contentStr,
294
290
  avatarSrc: message.avatarSrc,
295
291
  avatarName: message.avatarName,
292
+ attachments: message.attachments,
293
+ checkpointId: message.checkpointId,
296
294
  }}
297
295
  showAvatar={showAvatars}
296
+ onRestore={onRestoreCheckpoint}
298
297
  />
299
298
  )
300
299
  }
@@ -406,18 +405,6 @@ export const AIConversation = React.memo<AIConversationProps>(
406
405
  </ReasoningDisplay>
407
406
  )}
408
407
  </OrchestratorMessage>
409
- {message.checkpointId && onRestoreCheckpoint && !message.isLoading && !hasReasoning && (
410
- <Checkpoint>
411
- <CheckpointIcon />
412
- <CheckpointTrigger
413
- onClick={() =>
414
- onRestoreCheckpoint(message.id, message.checkpointId!)
415
- }
416
- >
417
- Restore checkpoint
418
- </CheckpointTrigger>
419
- </Checkpoint>
420
- )}
421
408
  </React.Fragment>
422
409
  )
423
410
  }
@@ -435,18 +422,6 @@ export const AIConversation = React.memo<AIConversationProps>(
435
422
  }}
436
423
  isNested={false}
437
424
  />
438
- {message.checkpointId && onRestoreCheckpoint && !message.isLoading && (
439
- <Checkpoint>
440
- <CheckpointIcon />
441
- <CheckpointTrigger
442
- onClick={() =>
443
- onRestoreCheckpoint(message.id, message.checkpointId!)
444
- }
445
- >
446
- Restore checkpoint
447
- </CheckpointTrigger>
448
- </Checkpoint>
449
- )}
450
425
  </React.Fragment>
451
426
  )
452
427
  }),
@@ -46,7 +46,8 @@ export const TRANSITION_NODE_SIZE = { width: 180, height: 52 };
46
46
  * same ELK `layered` engine the builder uses — but with correct per-node
47
47
  * dimensions for `devicePreview` vs `transition` nodes.
48
48
  *
49
- * Direction is RIGHT (left-to-right) to match the Bookgeek reference UX.
49
+ * Direction is RIGHT (left-to-right) to match the mobile-app-flow reference UX
50
+ * this canvas was modeled on.
50
51
  */
51
52
  export async function routesToPreviewCanvas(
52
53
  manifest: AppRouteManifest,
@@ -6,6 +6,8 @@ import {
6
6
  type Connection,
7
7
  type Node,
8
8
  useReactFlow,
9
+ useStoreApi,
10
+ useUpdateNodeInternals,
9
11
  } from "@xyflow/react";
10
12
  import { useCallback, useEffect } from "react";
11
13
  import { Canvas } from "@/components/ai-elements/canvas";
@@ -18,9 +20,26 @@ import { TransitionNode } from "@/components/composites/TransitionNode";
18
20
  import { TriggerNode } from "@/components/composites/TriggerNode";
19
21
  import { SpatialContainerNode } from "@/components/composites/SpatialContainerNode";
20
22
  import { DevicePreviewNode } from "@/components/composites/DevicePreviewNode";
23
+ import { AgentAnnotation } from "@/components/composites/AgentAnnotation";
21
24
  import type { WorkflowCanvasProps, WorkflowEdge } from "@/lib/workflow/interfaces";
22
25
  import "@xyflow/react/dist/style.css";
23
26
 
27
+ // Bounded retry for node measurement — see the effect in WorkflowCanvasInner.
28
+ //
29
+ // ~30s, which sounds long and is not. The loop stops the instant every node
30
+ // reports handleBounds, so in the ordinary case it ends after one pass; the cap
31
+ // only bounds the pathological case where a node never appears at all.
32
+ //
33
+ // It was 3s, and that was too short for the case this exists to fix. The grid's
34
+ // device nodes each carry an iframe, and on a cold sandbox they are not in the
35
+ // DOM until Metro has served a bundle. `useUpdateNodeInternals` silently drops
36
+ // every id it cannot resolve, so a window that closes before the iframes mount
37
+ // spends all its attempts on nothing and gives up — leaving exactly the blank
38
+ // canvas of edgeless nodes it was added to prevent. Verified live: all five
39
+ // nodes resolvable, all five still `handleBounds: NULL`.
40
+ const MEASURE_RETRY_MS = 250;
41
+ const MEASURE_MAX_ATTEMPTS = 120;
42
+
24
43
  const edgeTypes = {
25
44
  straight: Edge.Strict,
26
45
  animated: Edge.Animated,
@@ -33,6 +52,7 @@ const nodeTypes = {
33
52
  trigger: TriggerNode,
34
53
  spatialContainer: SpatialContainerNode,
35
54
  devicePreview: DevicePreviewNode,
55
+ agentAnnotation: AgentAnnotation,
36
56
  };
37
57
 
38
58
  function WorkflowCanvasInner({
@@ -52,9 +72,95 @@ function WorkflowCanvasInner({
52
72
  topLeft,
53
73
  topRight,
54
74
  topCenter,
75
+ fitViewSignal,
55
76
  className,
56
77
  }: WorkflowCanvasProps) {
57
78
  const { fitView } = useReactFlow();
79
+ const updateNodeInternals = useUpdateNodeInternals();
80
+ const store = useStoreApi();
81
+
82
+ /**
83
+ * Force xyflow to measure the nodes it is showing.
84
+ *
85
+ * xyflow will not draw an edge until BOTH endpoints have `handleBounds` —
86
+ * the measured position of each handle within its node — and those come from
87
+ * one place only: the per-node ResizeObserver. Publishing `initialWidth` /
88
+ * `initialHeight` (which the preview nodes do) satisfies the separate
89
+ * `nodeHasDimensions` check that controls VISIBILITY, and does nothing for
90
+ * `handleBounds`. The two are easy to conflate and fail very differently.
91
+ *
92
+ * When that observer does not deliver — it fires as an unsynchronized
93
+ * callback (see the fitView effect below, which works around the same race),
94
+ * and reports nothing for a node that is re-created but never changes size —
95
+ * the result is not a missing node. It is a canvas of perfectly normal nodes
96
+ * with EVERY edge silently absent: `edgeLookup` holds them all, each with
97
+ * valid endpoints and handle ids, and not one is rendered. Observed live with
98
+ * all five nodes at `measured: {}` and `handleBounds: null` while their DOM
99
+ * elements had real size, and xyflow logged no warning.
100
+ *
101
+ * Keyed on the node ids rather than the `nodes` array, which is a new
102
+ * reference on nearly every render.
103
+ *
104
+ * RETRIED, not fired once, because the request is silently dropped when it
105
+ * is early. `useUpdateNodeInternals` resolves each id through
106
+ * `domNode.querySelector('.react-flow__node[data-id=...]')` and keeps only
107
+ * the ones it finds — a node that has not mounted yet is skipped with no
108
+ * error and no retry, and since the id list has not changed, the effect
109
+ * never runs again. One shot at 60ms worked for the single-device view and
110
+ * missed the grid every time, where five nodes carrying iframes mount later.
111
+ * So it repeats until the store actually reports `handleBounds`, and stops
112
+ * the moment it does.
113
+ */
114
+ const nodeIdSignature = nodes.map((n) => n.id).join("|");
115
+ useEffect(() => {
116
+ if (!nodeIdSignature) return;
117
+ const ids = nodeIdSignature.split("|");
118
+ let attempts = 0;
119
+ let timer: ReturnType<typeof setTimeout>;
120
+
121
+ const measured = () => {
122
+ const lookup = store.getState().nodeLookup;
123
+ return ids.every((id) => !!lookup.get(id)?.internals?.handleBounds);
124
+ };
125
+
126
+ const pass = () => {
127
+ updateNodeInternals(ids);
128
+ attempts += 1;
129
+ // `updateNodeInternals` defers its own work to a rAF, so the result is
130
+ // only readable on a later tick — hence check on the next timer rather
131
+ // than immediately after the call.
132
+ timer = setTimeout(() => {
133
+ if (!measured() && attempts < MEASURE_MAX_ATTEMPTS) pass();
134
+ }, MEASURE_RETRY_MS);
135
+ };
136
+
137
+ timer = setTimeout(pass, 60);
138
+ return () => clearTimeout(timer);
139
+ }, [nodeIdSignature, updateNodeInternals, store]);
140
+
141
+ // Opt-in re-fit, e.g. after the device-preview toolbar's Route select
142
+ // switches to "All" and the node count/extent changes underneath the
143
+ // user. Deliberately keyed on `fitViewSignal` alone, not `nodes`/`edges`
144
+ // — those are new array references on nearly every render (activeEdgeIds
145
+ // toggling an edge's type, status polling, ...), so fitting view on
146
+ // every such change would override the user's own pan/zoom constantly.
147
+ useEffect(() => {
148
+ if (fitViewSignal === undefined) return;
149
+ // A plain synchronous call, and even a double-rAF, both raced xyflow's
150
+ // own internal node measurement — verified live in both cases: the
151
+ // resulting scale was far smaller than the two device-preview nodes'
152
+ // true combined width needed (computed against their real DOM rects:
153
+ // ~1.2 would tightly fit them, but fitView produced ~0.57 and one node
154
+ // still landed partly outside the pane). xyflow measures each node via
155
+ // ResizeObserver, which fires as a separate, unsynchronized callback —
156
+ // not guaranteed to have run even two animation frames after the nodes
157
+ // prop change lands. A short real-time delay is the standard, if
158
+ // inelegant, workaround for this exact class of race.
159
+ const timer = setTimeout(() => {
160
+ fitView({ padding: 0.2, duration: 300 });
161
+ }, 50);
162
+ return () => clearTimeout(timer);
163
+ }, [fitViewSignal, fitView]);
58
164
 
59
165
  useEffect(() => {
60
166
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -125,9 +231,13 @@ function WorkflowCanvasInner({
125
231
  </Panel>
126
232
  )}
127
233
  {topCenter && (
234
+ // Rendered top-left, not top-center — the prop name is legacy
235
+ // (its one real consumer, the device-preview toolbar, moved to
236
+ // left alignment; nothing else uses this slot for content that
237
+ // actually wants centering).
128
238
  <Panel
129
239
  className="pointer-events-auto border-none bg-transparent p-0"
130
- position="top-center"
240
+ position="top-left"
131
241
  >
132
242
  {topCenter}
133
243
  </Panel>
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ReactFlowProvider } from "@xyflow/react";
3
+ import { AgentAnnotation } from "./AgentAnnotation";
4
+ import "@xyflow/react/dist/style.css";
5
+
6
+ /**
7
+ * AgentAnnotation Composite Stories
8
+ *
9
+ * A wordless presence pin marking the screen or region an agent is actively
10
+ * working on, completed, or stopped with error.
11
+ *
12
+ * ## Features
13
+ * - Pulsing animated halo for active work
14
+ * - Accessible hover label and screen reader text
15
+ * - Color accents distinguishing concurrent agents
16
+ * - Counter-scaled to maintain constant screen size across viewport zoom
17
+ */
18
+ const meta = {
19
+ title: "Composites/AgentAnnotation",
20
+ component: AgentAnnotation,
21
+ tags: ["autodocs"],
22
+ decorators: [
23
+ (Story) => (
24
+ <ReactFlowProvider>
25
+ <div className="flex items-center justify-center p-8">
26
+ <Story />
27
+ </div>
28
+ </ReactFlowProvider>
29
+ ),
30
+ ],
31
+ parameters: {
32
+ layout: "centered",
33
+ docs: {
34
+ description: {
35
+ component:
36
+ "Wordless presence pin marking the active, completed, or failed screen position of a sub-agent.",
37
+ },
38
+ },
39
+ },
40
+ } satisfies Meta<typeof AgentAnnotation>;
41
+
42
+ export default meta;
43
+ type Story = StoryObj<typeof meta>;
44
+
45
+ export const Active: Story = {
46
+ args: {
47
+ id: "agent-pin-active",
48
+ selected: false,
49
+ data: {
50
+ type: "agentAnnotation",
51
+ agentName: "Coder Specialist",
52
+ status: "active",
53
+ colorIndex: 0,
54
+ },
55
+ },
56
+ };
57
+
58
+ export const Completed: Story = {
59
+ args: {
60
+ id: "agent-pin-completed",
61
+ selected: false,
62
+ data: {
63
+ type: "agentAnnotation",
64
+ agentName: "Architect Specialist",
65
+ status: "completed",
66
+ colorIndex: 1,
67
+ },
68
+ },
69
+ };
70
+
71
+ export const ErrorState: Story = {
72
+ args: {
73
+ id: "agent-pin-error",
74
+ selected: false,
75
+ data: {
76
+ type: "agentAnnotation",
77
+ agentName: "Review Specialist",
78
+ status: "error",
79
+ colorIndex: 3,
80
+ },
81
+ },
82
+ };
83
+
84
+ export const AmberAccent: Story = {
85
+ args: {
86
+ id: "agent-pin-amber",
87
+ selected: false,
88
+ data: {
89
+ type: "agentAnnotation",
90
+ agentName: "Data Engineer",
91
+ status: "active",
92
+ colorIndex: 2,
93
+ },
94
+ },
95
+ };