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.
- 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 +54 -31
- package/components/blocks/PreviewCanvas/preview-layout.ts +2 -1
- package/components/blocks/WorkflowCanvas/WorkflowCanvas.tsx +32 -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/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/OrchestratorMessage/OrchestratorMessage.tsx +51 -2
- package/components/composites/PromptInput/PromptInput.stories.tsx +63 -0
- package/components/composites/PromptInput/PromptInput.tsx +138 -5
- package/components/composites/PromptInput/index.ts +41 -1
- package/components/composites/UserMessage/UserMessage.tsx +124 -3
- package/components/composites/UserMessage/index.ts +1 -1
- package/components/composites/WorkflowToolbar/WorkflowToolbar.tsx +79 -22
- package/components/composites/WorkflowToolbar/interfaces.ts +14 -0
- package/components/composites/WorkspaceEmptyState/WorkspaceEmptyState.stories.tsx +38 -0
- package/components/composites/WorkspaceEmptyState/WorkspaceEmptyState.tsx +60 -0
- package/components/composites/WorkspaceEmptyState/index.ts +2 -0
- package/components/composites/index.ts +44 -3
- package/components/features/ChatPanel/ChatPanel.tsx +27 -3
- 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/features/TextEditor/TextEditor.tsx +43 -2
- package/components/features/TextEditor/index.ts +1 -0
- package/components/features/index.ts +1 -1
- package/components/index.ts +56 -3
- package/dist/index.cjs +1252 -464
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +115 -33
- package/dist/index.d.ts +291 -26
- package/dist/index.js +1231 -459
- 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,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
|
|
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-
|
|
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
|
+
};
|