@eventcatalog/core 5.7.0 → 5.7.1

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/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-22KZ4TJF.js → chunk-36XVRD36.js} +1 -1
  6. package/dist/{chunk-FMWQ2YYN.js → chunk-AXWTR2FV.js} +1 -1
  7. package/dist/{chunk-BE5PTWNC.js → chunk-CPF5WE3R.js} +1 -1
  8. package/dist/{chunk-4CV7VC4K.js → chunk-GRC67WHC.js} +1 -1
  9. package/dist/{chunk-MOPKQMQP.js → chunk-HZ3XZKUT.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +589 -120
  19. package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
  20. package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
  21. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
  22. package/eventcatalog/src/features/ai/chat-api.ts +24 -5
  23. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +62 -61
  24. package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
  25. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
  26. package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
  27. package/eventcatalog/src/features/studio/catalog.ts +5 -3
  28. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
  29. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +91 -10
  30. package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
  31. package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
  32. package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
  33. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +2 -14
  34. package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
  35. package/package.json +4 -4
@@ -1,5 +1,23 @@
1
1
  import { useEffect, useRef, useCallback, useState, useMemo, memo } from 'react';
2
- import { X, Square, Trash2, Sparkles, Copy, Check, Maximize2, Minimize2, Wrench, ChevronDown, MessageSquare } from 'lucide-react';
2
+ import {
3
+ X,
4
+ Square,
5
+ Trash2,
6
+ Sparkles,
7
+ Copy,
8
+ Check,
9
+ Maximize2,
10
+ Minimize2,
11
+ Wrench,
12
+ ChevronDown,
13
+ ChevronLeft,
14
+ ChevronRight,
15
+ MessageSquare,
16
+ LocateFixed,
17
+ OctagonAlert,
18
+ TriangleAlert,
19
+ Info,
20
+ } from 'lucide-react';
3
21
  import { useChat } from '@ai-sdk/react';
4
22
  import { DefaultChatTransport } from 'ai';
5
23
  import ReactMarkdown from 'react-markdown';
@@ -10,6 +28,16 @@ import * as Dialog from '@radix-ui/react-dialog';
10
28
  import * as Popover from '@radix-ui/react-popover';
11
29
  import { buildUrl } from '@utils/url-builder';
12
30
  import OfflineReply, { ConfigurationErrorReply, getChatError, offlineReplyText } from './OfflineReply';
31
+ import {
32
+ ASSISTANT_DRAWING_EVENT,
33
+ CANVAS_FOCUS_EVENT,
34
+ CANVAS_HOTSPOT_EVENT,
35
+ canvasRefIn,
36
+ nodesInFocusLink,
37
+ } from './assistant-events';
38
+ import { getResourceReferenceColorName } from '@utils/resource-reference-colors';
39
+ import { tailwind500RgbByColor } from '@utils/collection-colors';
40
+ import { getResourceReferenceIcon } from '@utils/resource-reference-icons';
13
41
 
14
42
  interface ToolMetadata {
15
43
  name: string;
@@ -23,10 +51,6 @@ const CHAT_PANEL_STYLES = `
23
51
  0%, 100% { transform: scale(1); opacity: .7; }
24
52
  50% { transform: scale(1.06); opacity: 1; }
25
53
  }
26
- @keyframes ec-chat-dot {
27
- 0%, 80%, 100% { transform: translateY(0); opacity: .35; }
28
- 40% { transform: translateY(-3px); opacity: 1; }
29
- }
30
54
  .ec-chat-surface { font-weight: 400; }
31
55
  .ec-chat-avatar { background: rgb(var(--ec-accent) / .06); animation: ec-chat-breathe 5s ease-in-out infinite; }
32
56
  /* The closed panel stays on the page (off screen): a running animation there still costs every frame */
@@ -38,9 +62,58 @@ const CHAT_PANEL_STYLES = `
38
62
  .ec-chat-offline > :nth-child(2) { animation-delay: 100ms; }
39
63
  .ec-chat-offline > :nth-child(3) { animation-delay: 220ms; }
40
64
  .ec-chat-offline > :nth-child(4) { animation-delay: 340ms; }
41
- .ec-chat-dot { width: 4px; height: 4px; border-radius: 50%; background: rgb(var(--ec-accent)); animation: ec-chat-dot 1.2s infinite; }
42
- .ec-chat-dot:nth-child(2) { animation-delay: 150ms; }
43
- .ec-chat-dot:nth-child(3) { animation-delay: 300ms; }
65
+ /* What the assistant's doing while it works: a card with a sheen passing over it, its steps so far ticked and the
66
+ one it's on spinning, its words shimmering */
67
+ @keyframes ec-chat-sheen {
68
+ from { background-position: 200% 0; }
69
+ to { background-position: -200% 0; }
70
+ }
71
+ @keyframes ec-chat-spin { to { transform: rotate(360deg); } }
72
+ .ec-chat-activity {
73
+ position: relative;
74
+ overflow: hidden;
75
+ border: 1px solid rgb(var(--ec-accent) / .18);
76
+ border-radius: 14px;
77
+ background: rgb(var(--ec-accent) / .03);
78
+ box-shadow: 0 6px 20px rgb(var(--ec-accent) / .05);
79
+ }
80
+ .ec-chat-activity::before {
81
+ content: '';
82
+ position: absolute;
83
+ inset: 0;
84
+ pointer-events: none;
85
+ background: linear-gradient(110deg, transparent 30%, rgb(var(--ec-accent) / .09) 50%, transparent 70%);
86
+ background-size: 200% 100%;
87
+ animation: ec-chat-sheen 2.2s linear infinite;
88
+ }
89
+ .ec-chat-activity-now {
90
+ background: linear-gradient(90deg, rgb(var(--ec-page-text)) 0%, rgb(var(--ec-accent)) 50%, rgb(var(--ec-page-text)) 100%);
91
+ background-size: 200% 100%;
92
+ -webkit-background-clip: text;
93
+ background-clip: text;
94
+ color: transparent;
95
+ animation: ec-chat-sheen 2.4s linear infinite;
96
+ }
97
+ .ec-chat-spinner {
98
+ width: 12px;
99
+ height: 12px;
100
+ flex: none;
101
+ border-radius: 50%;
102
+ border: 1.5px solid rgb(var(--ec-accent) / .2);
103
+ border-top-color: rgb(var(--ec-accent));
104
+ animation: ec-chat-spin .8s linear infinite;
105
+ }
106
+ /* Follow-up questions on their way: pills like theirs, a sheen passing along them */
107
+ .ec-chat-pending-pill {
108
+ height: 30px;
109
+ border-radius: 999px;
110
+ background: linear-gradient(90deg, rgb(var(--ec-accent) / .05) 25%, rgb(var(--ec-accent) / .14) 50%, rgb(var(--ec-accent) / .05) 75%);
111
+ background-size: 200% 100%;
112
+ animation: fadeInUp 300ms ease-out both, ec-chat-sheen 1.6s linear infinite;
113
+ }
114
+ .ec-chat-surface[aria-hidden='true'] .ec-chat-activity,
115
+ .ec-chat-surface[aria-hidden='true'] .ec-chat-activity *,
116
+ .ec-chat-surface[aria-hidden='true'] .ec-chat-pending-pill { animation-play-state: paused; }
44
117
  .ec-chat-composer {
45
118
  border-radius: 20px;
46
119
  border-color: rgb(var(--ec-accent) / .22);
@@ -49,8 +122,9 @@ const CHAT_PANEL_STYLES = `
49
122
  transition: border-color 200ms, box-shadow 200ms;
50
123
  }
51
124
  .ec-chat-composer:focus-within { border-color: rgb(var(--ec-accent) / .5); box-shadow: 0 0 0 3px rgb(var(--ec-accent) / .09); }
52
- .ec-chat-composer textarea { display: block; resize: none; min-height: 104px; padding: 14px 16px 46px; border-radius: 20px; }
53
- .ec-chat-composer-actions { top: auto; bottom: 10px; transform: none; }
125
+ /* What's typed grows the box (up to a point, then scrolls), with what it's based on and Send in a row under it */
126
+ .ec-chat-composer textarea { display: block; resize: none; min-height: 58px; max-height: 200px; overflow-y: auto; padding: 14px 16px 4px; border-radius: 20px 20px 0 0; }
127
+ .ec-chat-composer-footer { display: flex; align-items: center; gap: 8px; padding: 2px 8px 8px 16px; }
54
128
  @media (prefers-reduced-motion: reduce) {
55
129
  .ec-chat-surface, .ec-chat-surface *, #eventcatalog-header, #eventcatalog-application {
56
130
  animation: none !important;
@@ -153,6 +227,8 @@ const getGreeting = () => {
153
227
  interface SuggestedQuestion {
154
228
  label: string;
155
229
  prompt: string;
230
+ /** Answered here, straight away, rather than by the model: e.g. a question back to the person */
231
+ reply?: string;
156
232
  }
157
233
 
158
234
  interface QuestionConfig {
@@ -161,6 +237,33 @@ interface QuestionConfig {
161
237
  }
162
238
 
163
239
  const suggestedQuestionsConfig: QuestionConfig[] = [
240
+ // A Studio canvas: the assistant draws on it
241
+ {
242
+ pattern: /\/studio\/[0-9a-f-]{36}/i,
243
+ questions: [
244
+ {
245
+ label: 'Create a new diagram',
246
+ prompt: 'Create a new diagram',
247
+ reply: [
248
+ 'What would you like to diagram? Describe it in a sentence or two, for example:',
249
+ '',
250
+ '- How an order flows from checkout to shipping',
251
+ '- One of our domains and what it talks to',
252
+ "- A new service you're designing, and the events it sends and receives",
253
+ ].join('\n'),
254
+ },
255
+ {
256
+ label: 'Review my architecture, highlight any issues',
257
+ prompt:
258
+ 'Review the architecture on this canvas and highlight any issues: flag what you find with hotspots on the nodes they are about (critical, warning or info), and add sticky notes with your review and suggestions.',
259
+ },
260
+ {
261
+ label: 'How could we improve this architecture?',
262
+ prompt:
263
+ 'How could we improve the architecture on this canvas? Flag the problems and risks you find with hotspots on the nodes they are about, and add sticky notes with your suggested improvements.',
264
+ },
265
+ ],
266
+ },
164
267
  // Message pages (events, commands, queries) - most specific first
165
268
  {
166
269
  pattern: /^\/docs\/(events|commands|queries)\/.+/,
@@ -364,6 +467,9 @@ const suggestedQuestionsConfig: QuestionConfig[] = [
364
467
  },
365
468
  ];
366
469
 
470
+ /** The Studio canvas the page shows, if it's one: the assistant draws on it */
471
+ export const getCanvasId = (pathname: string) => pathname.match(/\/studio\/([0-9a-f-]{36})\/?(?:[?#]|$)/i)?.[1];
472
+
367
473
  // Get suggested questions based on current URL path
368
474
  const getSuggestedQuestions = (pathname: string): SuggestedQuestion[] => {
369
475
  for (const config of suggestedQuestionsConfig) {
@@ -446,23 +552,29 @@ const getFollowUpSuggestions = (message: { parts?: Array<any> }): string[] => {
446
552
  return [];
447
553
  };
448
554
 
449
- // Helper to extract currently running tools from message parts
450
- const getRunningTools = (message: { parts?: Array<any> }): string[] => {
451
- if (!message.parts) return [];
452
-
453
- const runningTools: string[] = [];
454
- for (const part of message.parts) {
455
- // Tool parts have type like "tool-{toolName}" and state indicates progress
456
- if (part.type?.startsWith('tool-') && part.state !== 'output-available') {
457
- // Extract tool name from type (e.g., "tool-getServiceHealth" -> "getServiceHealth")
458
- const toolName = part.type.replace('tool-', '');
459
- // Skip the follow-up suggestions tool as it's internal
460
- if (toolName !== 'suggestFollowUpQuestions') {
461
- runningTools.push(toolName);
462
- }
463
- }
464
- }
465
- return runningTools;
555
+ /** What the assistant's doing, in words, while a tool runs (or is being written, for the canvas's) */
556
+ const TOOL_ACTIVITY: Record<string, string> = {
557
+ getCanvas: 'Reading the canvas…',
558
+ addToCanvas: 'Drawing on the canvas…',
559
+ updateCanvasNode: 'Updating the canvas…',
560
+ connectCanvasNodes: 'Connecting things on the canvas…',
561
+ removeFromCanvas: 'Removing things from the canvas…',
562
+ layoutCanvas: 'Tidying the canvas…',
563
+ walkThrough: 'Preparing the walkthrough…',
564
+ showOnCanvas: 'Showing you on the canvas…',
565
+ addHotspot: 'Flagging hotspots…',
566
+ getResources: 'Searching the catalog…',
567
+ getResource: 'Reading the catalog…',
568
+ };
569
+ export const activityOf = (toolName: string, state?: string) =>
570
+ toolName === 'addToCanvas' && state === 'input-streaming'
571
+ ? 'Planning the diagram…'
572
+ : (TOOL_ACTIVITY[toolName] ?? `Using ${toolName}…`);
573
+
574
+ /** The assistant is between things it writes (thinking, or using a tool), not writing text right now */
575
+ export const isBetweenText = (message: { parts?: Array<any> } | undefined) => {
576
+ const last = message?.parts?.at(-1);
577
+ return !last || last.type !== 'text' || last.state === 'done';
466
578
  };
467
579
 
468
580
  // Helper to extract completed tools from message parts (for showing after completion)
@@ -484,28 +596,218 @@ const getCompletedTools = (message: { parts?: Array<any> }): string[] => {
484
596
  };
485
597
 
486
598
  // Skeleton loading component - memoized since it never changes
487
- const SkeletonLoader = memo(({ label = 'Thinking it through…' }: { label?: string }) => (
488
- <div role="status" className="ec-chat-message flex items-center gap-3 py-3 text-xs text-[rgb(var(--ec-page-text-muted))]">
489
- <span className="flex items-center gap-1" aria-hidden="true">
490
- <span className="ec-chat-dot" />
491
- <span className="ec-chat-dot" />
492
- <span className="ec-chat-dot" />
493
- </span>
494
- {label}
599
+ export type ActivityStep = { label: string; done: boolean; count: number };
600
+
601
+ /**
602
+ * What the assistant has done this turn, step by step (each tool it used, in words, repeats counted), and what it's
603
+ * doing now: the tool it's using, else thinking. Only for a turn that's still going.
604
+ */
605
+ export const getActivitySteps = (message: { role?: string; parts?: Array<any> } | undefined): ActivityStep[] => {
606
+ const steps: ActivityStep[] = [];
607
+ for (const part of message?.role === 'assistant' ? (message.parts ?? []) : []) {
608
+ if (!part.type?.startsWith('tool-')) continue;
609
+ const name = part.type.replace('tool-', '');
610
+ if (name === 'suggestFollowUpQuestions') continue;
611
+ const done = part.state === 'output-available' || part.state === 'output-error';
612
+ const label = activityOf(name, part.state).replace(/…$/, '');
613
+ const last = steps.at(-1);
614
+ if (last && last.label === label) {
615
+ last.count += 1;
616
+ last.done = done;
617
+ } else steps.push({ label, done, count: 1 });
618
+ }
619
+ if (!steps.length || steps.every((step) => step.done)) steps.push({ label: 'Thinking it through', done: false, count: 1 });
620
+ return steps;
621
+ };
622
+
623
+ /** The card the assistant's shown working in: its steps so far, ticked, and what it's on now */
624
+ const ActivityCard = memo(({ steps }: { steps: ActivityStep[] }) => (
625
+ <div role="status" aria-live="polite" className="ec-chat-message ec-chat-activity my-2 px-4 py-3">
626
+ <ol className="relative space-y-1.5 text-xs">
627
+ {steps.map((step, index) => (
628
+ <li key={index} className="flex items-center gap-2.5">
629
+ {step.done ? (
630
+ <Check size={12} className="flex-none text-[rgb(var(--ec-accent))]" aria-hidden="true" />
631
+ ) : (
632
+ <span className="ec-chat-spinner" aria-hidden="true" />
633
+ )}
634
+ <span className={step.done ? 'text-[rgb(var(--ec-page-text-muted))]' : 'ec-chat-activity-now font-medium'}>
635
+ {step.label}
636
+ {step.done ? '' : '…'}
637
+ {step.count > 1 && <span className="ml-1 opacity-60">×{step.count}</span>}
638
+ </span>
639
+ </li>
640
+ ))}
641
+ </ol>
495
642
  </div>
496
643
  ));
497
- SkeletonLoader.displayName = 'SkeletonLoader';
644
+ ActivityCard.displayName = 'ActivityCard';
645
+
646
+ /** It's writing follow-up questions (what it does once it's answered) */
647
+ export const isSuggestingFollowUps = (message: { role?: string; parts?: Array<any> } | undefined) =>
648
+ message?.role === 'assistant' &&
649
+ !!message.parts?.some((part) => part.type === 'tool-suggestFollowUpQuestions' && part.state !== 'output-available');
650
+
651
+ /** Where the follow-up questions will be, while it writes them: pills like theirs, coming in */
652
+ const FollowUpsLoading = memo(() => (
653
+ <div role="status" aria-live="polite" className="ec-chat-message mt-1 flex flex-col items-start gap-2">
654
+ <span className="ec-chat-activity-now text-xs font-medium">Suggesting what to ask next…</span>
655
+ {['72%', '58%', '66%'].map((width, index) => (
656
+ <span
657
+ key={width}
658
+ aria-hidden="true"
659
+ className="ec-chat-pending-pill block"
660
+ style={{ width, animationDelay: `${index * 90}ms, ${index * 150}ms` }}
661
+ />
662
+ ))}
663
+ </div>
664
+ ));
665
+ FollowUpsLoading.displayName = 'FollowUpsLoading';
498
666
 
499
667
  // Memoized markdown components to prevent re-renders
668
+ /** Takes the person to nodes on the Studio canvas they have open */
669
+ const focusCanvas = (nodeIds: string[]) =>
670
+ window.dispatchEvent(
671
+ new CustomEvent(CANVAS_FOCUS_EVENT, { detail: { canvasId: getCanvasId(window.location.pathname), nodeIds } })
672
+ );
673
+
674
+ /** Takes the person to a hotspot on the Studio canvas they have open, and opens it */
675
+ const showHotspot = (hotspotId: string) =>
676
+ window.dispatchEvent(
677
+ new CustomEvent(CANVAS_HOTSPOT_EVENT, { detail: { canvasId: getCanvasId(window.location.pathname), hotspotId } })
678
+ );
679
+
680
+ /** How long the pointer rests on a walkthrough step before it takes them there (so moving across the steps doesn't) */
681
+ const FOCUS_ON_HOVER_MS = 250;
682
+
683
+ /** Something on the canvas, from the chat: clicking it, or resting the pointer on it, takes them there */
684
+ function CanvasLink({ go, className, children }: { go: () => void; className: string; children: React.ReactNode }) {
685
+ const hover = useRef<ReturnType<typeof setTimeout> | null>(null);
686
+ const cancel = () => {
687
+ if (hover.current !== null) clearTimeout(hover.current);
688
+ hover.current = null;
689
+ };
690
+ useEffect(() => cancel, []);
691
+ return (
692
+ <button
693
+ type="button"
694
+ onClick={() => {
695
+ cancel();
696
+ go();
697
+ }}
698
+ onMouseEnter={() => {
699
+ cancel();
700
+ hover.current = setTimeout(go, FOCUS_ON_HOVER_MS);
701
+ }}
702
+ onMouseLeave={cancel}
703
+ title="Show me on the canvas"
704
+ className={`cursor-pointer transition-colors ${className}`}
705
+ >
706
+ {children}
707
+ </button>
708
+ );
709
+ }
710
+
711
+ /** A step of a walkthrough on a canvas: takes them to its nodes */
712
+ const FocusLink = ({ nodeIds, children }: { nodeIds: string[]; children: React.ReactNode }) => (
713
+ <CanvasLink
714
+ go={() => focusCanvas(nodeIds)}
715
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-[rgb(var(--ec-accent))] bg-[rgb(var(--ec-accent)/0.08)] hover:bg-[rgb(var(--ec-accent)/0.16)]"
716
+ >
717
+ {children}
718
+ <LocateFixed size={12} className="flex-none opacity-70" aria-hidden="true" />
719
+ </CanvasLink>
720
+ );
721
+
722
+ /** Something on the canvas the assistant mentions: drawn like the docs' resource references (its type's icon and colour) */
723
+ const CanvasRef = ({ type, nodeId, children }: { type: string; nodeId: string; children: React.ReactNode }) => {
724
+ const rgb = type ? tailwind500RgbByColor[getResourceReferenceColorName(type)] : undefined;
725
+ const style = {
726
+ '--ec-resource-ref-bg': rgb ? `rgb(${rgb} / 0.12)` : 'rgb(var(--ec-accent) / 0.12)',
727
+ '--ec-resource-ref-color': rgb ? `rgb(${rgb})` : 'rgb(var(--ec-accent))',
728
+ } as React.CSSProperties;
729
+ return (
730
+ <span style={style}>
731
+ <CanvasLink
732
+ go={() => focusCanvas([nodeId])}
733
+ className="inline-flex items-center gap-1 px-1 -mx-0.5 rounded font-medium underline decoration-dotted decoration-2 underline-offset-[3px] text-[var(--ec-resource-ref-color)] decoration-[var(--ec-resource-ref-color)] hover:bg-[var(--ec-resource-ref-bg)]"
734
+ >
735
+ <svg
736
+ className="w-3.5 h-3.5 flex-shrink-0"
737
+ fill="none"
738
+ viewBox="0 0 24 24"
739
+ stroke="currentColor"
740
+ strokeWidth={1.75}
741
+ aria-hidden="true"
742
+ dangerouslySetInnerHTML={{ __html: getResourceReferenceIcon(type) }}
743
+ />
744
+ <span>{children}</span>
745
+ </CanvasLink>
746
+ </span>
747
+ );
748
+ };
749
+
750
+ /** Hotspots' severities, as Studio shows them */
751
+ const HOTSPOT_SEVERITY = {
752
+ critical: { label: 'Critical', color: '#dc2626', icon: OctagonAlert },
753
+ warning: { label: 'Warning', color: '#d97706', icon: TriangleAlert },
754
+ info: { label: 'Info', color: '#2563eb', icon: Info },
755
+ } as const;
756
+ export type FlaggedHotspot = { id: string; severity: keyof typeof HOTSPOT_SEVERITY; title: string };
757
+
758
+ /** The hotspots the assistant flagged on the canvas in a message */
759
+ export const getFlaggedHotspots = (message: { parts?: Array<any> }): FlaggedHotspot[] =>
760
+ (message.parts ?? []).flatMap((part) =>
761
+ part.type === 'tool-addHotspot' &&
762
+ part.state === 'output-available' &&
763
+ typeof part.output?.hotspotId === 'string' &&
764
+ part.input?.severity in HOTSPOT_SEVERITY
765
+ ? [{ id: part.output.hotspotId, severity: part.input.severity, title: String(part.input.title ?? '') }]
766
+ : []
767
+ );
768
+
769
+ /** The hotspots it flagged, listed under what it says: each takes them to it on the canvas, opened */
770
+ const FlaggedHotspots = memo(({ hotspots }: { hotspots: FlaggedHotspot[] }) => (
771
+ <div className="mt-3 w-full overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))]">
772
+ <div className="px-3 py-2 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-accent)/0.03)]">
773
+ Hotspots flagged on the canvas ({hotspots.length})
774
+ </div>
775
+ <ul>
776
+ {hotspots.map((hotspot) => {
777
+ const { label, color, icon: Icon } = HOTSPOT_SEVERITY[hotspot.severity];
778
+ return (
779
+ <li key={hotspot.id} className="border-t border-[rgb(var(--ec-page-border))]">
780
+ <CanvasLink
781
+ go={() => showHotspot(hotspot.id)}
782
+ className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent)/0.06)]"
783
+ >
784
+ <Icon size={14} className="flex-none" style={{ color }} aria-label={label} />
785
+ <span className="min-w-0 flex-1">{hotspot.title}</span>
786
+ <LocateFixed size={12} className="flex-none text-[rgb(var(--ec-icon-color))]" aria-hidden="true" />
787
+ </CanvasLink>
788
+ </li>
789
+ );
790
+ })}
791
+ </ul>
792
+ </div>
793
+ ));
794
+ FlaggedHotspots.displayName = 'FlaggedHotspots';
795
+
500
796
  const markdownComponents = {
501
- a: ({ ...props }: any) => (
502
- <a
503
- {...props}
504
- target="_blank"
505
- rel="noopener noreferrer"
506
- className="text-[rgb(var(--ec-accent))] hover:text-[rgb(var(--ec-accent-hover))] underline"
507
- />
508
- ),
797
+ a: ({ node: _node, ...props }: any) => {
798
+ const nodeIds = nodesInFocusLink(props.href);
799
+ if (nodeIds) return <FocusLink nodeIds={nodeIds}>{props.children}</FocusLink>;
800
+ const ref = canvasRefIn(props.href);
801
+ if (ref) return <CanvasRef {...ref}>{props.children}</CanvasRef>;
802
+ return (
803
+ <a
804
+ {...props}
805
+ target="_blank"
806
+ rel="noopener noreferrer"
807
+ className="text-[rgb(var(--ec-accent))] hover:text-[rgb(var(--ec-accent-hover))] underline"
808
+ />
809
+ );
810
+ },
509
811
  code: ({ children, className, ...props }: any) => {
510
812
  const isInline = !className;
511
813
  const match = /language-(\w+)/.exec(className || '');
@@ -541,6 +843,104 @@ const markdownComponents = {
541
843
  };
542
844
 
543
845
  // Modal version with slightly different code styling
846
+ export type WalkthroughStep = { title: string; description: string; nodeIds: string[] };
847
+ export type Walkthrough = { id: string; title: string; steps: WalkthroughStep[] };
848
+
849
+ /** The walkthroughs the assistant gave in a message (walkThrough), as the canvas had them */
850
+ export const getWalkthroughs = (message: { parts?: Array<any> }): Walkthrough[] =>
851
+ (message.parts ?? []).flatMap((part) =>
852
+ part.type === 'tool-walkThrough' && part.state === 'output-available' && Array.isArray(part.output?.steps)
853
+ ? [{ id: String(part.toolCallId), title: String(part.output.title ?? ''), steps: part.output.steps as WalkthroughStep[] }]
854
+ : []
855
+ );
856
+
857
+ /**
858
+ * A walkthrough, step by step: each step takes them to its nodes on the canvas (clicked, or the pointer resting on it),
859
+ * and Previous and Next go through them in turn
860
+ */
861
+ const WalkthroughSteps = memo(({ walkthrough }: { walkthrough: Walkthrough }) => {
862
+ const [current, setCurrent] = useState<number | null>(null);
863
+ const goTo = useCallback(
864
+ (index: number) => {
865
+ const step = walkthrough.steps[index];
866
+ if (!step) return;
867
+ setCurrent(index);
868
+ if (step.nodeIds.length) focusCanvas(step.nodeIds);
869
+ },
870
+ [walkthrough]
871
+ );
872
+ const count = walkthrough.steps.length;
873
+ return (
874
+ <div className="mt-3 w-full overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))]">
875
+ <div className="flex items-center gap-2 px-3 py-2 bg-[rgb(var(--ec-accent)/0.03)] border-b border-[rgb(var(--ec-page-border))]">
876
+ <span className="min-w-0 flex-1 truncate text-xs font-semibold text-[rgb(var(--ec-page-text))]">{walkthrough.title}</span>
877
+ <span className="flex-none text-[11px] text-[rgb(var(--ec-page-text-muted))]">
878
+ {current === null ? `${count} step${count === 1 ? '' : 's'}` : `Step ${current + 1} of ${count}`}
879
+ </span>
880
+ <button
881
+ type="button"
882
+ aria-label="Previous step"
883
+ disabled={current === null || current === 0}
884
+ onClick={() => goTo((current ?? 0) - 1)}
885
+ className="flex-none cursor-pointer rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-accent)/0.1)] disabled:cursor-default disabled:opacity-30"
886
+ >
887
+ <ChevronLeft size={14} />
888
+ </button>
889
+ <button
890
+ type="button"
891
+ aria-label="Next step"
892
+ disabled={current === count - 1}
893
+ onClick={() => goTo(current === null ? 0 : current + 1)}
894
+ className="flex-none cursor-pointer rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-accent)/0.1)] disabled:cursor-default disabled:opacity-30"
895
+ >
896
+ <ChevronRight size={14} />
897
+ </button>
898
+ </div>
899
+ <ol className="py-1">
900
+ {walkthrough.steps.map((step, index) => {
901
+ const active = index === current;
902
+ return (
903
+ <li key={index} className="relative">
904
+ {/* The line down to the next step */}
905
+ {index < count - 1 && (
906
+ <span aria-hidden="true" className="absolute left-[23px] top-8 bottom-0 w-px bg-[rgb(var(--ec-accent)/0.2)]" />
907
+ )}
908
+ <CanvasLink
909
+ go={() => goTo(index)}
910
+ className={`flex w-full items-start gap-3 px-3 py-2 text-left ${
911
+ active ? 'bg-[rgb(var(--ec-accent)/0.08)]' : 'hover:bg-[rgb(var(--ec-accent)/0.05)]'
912
+ }`}
913
+ >
914
+ <span
915
+ className={`relative z-10 flex h-[22px] w-[22px] flex-none items-center justify-center rounded-full text-[11px] font-semibold ${
916
+ active
917
+ ? 'bg-[rgb(var(--ec-accent))] text-white'
918
+ : 'bg-[rgb(var(--ec-card-bg))] text-[rgb(var(--ec-accent))] ring-1 ring-inset ring-[rgb(var(--ec-accent)/0.35)]'
919
+ }`}
920
+ >
921
+ {index + 1}
922
+ </span>
923
+ <span className="min-w-0 flex-1">
924
+ <span className="flex items-center gap-1.5 text-xs font-medium text-[rgb(var(--ec-page-text))]">
925
+ {step.title}
926
+ {step.nodeIds.length > 0 && (
927
+ <LocateFixed size={11} className="flex-none text-[rgb(var(--ec-accent))] opacity-70" aria-hidden="true" />
928
+ )}
929
+ </span>
930
+ <span className="mt-0.5 block text-xs leading-relaxed text-[rgb(var(--ec-page-text-muted))]">
931
+ {step.description}
932
+ </span>
933
+ </span>
934
+ </CanvasLink>
935
+ </li>
936
+ );
937
+ })}
938
+ </ol>
939
+ </div>
940
+ );
941
+ });
942
+ WalkthroughSteps.displayName = 'WalkthroughSteps';
943
+
544
944
  const modalMarkdownComponents = {
545
945
  ...markdownComponents,
546
946
  code: ({ children, className, ...props }: any) => {
@@ -631,6 +1031,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
631
1031
 
632
1032
  // Memoize suggested questions to avoid recalculating on every render
633
1033
  const suggestedQuestions = useMemo(() => getSuggestedQuestions(pathname), [pathname]);
1034
+ const canvasId = useMemo(() => getCanvasId(pathname), [pathname]);
634
1035
 
635
1036
  // Memoize page context to avoid recalculating on every render
636
1037
  const pageContext = useMemo(() => {
@@ -699,9 +1100,31 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
699
1100
  isWaitingForResponse ||
700
1101
  (messages.length > 0 && (status === 'submitted' || status === 'streaming') && !assistantHasContent);
701
1102
  const isLoading = isThinking || isStreaming;
1103
+ // Working, and not writing text right now (e.g. drawing on a canvas after saying what it'll do): still shown working
1104
+ const isWorking = isThinking || (isStreaming && isBetweenText(lastAssistantMessage));
1105
+
1106
+ // What it's done and is doing in the turn going on (the last message, once the assistant's started it)
1107
+ const lastMessage = messages.at(-1);
1108
+ const activitySteps = useMemo(() => getActivitySteps(lastMessage), [lastMessage]);
1109
+ const suggestingFollowUps = useMemo(() => isSuggestingFollowUps(lastMessage), [lastMessage]);
702
1110
 
703
- // Get currently running tools from the last assistant message
704
- const runningTools = useMemo(() => (lastAssistantMessage ? getRunningTools(lastAssistantMessage) : []), [lastAssistantMessage]);
1111
+ // The assistant showing them something on the canvas (showOnCanvas, a walkthrough's start): taken there, once
1112
+ const shownOnCanvas = useRef(new Set<string>());
1113
+ useEffect(() => {
1114
+ for (const part of (lastAssistantMessage?.parts ?? []) as Array<any>) {
1115
+ if (part.state !== 'output-available' || shownOnCanvas.current.has(part.toolCallId)) continue;
1116
+ // What it showed them, or a walkthrough's first step
1117
+ const nodeIds: string[] | undefined =
1118
+ part.type === 'tool-showOnCanvas'
1119
+ ? part.output?.nodeIds
1120
+ : part.type === 'tool-walkThrough'
1121
+ ? part.output?.steps?.[0]?.nodeIds
1122
+ : undefined;
1123
+ if (!nodeIds?.length) continue;
1124
+ shownOnCanvas.current.add(part.toolCallId);
1125
+ focusCanvas(nodeIds);
1126
+ }
1127
+ }, [lastAssistantMessage]);
705
1128
 
706
1129
  // Jump the messages (in the panel and the fullscreen modal) to the latest. Instantly: a smooth scroll restarted on
707
1130
  // every streamed token lands short of the growing answer and scrolls back and forth.
@@ -829,9 +1252,11 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
829
1252
  return;
830
1253
  }
831
1254
  setIsWaitingForResponse(true);
832
- sendMessage({ text });
1255
+ // The canvas follows the assistant while it draws
1256
+ if (canvasId) window.dispatchEvent(new CustomEvent(ASSISTANT_DRAWING_EVENT, { detail: { canvasId } }));
1257
+ sendMessage({ text }, canvasId ? { body: { canvasId } } : undefined);
833
1258
  },
834
- [configured, isLoading, sendMessage, setMessages]
1259
+ [configured, isLoading, sendMessage, setMessages, canvasId]
835
1260
  );
836
1261
 
837
1262
  const stopResponse = useCallback(() => {
@@ -857,6 +1282,30 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
857
1282
  [submitMessage]
858
1283
  );
859
1284
 
1285
+ // A suggested question: asked, or answered here straight away (no need to wait on the model to ask something back)
1286
+ const handleSuggestedQuestion = useCallback(
1287
+ ({ prompt, reply }: SuggestedQuestion) => {
1288
+ if (!reply || isLoading) return handleSuggestedAction(prompt);
1289
+ followLatest.current = true;
1290
+ setMessages((previous) => [
1291
+ ...previous,
1292
+ { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: prompt }] },
1293
+ { id: crypto.randomUUID(), role: 'assistant', parts: [{ type: 'text', text: reply }] },
1294
+ ]);
1295
+ (isFullscreen ? modalInputRef : inputRef).current?.focus();
1296
+ },
1297
+ [handleSuggestedAction, isLoading, isFullscreen, setMessages]
1298
+ );
1299
+
1300
+ // The box grows with what's typed in it (up to its max-height, then scrolls), and shrinks back once it's sent
1301
+ useEffect(() => {
1302
+ for (const input of [inputRef.current, modalInputRef.current]) {
1303
+ if (!input) continue;
1304
+ input.style.height = 'auto';
1305
+ input.style.height = `${input.scrollHeight}px`;
1306
+ }
1307
+ }, [inputValue, isFullscreen, isOpen]);
1308
+
860
1309
  // Handle input enter key
861
1310
  const handleInputKeyDown = useCallback(
862
1311
  (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -1004,7 +1453,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1004
1453
  </div>
1005
1454
  <h2 className="text-lg font-medium text-[rgb(var(--ec-page-text))] mb-1">{greeting}</h2>
1006
1455
  <p className="text-sm font-normal text-[rgb(var(--ec-content-text))]">
1007
- I'm here to help with your architecture
1456
+ {canvasId
1457
+ ? "Tell me what to diagram and I'll draw it on the canvas"
1458
+ : "I'm here to help with your architecture"}
1008
1459
  </p>
1009
1460
  </div>
1010
1461
 
@@ -1013,7 +1464,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1013
1464
  {suggestedQuestions.map((question, index) => (
1014
1465
  <button
1015
1466
  key={index}
1016
- onClick={() => handleSuggestedAction(question.prompt)}
1467
+ onClick={() => handleSuggestedQuestion(question)}
1017
1468
  className="ec-chat-suggestion text-left px-3 py-2 text-xs text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-accent)/0.04)] hover:bg-[rgb(var(--ec-accent)/0.09)] rounded-full transition-all duration-200"
1018
1469
  style={isOpen ? fadeInStyles.getQuestionStyle(index) : undefined}
1019
1470
  >
@@ -1029,6 +1480,8 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1029
1480
  const content = getMessageContent(message);
1030
1481
  const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : [];
1031
1482
  const completedTools = message.role === 'assistant' ? getCompletedTools(message) : [];
1483
+ const flaggedHotspots = message.role === 'assistant' ? getFlaggedHotspots(message) : [];
1484
+ const walkthroughs = message.role === 'assistant' ? getWalkthroughs(message) : [];
1032
1485
  const isLastMessage = messageIndex === messages.length - 1;
1033
1486
  return (
1034
1487
  <div
@@ -1067,6 +1520,10 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1067
1520
  </div>
1068
1521
  </div>
1069
1522
  {/* Follow-up suggestions - only show for last assistant message when not loading */}
1523
+ {walkthroughs.map((walkthrough) => (
1524
+ <WalkthroughSteps key={walkthrough.id} walkthrough={walkthrough} />
1525
+ ))}
1526
+ {flaggedHotspots.length > 0 && <FlaggedHotspots hotspots={flaggedHotspots} />}
1070
1527
  {isLastMessage && followUpSuggestions.length > 0 && !isLoading && (
1071
1528
  <div className="flex flex-wrap gap-2 mt-3 w-full">
1072
1529
  {followUpSuggestions.map((suggestion, index) => (
@@ -1088,9 +1545,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1088
1545
  })}
1089
1546
 
1090
1547
  {/* Skeleton loading indicator */}
1091
- {isThinking && (
1548
+ {isWorking && (
1092
1549
  <div className="w-full">
1093
- <SkeletonLoader label={runningTools.length > 0 ? `Using ${runningTools[0]}…` : undefined} />
1550
+ {suggestingFollowUps ? <FollowUpsLoading /> : <ActivityCard steps={activitySteps} />}
1094
1551
  </div>
1095
1552
  )}
1096
1553
 
@@ -1136,40 +1593,42 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1136
1593
  value={inputValue}
1137
1594
  onChange={(e) => setInputValue(e.target.value)}
1138
1595
  onKeyDown={handleInputKeyDown}
1139
- placeholder="Ask, search, or explain..."
1596
+ placeholder={canvasId ? 'Describe what to diagram...' : 'Ask, search, or explain...'}
1140
1597
  disabled={isLoading}
1141
- className="w-full px-4 py-3 pr-16 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-hidden text-sm disabled:opacity-50 rounded-xl"
1598
+ className="w-full px-4 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-hidden text-sm disabled:opacity-50 rounded-xl"
1142
1599
  />
1143
- <div className="absolute bottom-4 left-4 right-20 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
1144
- <span className="mr-1.5 rounded bg-[rgb(var(--ec-accent)/0.08)] px-1 py-0.5 font-medium text-[rgb(var(--ec-accent))]">
1145
- AI
1146
- </span>
1147
- {configured
1148
- ? pageContext
1149
- ? `Based on ${pageContext.name}`
1150
- : 'Your architecture, connected'
1151
- : 'Explore your catalog with AI'}
1152
- </div>
1153
- <div className="ec-chat-composer-actions absolute right-2 z-10">
1154
- {isStreaming || isSetupThinking ? (
1155
- <button
1156
- type="button"
1157
- onClick={stopResponse}
1158
- className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
1159
- aria-label="Stop generating"
1160
- >
1161
- <Square size={14} fill="currentColor" />
1162
- </button>
1163
- ) : (
1164
- <button
1165
- type="submit"
1166
- disabled={!inputValue.trim() || isLoading}
1167
- className="px-3 py-1.5 bg-[rgb(var(--ec-accent))] text-white text-xs font-medium rounded-lg hover:bg-[rgb(var(--ec-accent-hover))] disabled:bg-transparent disabled:text-[rgb(var(--ec-icon-color))] transition-colors"
1168
- aria-label="Send message"
1169
- >
1170
- Send
1171
- </button>
1172
- )}
1600
+ <div className="ec-chat-composer-footer">
1601
+ <div className="min-w-0 flex-1 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
1602
+ <span className="mr-1.5 rounded bg-[rgb(var(--ec-accent)/0.08)] px-1 py-0.5 font-medium text-[rgb(var(--ec-accent))]">
1603
+ AI
1604
+ </span>
1605
+ {configured
1606
+ ? pageContext
1607
+ ? `Based on ${pageContext.name}`
1608
+ : 'Your architecture, connected'
1609
+ : 'Explore your catalog with AI'}
1610
+ </div>
1611
+ <div className="flex-none">
1612
+ {isStreaming || isSetupThinking ? (
1613
+ <button
1614
+ type="button"
1615
+ onClick={stopResponse}
1616
+ className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
1617
+ aria-label="Stop generating"
1618
+ >
1619
+ <Square size={14} fill="currentColor" />
1620
+ </button>
1621
+ ) : (
1622
+ <button
1623
+ type="submit"
1624
+ disabled={!inputValue.trim() || isLoading}
1625
+ className="px-3 py-1.5 bg-[rgb(var(--ec-accent))] text-white text-xs font-medium rounded-lg hover:bg-[rgb(var(--ec-accent-hover))] disabled:bg-transparent disabled:text-[rgb(var(--ec-icon-color))] transition-colors"
1626
+ aria-label="Send message"
1627
+ >
1628
+ Send
1629
+ </button>
1630
+ )}
1631
+ </div>
1173
1632
  </div>
1174
1633
  </div>
1175
1634
  </form>
@@ -1312,7 +1771,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1312
1771
  </div>
1313
1772
  <h2 className="text-2xl font-medium text-[rgb(var(--ec-page-text))] mb-2">{greeting}</h2>
1314
1773
  <p className="font-normal text-[rgb(var(--ec-content-text))] text-center">
1315
- I'm here to help with your architecture
1774
+ {canvasId
1775
+ ? "Tell me what to diagram and I'll draw it on the canvas"
1776
+ : "I'm here to help with your architecture"}
1316
1777
  </p>
1317
1778
  </div>
1318
1779
 
@@ -1321,7 +1782,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1321
1782
  {suggestedQuestions.map((question, index) => (
1322
1783
  <button
1323
1784
  key={index}
1324
- onClick={() => handleSuggestedAction(question.prompt)}
1785
+ onClick={() => handleSuggestedQuestion(question)}
1325
1786
  className="ec-chat-suggestion px-3 py-2 text-xs text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-accent)/0.04)] hover:bg-[rgb(var(--ec-accent)/0.09)] rounded-full transition-all duration-200 text-left"
1326
1787
  >
1327
1788
  {question.label}
@@ -1336,6 +1797,8 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1336
1797
  const content = getMessageContent(message);
1337
1798
  const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : [];
1338
1799
  const completedTools = message.role === 'assistant' ? getCompletedTools(message) : [];
1800
+ const flaggedHotspots = message.role === 'assistant' ? getFlaggedHotspots(message) : [];
1801
+ const walkthroughs = message.role === 'assistant' ? getWalkthroughs(message) : [];
1339
1802
  const isLastMessage = messageIndex === messages.length - 1;
1340
1803
  return (
1341
1804
  <div
@@ -1374,6 +1837,10 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1374
1837
  </div>
1375
1838
  </div>
1376
1839
  {/* Follow-up suggestions - only show for last assistant message when not loading */}
1840
+ {walkthroughs.map((walkthrough) => (
1841
+ <WalkthroughSteps key={walkthrough.id} walkthrough={walkthrough} />
1842
+ ))}
1843
+ {flaggedHotspots.length > 0 && <FlaggedHotspots hotspots={flaggedHotspots} />}
1377
1844
  {isLastMessage && followUpSuggestions.length > 0 && !isLoading && (
1378
1845
  <div className="flex flex-wrap gap-2 mt-3 w-full">
1379
1846
  {followUpSuggestions.map((suggestion, index) => (
@@ -1394,9 +1861,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1394
1861
  );
1395
1862
  })}
1396
1863
 
1397
- {isThinking && (
1864
+ {isWorking && (
1398
1865
  <div className="w-full max-w-md">
1399
- <SkeletonLoader label={runningTools.length > 0 ? `Using ${runningTools[0]}…` : undefined} />
1866
+ {suggestingFollowUps ? <FollowUpsLoading /> : <ActivityCard steps={activitySteps} />}
1400
1867
  </div>
1401
1868
  )}
1402
1869
 
@@ -1431,40 +1898,42 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1431
1898
  value={inputValue}
1432
1899
  onChange={(e) => setInputValue(e.target.value)}
1433
1900
  onKeyDown={handleInputKeyDown}
1434
- placeholder="Ask, search, or explain..."
1901
+ placeholder={canvasId ? 'Describe what to diagram...' : 'Ask, search, or explain...'}
1435
1902
  disabled={isLoading}
1436
- className="w-full px-4 py-3.5 pr-20 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-hidden text-sm disabled:opacity-50 rounded-xl"
1903
+ className="w-full px-4 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-hidden text-sm disabled:opacity-50 rounded-xl"
1437
1904
  />
1438
- <div className="absolute bottom-4 left-4 right-20 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
1439
- <span className="mr-1.5 rounded bg-[rgb(var(--ec-accent)/0.08)] px-1 py-0.5 font-medium text-[rgb(var(--ec-accent))]">
1440
- AI
1441
- </span>
1442
- {configured
1443
- ? pageContext
1444
- ? `Based on ${pageContext.name}`
1445
- : 'Your architecture, connected'
1446
- : 'Explore your catalog with AI'}
1447
- </div>
1448
- <div className="ec-chat-composer-actions absolute right-2 z-10">
1449
- {isStreaming || isSetupThinking ? (
1450
- <button
1451
- type="button"
1452
- onClick={stopResponse}
1453
- className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
1454
- aria-label="Stop generating"
1455
- >
1456
- <Square size={14} fill="currentColor" />
1457
- </button>
1458
- ) : (
1459
- <button
1460
- type="submit"
1461
- disabled={!inputValue.trim() || isLoading}
1462
- className="px-4 py-2 bg-[rgb(var(--ec-accent))] text-white text-sm font-medium rounded-lg hover:bg-[rgb(var(--ec-accent-hover))] disabled:bg-transparent disabled:text-[rgb(var(--ec-icon-color))] disabled:cursor-not-allowed transition-colors"
1463
- aria-label="Send message"
1464
- >
1465
- Send
1466
- </button>
1467
- )}
1905
+ <div className="ec-chat-composer-footer">
1906
+ <div className="min-w-0 flex-1 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
1907
+ <span className="mr-1.5 rounded bg-[rgb(var(--ec-accent)/0.08)] px-1 py-0.5 font-medium text-[rgb(var(--ec-accent))]">
1908
+ AI
1909
+ </span>
1910
+ {configured
1911
+ ? pageContext
1912
+ ? `Based on ${pageContext.name}`
1913
+ : 'Your architecture, connected'
1914
+ : 'Explore your catalog with AI'}
1915
+ </div>
1916
+ <div className="flex-none">
1917
+ {isStreaming || isSetupThinking ? (
1918
+ <button
1919
+ type="button"
1920
+ onClick={stopResponse}
1921
+ className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
1922
+ aria-label="Stop generating"
1923
+ >
1924
+ <Square size={14} fill="currentColor" />
1925
+ </button>
1926
+ ) : (
1927
+ <button
1928
+ type="submit"
1929
+ disabled={!inputValue.trim() || isLoading}
1930
+ className="px-4 py-2 bg-[rgb(var(--ec-accent))] text-white text-sm font-medium rounded-lg hover:bg-[rgb(var(--ec-accent-hover))] disabled:bg-transparent disabled:text-[rgb(var(--ec-icon-color))] disabled:cursor-not-allowed transition-colors"
1931
+ aria-label="Send message"
1932
+ >
1933
+ Send
1934
+ </button>
1935
+ )}
1936
+ </div>
1468
1937
  </div>
1469
1938
  </div>
1470
1939
  </form>