ai-design-system 0.1.86 → 0.1.92

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 (44) 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 +54 -31
  5. package/components/blocks/PreviewCanvas/preview-layout.ts +2 -1
  6. package/components/blocks/WorkflowCanvas/WorkflowCanvas.tsx +32 -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/OrchestratorMessage/OrchestratorMessage.tsx +51 -2
  18. package/components/composites/PromptInput/PromptInput.stories.tsx +63 -0
  19. package/components/composites/PromptInput/PromptInput.tsx +138 -5
  20. package/components/composites/PromptInput/index.ts +41 -1
  21. package/components/composites/UserMessage/UserMessage.tsx +124 -3
  22. package/components/composites/UserMessage/index.ts +1 -1
  23. package/components/composites/WorkflowToolbar/WorkflowToolbar.tsx +79 -22
  24. package/components/composites/WorkflowToolbar/interfaces.ts +14 -0
  25. package/components/composites/WorkspaceEmptyState/WorkspaceEmptyState.stories.tsx +38 -0
  26. package/components/composites/WorkspaceEmptyState/WorkspaceEmptyState.tsx +60 -0
  27. package/components/composites/WorkspaceEmptyState/index.ts +2 -0
  28. package/components/composites/index.ts +44 -3
  29. package/components/features/ChatPanel/ChatPanel.tsx +27 -3
  30. package/components/features/ChatPanel/index.ts +2 -1
  31. package/components/features/ChatPanel/useChatPanel.d.ts +14 -2
  32. package/components/features/NodeEditor/NodeEditor.tsx +5 -0
  33. package/components/features/TextEditor/TextEditor.tsx +43 -2
  34. package/components/features/TextEditor/index.ts +1 -0
  35. package/components/features/index.ts +1 -1
  36. package/components/index.ts +56 -3
  37. package/dist/index.cjs +1252 -464
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.css +115 -33
  40. package/dist/index.d.ts +291 -26
  41. package/dist/index.js +1231 -459
  42. package/dist/index.js.map +1 -1
  43. package/lib/workflow/interfaces.ts +11 -1
  44. 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,8 @@ interface AIMessage {
61
56
  isLoading?: boolean;
62
57
  blocks?: AIMessageBlock[];
63
58
  checkpointId?: string;
59
+ restoreLabel?: string;
60
+ attachments?: UserMessageAttachment[];
64
61
  }
65
62
 
66
63
  export interface AIConversationProps
@@ -102,6 +99,17 @@ export interface AIConversationProps
102
99
  * Callback fired when user restores a conversation checkpoint
103
100
  */
104
101
  onRestoreCheckpoint?: (messageId: string, checkpointId: string) => void
102
+ /**
103
+ * Save the workspace as it stands after an orchestrator reply.
104
+ *
105
+ * Paired with `onRestoreCheckpoint`: undo sits on what the user asked for,
106
+ * save sits on what the agent produced. Omit it and no reply offers to save.
107
+ */
108
+ onSaveWorkspace?: () => void
109
+ /** True when the current version is already kept — every Save reads as saved. */
110
+ isCurrentVersionSaved?: boolean
111
+ /** A save is in flight — the control says so and refuses a second one. */
112
+ isSavingWorkspace?: boolean
105
113
  }
106
114
 
107
115
  /**
@@ -117,6 +125,9 @@ export const AIConversation = React.memo<AIConversationProps>(
117
125
  onToolAction,
118
126
  renderSystemMessage,
119
127
  onRestoreCheckpoint,
128
+ onSaveWorkspace,
129
+ isCurrentVersionSaved,
130
+ isSavingWorkspace,
120
131
  ...conversationProps
121
132
  }) => {
122
133
  const isEmpty = React.useMemo(
@@ -246,6 +257,7 @@ export const AIConversation = React.memo<AIConversationProps>(
246
257
  subAgents: msg.subAgents ? [...msg.subAgents] : undefined,
247
258
  blocks: msg.blocks ? [...msg.blocks] : undefined,
248
259
  checkpointId: msg.checkpointId,
260
+ restoreLabel: msg.restoreLabel,
249
261
  };
250
262
  result.push(currentGroup);
251
263
  }
@@ -261,6 +273,27 @@ export const AIConversation = React.memo<AIConversationProps>(
261
273
  return result;
262
274
  }, [messages]);
263
275
 
276
+ /**
277
+ * The last orchestrator reply, and the ONLY one that may offer to save.
278
+ *
279
+ * Saving anchors to the most recent request, so it captures the workspace as
280
+ * it is NOW — not as it was when some earlier reply was written. Offering the
281
+ * control on every reply implies each one saves its own moment, which is
282
+ * false and unrecoverable once acted on: the user would believe they had kept
283
+ * a point they never kept.
284
+ *
285
+ * `isCurrentVersionSaved` is likewise one fact about the workspace, not a
286
+ * property of each message. Rendered on every reply it read as "all of these
287
+ * are saved"; it belongs on the single reply the save would come from.
288
+ */
289
+ const lastReplyIndex = React.useMemo(() => {
290
+ for (let i = groupedMessages.length - 1; i >= 0; i--) {
291
+ const m = groupedMessages[i]
292
+ if (m.role === "orchestrator" || m.role === "assistant" || (!m.role && m.type === "ai")) return i
293
+ }
294
+ return -1
295
+ }, [groupedMessages])
296
+
264
297
  const renderedMessages = React.useMemo(
265
298
  () =>
266
299
  groupedMessages.map((message, index) => {
@@ -293,8 +326,12 @@ export const AIConversation = React.memo<AIConversationProps>(
293
326
  content: contentStr,
294
327
  avatarSrc: message.avatarSrc,
295
328
  avatarName: message.avatarName,
329
+ attachments: message.attachments,
330
+ checkpointId: message.checkpointId,
331
+ restoreLabel: message.restoreLabel,
296
332
  }}
297
333
  showAvatar={showAvatars}
334
+ onRestore={onRestoreCheckpoint}
298
335
  />
299
336
  )
300
337
  }
@@ -336,6 +373,16 @@ export const AIConversation = React.memo<AIConversationProps>(
336
373
  isLoading: message.isLoading && !hasReasoning,
337
374
  }}
338
375
  showAvatar={showAvatars && hasDisplayContent}
376
+ // Only on a settled reply with something to read: a save
377
+ // offered beside a streaming or empty message would anchor a
378
+ // snapshot to a turn that has not produced anything yet.
379
+ onSave={
380
+ index === lastReplyIndex && hasDisplayContent && !isStreaming
381
+ ? onSaveWorkspace
382
+ : undefined
383
+ }
384
+ isSaved={index === lastReplyIndex && isCurrentVersionSaved}
385
+ isSaving={index === lastReplyIndex && isSavingWorkspace}
339
386
  >
340
387
  {/* Render reasoning-section for hidden tool results and subagents */}
341
388
  {hasReasoning && (
@@ -406,18 +453,6 @@ export const AIConversation = React.memo<AIConversationProps>(
406
453
  </ReasoningDisplay>
407
454
  )}
408
455
  </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
456
  </React.Fragment>
422
457
  )
423
458
  }
@@ -435,22 +470,10 @@ export const AIConversation = React.memo<AIConversationProps>(
435
470
  }}
436
471
  isNested={false}
437
472
  />
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
473
  </React.Fragment>
451
474
  )
452
475
  }),
453
- [groupedMessages, showAvatars, onToolAction, onRestoreCheckpoint]
476
+ [groupedMessages, showAvatars, onToolAction, onRestoreCheckpoint, onSaveWorkspace, isCurrentVersionSaved, isSavingWorkspace, lastReplyIndex]
454
477
  )
455
478
 
456
479
  return (
@@ -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,
@@ -18,6 +18,7 @@ import { TransitionNode } from "@/components/composites/TransitionNode";
18
18
  import { TriggerNode } from "@/components/composites/TriggerNode";
19
19
  import { SpatialContainerNode } from "@/components/composites/SpatialContainerNode";
20
20
  import { DevicePreviewNode } from "@/components/composites/DevicePreviewNode";
21
+ import { AgentAnnotation } from "@/components/composites/AgentAnnotation";
21
22
  import type { WorkflowCanvasProps, WorkflowEdge } from "@/lib/workflow/interfaces";
22
23
  import "@xyflow/react/dist/style.css";
23
24
 
@@ -33,6 +34,7 @@ const nodeTypes = {
33
34
  trigger: TriggerNode,
34
35
  spatialContainer: SpatialContainerNode,
35
36
  devicePreview: DevicePreviewNode,
37
+ agentAnnotation: AgentAnnotation,
36
38
  };
37
39
 
38
40
  function WorkflowCanvasInner({
@@ -52,10 +54,35 @@ function WorkflowCanvasInner({
52
54
  topLeft,
53
55
  topRight,
54
56
  topCenter,
57
+ fitViewSignal,
55
58
  className,
56
59
  }: WorkflowCanvasProps) {
57
60
  const { fitView } = useReactFlow();
58
61
 
62
+ // Opt-in re-fit, e.g. after the device-preview toolbar's Route select
63
+ // switches to "All" and the node count/extent changes underneath the
64
+ // user. Deliberately keyed on `fitViewSignal` alone, not `nodes`/`edges`
65
+ // — those are new array references on nearly every render (activeEdgeIds
66
+ // toggling an edge's type, status polling, ...), so fitting view on
67
+ // every such change would override the user's own pan/zoom constantly.
68
+ useEffect(() => {
69
+ if (fitViewSignal === undefined) return;
70
+ // A plain synchronous call, and even a double-rAF, both raced xyflow's
71
+ // own internal node measurement — verified live in both cases: the
72
+ // resulting scale was far smaller than the two device-preview nodes'
73
+ // true combined width needed (computed against their real DOM rects:
74
+ // ~1.2 would tightly fit them, but fitView produced ~0.57 and one node
75
+ // still landed partly outside the pane). xyflow measures each node via
76
+ // ResizeObserver, which fires as a separate, unsynchronized callback —
77
+ // not guaranteed to have run even two animation frames after the nodes
78
+ // prop change lands. A short real-time delay is the standard, if
79
+ // inelegant, workaround for this exact class of race.
80
+ const timer = setTimeout(() => {
81
+ fitView({ padding: 0.2, duration: 300 });
82
+ }, 50);
83
+ return () => clearTimeout(timer);
84
+ }, [fitViewSignal, fitView]);
85
+
59
86
  useEffect(() => {
60
87
  const handleKeyDown = (event: KeyboardEvent) => {
61
88
  if ((event.metaKey || event.ctrlKey) && event.key === "/") {
@@ -125,9 +152,13 @@ function WorkflowCanvasInner({
125
152
  </Panel>
126
153
  )}
127
154
  {topCenter && (
155
+ // Rendered top-left, not top-center — the prop name is legacy
156
+ // (its one real consumer, the device-preview toolbar, moved to
157
+ // left alignment; nothing else uses this slot for content that
158
+ // actually wants centering).
128
159
  <Panel
129
160
  className="pointer-events-auto border-none bg-transparent p-0"
130
- position="top-center"
161
+ position="top-left"
131
162
  >
132
163
  {topCenter}
133
164
  </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
+ };