ai-design-system 0.1.85 → 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.
- package/components/ai-elements/ExpoAppPreview/ExpoAppPreview.tsx +8 -3
- package/components/ai-elements/prompt-input.tsx +92 -77
- package/components/blocks/AIConversation/AIConversation.stories.tsx +11 -1
- package/components/blocks/AIConversation/AIConversation.tsx +5 -30
- package/components/blocks/PreviewCanvas/preview-layout.ts +2 -1
- package/components/blocks/WorkflowCanvas/WorkflowCanvas.tsx +111 -1
- package/components/composites/AgentAnnotation/AgentAnnotation.stories.tsx +95 -0
- package/components/composites/AgentAnnotation/AgentAnnotation.tsx +127 -0
- package/components/composites/AgentAnnotation/index.ts +2 -0
- package/components/composites/AppHeader/AppHeader.tsx +27 -33
- package/components/composites/AppHeader/interfaces.ts +2 -0
- package/components/composites/ApprovalCard/ApprovalCard.tsx +125 -65
- package/components/composites/DevicePreviewNode/DevicePreviewNode.tsx +131 -68
- package/components/composites/DevicePreviewNode/interfaces.ts +42 -0
- package/components/composites/DevicePreviewToolbar/DevicePreviewToolbar.stories.tsx +12 -14
- package/components/composites/DevicePreviewToolbar/DevicePreviewToolbar.tsx +16 -55
- package/components/composites/DevicePreviewToolbar/index.ts +0 -1
- package/components/composites/DevicePreviewToolbar/interfaces.ts +5 -16
- package/components/composites/PromptInput/PromptInput.tsx +61 -5
- package/components/composites/PromptInput/index.ts +15 -1
- package/components/composites/UserMessage/UserMessage.tsx +97 -3
- package/components/composites/UserMessage/index.ts +1 -1
- package/components/composites/WorkflowToolbar/WorkflowToolbar.tsx +57 -22
- package/components/composites/index.ts +19 -3
- package/components/features/ChatPanel/ChatPanel.tsx +9 -2
- package/components/features/ChatPanel/index.ts +2 -1
- package/components/features/ChatPanel/useChatPanel.d.ts +14 -2
- package/components/features/NodeEditor/NodeEditor.tsx +5 -0
- package/components/index.ts +35 -3
- package/dist/index.cjs +747 -424
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +112 -33
- package/dist/index.d.ts +219 -76
- package/dist/index.js +737 -420
- package/dist/index.js.map +1 -1
- package/lib/workflow/interfaces.ts +11 -1
- 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 = "
|
|
34
|
+
emptyLabel = "Nothing to preview yet",
|
|
35
35
|
error = null,
|
|
36
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
|
363
|
-
<
|
|
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 (
|
|
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
|
|
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
|
|
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-
|
|
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
|
+
};
|