@eventcatalog/core 5.7.0 → 5.7.2

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 (38) 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-FMWQ2YYN.js → chunk-B3FUBDJN.js} +1 -1
  6. package/dist/{chunk-4CV7VC4K.js → chunk-EPI5BHEU.js} +1 -1
  7. package/dist/{chunk-MOPKQMQP.js → chunk-NNTWQNQ5.js} +1 -1
  8. package/dist/{chunk-BE5PTWNC.js → chunk-OR4VBY3F.js} +1 -1
  9. package/dist/{chunk-22KZ4TJF.js → chunk-TYZENGGJ.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 +702 -128
  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/ChatPanel/panel-width.ts +28 -0
  22. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
  23. package/eventcatalog/src/features/ai/chat-api.ts +24 -5
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +72 -71
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
  28. package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
  29. package/eventcatalog/src/features/studio/catalog.ts +5 -3
  30. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
  31. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +116 -13
  32. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  33. package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
  34. package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
  35. package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
  36. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -15
  37. package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
  38. package/package.json +4 -4
@@ -1,5 +1,23 @@
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';
1
+ import { useEffect, useRef, useCallback, useState, useMemo, memo, type RefObject } from '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,17 @@ 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 { clampPanelWidth, MAX_PANEL_WIDTH, MIN_PANEL_WIDTH, PANEL_WIDTH, readPanelWidth, savePanelWidth } from './panel-width';
32
+ import {
33
+ ASSISTANT_DRAWING_EVENT,
34
+ CANVAS_FOCUS_EVENT,
35
+ CANVAS_HOTSPOT_EVENT,
36
+ canvasRefIn,
37
+ nodesInFocusLink,
38
+ } from './assistant-events';
39
+ import { getResourceReferenceColorName } from '@utils/resource-reference-colors';
40
+ import { tailwind500RgbByColor } from '@utils/collection-colors';
41
+ import { getResourceReferenceIcon } from '@utils/resource-reference-icons';
13
42
 
14
43
  interface ToolMetadata {
15
44
  name: string;
@@ -23,10 +52,6 @@ const CHAT_PANEL_STYLES = `
23
52
  0%, 100% { transform: scale(1); opacity: .7; }
24
53
  50% { transform: scale(1.06); opacity: 1; }
25
54
  }
26
- @keyframes ec-chat-dot {
27
- 0%, 80%, 100% { transform: translateY(0); opacity: .35; }
28
- 40% { transform: translateY(-3px); opacity: 1; }
29
- }
30
55
  .ec-chat-surface { font-weight: 400; }
31
56
  .ec-chat-avatar { background: rgb(var(--ec-accent) / .06); animation: ec-chat-breathe 5s ease-in-out infinite; }
32
57
  /* The closed panel stays on the page (off screen): a running animation there still costs every frame */
@@ -38,9 +63,58 @@ const CHAT_PANEL_STYLES = `
38
63
  .ec-chat-offline > :nth-child(2) { animation-delay: 100ms; }
39
64
  .ec-chat-offline > :nth-child(3) { animation-delay: 220ms; }
40
65
  .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; }
66
+ /* What the assistant's doing while it works: a card with a sheen passing over it, its steps so far ticked and the
67
+ one it's on spinning, its words shimmering */
68
+ @keyframes ec-chat-sheen {
69
+ from { background-position: 200% 0; }
70
+ to { background-position: -200% 0; }
71
+ }
72
+ @keyframes ec-chat-spin { to { transform: rotate(360deg); } }
73
+ .ec-chat-activity {
74
+ position: relative;
75
+ overflow: hidden;
76
+ border: 1px solid rgb(var(--ec-accent) / .18);
77
+ border-radius: 14px;
78
+ background: rgb(var(--ec-accent) / .03);
79
+ box-shadow: 0 6px 20px rgb(var(--ec-accent) / .05);
80
+ }
81
+ .ec-chat-activity::before {
82
+ content: '';
83
+ position: absolute;
84
+ inset: 0;
85
+ pointer-events: none;
86
+ background: linear-gradient(110deg, transparent 30%, rgb(var(--ec-accent) / .09) 50%, transparent 70%);
87
+ background-size: 200% 100%;
88
+ animation: ec-chat-sheen 2.2s linear infinite;
89
+ }
90
+ .ec-chat-activity-now {
91
+ background: linear-gradient(90deg, rgb(var(--ec-page-text)) 0%, rgb(var(--ec-accent)) 50%, rgb(var(--ec-page-text)) 100%);
92
+ background-size: 200% 100%;
93
+ -webkit-background-clip: text;
94
+ background-clip: text;
95
+ color: transparent;
96
+ animation: ec-chat-sheen 2.4s linear infinite;
97
+ }
98
+ .ec-chat-spinner {
99
+ width: 12px;
100
+ height: 12px;
101
+ flex: none;
102
+ border-radius: 50%;
103
+ border: 1.5px solid rgb(var(--ec-accent) / .2);
104
+ border-top-color: rgb(var(--ec-accent));
105
+ animation: ec-chat-spin .8s linear infinite;
106
+ }
107
+ /* Follow-up questions on their way: pills like theirs, a sheen passing along them */
108
+ .ec-chat-pending-pill {
109
+ height: 30px;
110
+ border-radius: 999px;
111
+ background: linear-gradient(90deg, rgb(var(--ec-accent) / .05) 25%, rgb(var(--ec-accent) / .14) 50%, rgb(var(--ec-accent) / .05) 75%);
112
+ background-size: 200% 100%;
113
+ animation: fadeInUp 300ms ease-out both, ec-chat-sheen 1.6s linear infinite;
114
+ }
115
+ .ec-chat-surface[aria-hidden='true'] .ec-chat-activity,
116
+ .ec-chat-surface[aria-hidden='true'] .ec-chat-activity *,
117
+ .ec-chat-surface[aria-hidden='true'] .ec-chat-pending-pill { animation-play-state: paused; }
44
118
  .ec-chat-composer {
45
119
  border-radius: 20px;
46
120
  border-color: rgb(var(--ec-accent) / .22);
@@ -49,8 +123,9 @@ const CHAT_PANEL_STYLES = `
49
123
  transition: border-color 200ms, box-shadow 200ms;
50
124
  }
51
125
  .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; }
126
+ /* 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 */
127
+ .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; }
128
+ .ec-chat-composer-footer { display: flex; align-items: center; gap: 8px; padding: 2px 8px 8px 16px; }
54
129
  @media (prefers-reduced-motion: reduce) {
55
130
  .ec-chat-surface, .ec-chat-surface *, #eventcatalog-header, #eventcatalog-application {
56
131
  animation: none !important;
@@ -153,6 +228,8 @@ const getGreeting = () => {
153
228
  interface SuggestedQuestion {
154
229
  label: string;
155
230
  prompt: string;
231
+ /** Answered here, straight away, rather than by the model: e.g. a question back to the person */
232
+ reply?: string;
156
233
  }
157
234
 
158
235
  interface QuestionConfig {
@@ -161,6 +238,33 @@ interface QuestionConfig {
161
238
  }
162
239
 
163
240
  const suggestedQuestionsConfig: QuestionConfig[] = [
241
+ // A Studio canvas: the assistant draws on it
242
+ {
243
+ pattern: /\/studio\/[0-9a-f-]{36}/i,
244
+ questions: [
245
+ {
246
+ label: 'Create a new diagram',
247
+ prompt: 'Create a new diagram',
248
+ reply: [
249
+ 'What would you like to diagram? Describe it in a sentence or two, for example:',
250
+ '',
251
+ '- How an order flows from checkout to shipping',
252
+ '- One of our domains and what it talks to',
253
+ "- A new service you're designing, and the events it sends and receives",
254
+ ].join('\n'),
255
+ },
256
+ {
257
+ label: 'Review my architecture, highlight any issues',
258
+ prompt:
259
+ '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.',
260
+ },
261
+ {
262
+ label: 'How could we improve this architecture?',
263
+ prompt:
264
+ '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.',
265
+ },
266
+ ],
267
+ },
164
268
  // Message pages (events, commands, queries) - most specific first
165
269
  {
166
270
  pattern: /^\/docs\/(events|commands|queries)\/.+/,
@@ -364,6 +468,9 @@ const suggestedQuestionsConfig: QuestionConfig[] = [
364
468
  },
365
469
  ];
366
470
 
471
+ /** The Studio canvas the page shows, if it's one: the assistant draws on it */
472
+ export const getCanvasId = (pathname: string) => pathname.match(/\/studio\/([0-9a-f-]{36})\/?(?:[?#]|$)/i)?.[1];
473
+
367
474
  // Get suggested questions based on current URL path
368
475
  const getSuggestedQuestions = (pathname: string): SuggestedQuestion[] => {
369
476
  for (const config of suggestedQuestionsConfig) {
@@ -381,7 +488,15 @@ interface ChatPanelProps {
381
488
  onClose: () => void;
382
489
  }
383
490
 
384
- const PANEL_WIDTH = 400;
491
+ /** The window's width without its scrollbar, which the panel (fixed, on the right) sits inside of (or the window's, where
492
+ * that isn't known) */
493
+ const viewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
494
+
495
+ /** What the panel pushes over to make room for it (looked up each time: ClientRouter navigations swap in new pages) */
496
+ const pageBesidePanel = () =>
497
+ [document.getElementById('eventcatalog-application'), document.getElementById('eventcatalog-header')].filter(
498
+ Boolean
499
+ ) as HTMLElement[];
385
500
 
386
501
  // Staggered fade-in animation styles (delays account for 420ms panel slide)
387
502
  const fadeInStyles = {
@@ -446,23 +561,29 @@ const getFollowUpSuggestions = (message: { parts?: Array<any> }): string[] => {
446
561
  return [];
447
562
  };
448
563
 
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;
564
+ /** What the assistant's doing, in words, while a tool runs (or is being written, for the canvas's) */
565
+ const TOOL_ACTIVITY: Record<string, string> = {
566
+ getCanvas: 'Reading the canvas…',
567
+ addToCanvas: 'Drawing on the canvas…',
568
+ updateCanvasNode: 'Updating the canvas…',
569
+ connectCanvasNodes: 'Connecting things on the canvas…',
570
+ removeFromCanvas: 'Removing things from the canvas…',
571
+ layoutCanvas: 'Tidying the canvas…',
572
+ walkThrough: 'Preparing the walkthrough…',
573
+ showOnCanvas: 'Showing you on the canvas…',
574
+ addHotspot: 'Flagging hotspots…',
575
+ getResources: 'Searching the catalog…',
576
+ getResource: 'Reading the catalog…',
577
+ };
578
+ export const activityOf = (toolName: string, state?: string) =>
579
+ toolName === 'addToCanvas' && state === 'input-streaming'
580
+ ? 'Planning the diagram…'
581
+ : (TOOL_ACTIVITY[toolName] ?? `Using ${toolName}…`);
582
+
583
+ /** The assistant is between things it writes (thinking, or using a tool), not writing text right now */
584
+ export const isBetweenText = (message: { parts?: Array<any> } | undefined) => {
585
+ const last = message?.parts?.at(-1);
586
+ return !last || last.type !== 'text' || last.state === 'done';
466
587
  };
467
588
 
468
589
  // Helper to extract completed tools from message parts (for showing after completion)
@@ -484,28 +605,296 @@ const getCompletedTools = (message: { parts?: Array<any> }): string[] => {
484
605
  };
485
606
 
486
607
  // 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}
608
+ export type ActivityStep = { label: string; done: boolean; count: number };
609
+
610
+ /**
611
+ * What the assistant has done this turn, step by step (each tool it used, in words, repeats counted), and what it's
612
+ * doing now: the tool it's using, else thinking. Only for a turn that's still going.
613
+ */
614
+ export const getActivitySteps = (message: { role?: string; parts?: Array<any> } | undefined): ActivityStep[] => {
615
+ const steps: ActivityStep[] = [];
616
+ for (const part of message?.role === 'assistant' ? (message.parts ?? []) : []) {
617
+ if (!part.type?.startsWith('tool-')) continue;
618
+ const name = part.type.replace('tool-', '');
619
+ if (name === 'suggestFollowUpQuestions') continue;
620
+ const done = part.state === 'output-available' || part.state === 'output-error';
621
+ const label = activityOf(name, part.state).replace(/…$/, '');
622
+ const last = steps.at(-1);
623
+ if (last && last.label === label) {
624
+ last.count += 1;
625
+ last.done = done;
626
+ } else steps.push({ label, done, count: 1 });
627
+ }
628
+ if (!steps.length || steps.every((step) => step.done)) steps.push({ label: 'Thinking it through', done: false, count: 1 });
629
+ return steps;
630
+ };
631
+
632
+ /** The card the assistant's shown working in: its steps so far, ticked, and what it's on now */
633
+ const ActivityCard = memo(({ steps }: { steps: ActivityStep[] }) => (
634
+ <div role="status" aria-live="polite" className="ec-chat-message ec-chat-activity my-2 px-4 py-3">
635
+ <ol className="relative space-y-1.5 text-xs">
636
+ {steps.map((step, index) => (
637
+ <li key={index} className="flex items-center gap-2.5">
638
+ {step.done ? (
639
+ <Check size={12} className="flex-none text-[rgb(var(--ec-accent))]" aria-hidden="true" />
640
+ ) : (
641
+ <span className="ec-chat-spinner" aria-hidden="true" />
642
+ )}
643
+ <span className={step.done ? 'text-[rgb(var(--ec-page-text-muted))]' : 'ec-chat-activity-now font-medium'}>
644
+ {step.label}
645
+ {step.done ? '' : '…'}
646
+ {step.count > 1 && <span className="ml-1 opacity-60">×{step.count}</span>}
647
+ </span>
648
+ </li>
649
+ ))}
650
+ </ol>
651
+ </div>
652
+ ));
653
+ ActivityCard.displayName = 'ActivityCard';
654
+
655
+ /** It's writing follow-up questions (what it does once it's answered) */
656
+ export const isSuggestingFollowUps = (message: { role?: string; parts?: Array<any> } | undefined) =>
657
+ message?.role === 'assistant' &&
658
+ !!message.parts?.some((part) => part.type === 'tool-suggestFollowUpQuestions' && part.state !== 'output-available');
659
+
660
+ /** Where the follow-up questions will be, while it writes them: pills like theirs, coming in */
661
+ const FollowUpsLoading = memo(() => (
662
+ <div role="status" aria-live="polite" className="ec-chat-message mt-1 flex flex-col items-start gap-2">
663
+ <span className="ec-chat-activity-now text-xs font-medium">Suggesting what to ask next…</span>
664
+ {['72%', '58%', '66%'].map((width, index) => (
665
+ <span
666
+ key={width}
667
+ aria-hidden="true"
668
+ className="ec-chat-pending-pill block"
669
+ style={{ width, animationDelay: `${index * 90}ms, ${index * 150}ms` }}
670
+ />
671
+ ))}
495
672
  </div>
496
673
  ));
497
- SkeletonLoader.displayName = 'SkeletonLoader';
674
+ FollowUpsLoading.displayName = 'FollowUpsLoading';
498
675
 
499
676
  // Memoized markdown components to prevent re-renders
677
+ /** Takes the person to nodes on the Studio canvas they have open */
678
+ const focusCanvas = (nodeIds: string[]) =>
679
+ window.dispatchEvent(
680
+ new CustomEvent(CANVAS_FOCUS_EVENT, { detail: { canvasId: getCanvasId(window.location.pathname), nodeIds } })
681
+ );
682
+
683
+ /** Takes the person to a hotspot on the Studio canvas they have open, and opens it */
684
+ const showHotspot = (hotspotId: string) =>
685
+ window.dispatchEvent(
686
+ new CustomEvent(CANVAS_HOTSPOT_EVENT, { detail: { canvasId: getCanvasId(window.location.pathname), hotspotId } })
687
+ );
688
+
689
+ /** How long the pointer rests on a walkthrough step before it takes them there (so moving across the steps doesn't) */
690
+ const FOCUS_ON_HOVER_MS = 250;
691
+
692
+ /** Something on the canvas, from the chat: clicking it, or resting the pointer on it, takes them there */
693
+ function CanvasLink({ go, className, children }: { go: () => void; className: string; children: React.ReactNode }) {
694
+ const hover = useRef<ReturnType<typeof setTimeout> | null>(null);
695
+ const cancel = () => {
696
+ if (hover.current !== null) clearTimeout(hover.current);
697
+ hover.current = null;
698
+ };
699
+ useEffect(() => cancel, []);
700
+ return (
701
+ <button
702
+ type="button"
703
+ onClick={() => {
704
+ cancel();
705
+ go();
706
+ }}
707
+ onMouseEnter={() => {
708
+ cancel();
709
+ hover.current = setTimeout(go, FOCUS_ON_HOVER_MS);
710
+ }}
711
+ onMouseLeave={cancel}
712
+ title="Show me on the canvas"
713
+ className={`cursor-pointer transition-colors ${className}`}
714
+ >
715
+ {children}
716
+ </button>
717
+ );
718
+ }
719
+
720
+ /** A step of a walkthrough on a canvas: takes them to its nodes */
721
+ const FocusLink = ({ nodeIds, children }: { nodeIds: string[]; children: React.ReactNode }) => (
722
+ <CanvasLink
723
+ go={() => focusCanvas(nodeIds)}
724
+ 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)]"
725
+ >
726
+ {children}
727
+ <LocateFixed size={12} className="flex-none opacity-70" aria-hidden="true" />
728
+ </CanvasLink>
729
+ );
730
+
731
+ /** Something on the canvas the assistant mentions: drawn like the docs' resource references (its type's icon and colour) */
732
+ const CanvasRef = ({ type, nodeId, children }: { type: string; nodeId: string; children: React.ReactNode }) => {
733
+ const rgb = type ? tailwind500RgbByColor[getResourceReferenceColorName(type)] : undefined;
734
+ const style = {
735
+ '--ec-resource-ref-bg': rgb ? `rgb(${rgb} / 0.12)` : 'rgb(var(--ec-accent) / 0.12)',
736
+ '--ec-resource-ref-color': rgb ? `rgb(${rgb})` : 'rgb(var(--ec-accent))',
737
+ } as React.CSSProperties;
738
+ return (
739
+ <span style={style}>
740
+ <CanvasLink
741
+ go={() => focusCanvas([nodeId])}
742
+ 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)]"
743
+ >
744
+ <svg
745
+ className="w-3.5 h-3.5 flex-shrink-0"
746
+ fill="none"
747
+ viewBox="0 0 24 24"
748
+ stroke="currentColor"
749
+ strokeWidth={1.75}
750
+ aria-hidden="true"
751
+ dangerouslySetInnerHTML={{ __html: getResourceReferenceIcon(type) }}
752
+ />
753
+ <span>{children}</span>
754
+ </CanvasLink>
755
+ </span>
756
+ );
757
+ };
758
+
759
+ /** Hotspots' severities, as Studio shows them */
760
+ const HOTSPOT_SEVERITY = {
761
+ critical: { label: 'Critical', color: '#dc2626', icon: OctagonAlert },
762
+ warning: { label: 'Warning', color: '#d97706', icon: TriangleAlert },
763
+ info: { label: 'Info', color: '#2563eb', icon: Info },
764
+ } as const;
765
+ export type FlaggedHotspot = { id: string; severity: keyof typeof HOTSPOT_SEVERITY; title: string };
766
+
767
+ /** The hotspots the assistant flagged on the canvas in a message */
768
+ export const getFlaggedHotspots = (message: { parts?: Array<any> }): FlaggedHotspot[] =>
769
+ (message.parts ?? []).flatMap((part) =>
770
+ part.type === 'tool-addHotspot' &&
771
+ part.state === 'output-available' &&
772
+ typeof part.output?.hotspotId === 'string' &&
773
+ part.input?.severity in HOTSPOT_SEVERITY
774
+ ? [{ id: part.output.hotspotId, severity: part.input.severity, title: String(part.input.title ?? '') }]
775
+ : []
776
+ );
777
+
778
+ /** The hotspots it flagged, listed under what it says: each takes them to it on the canvas, opened */
779
+ const FlaggedHotspots = memo(({ hotspots }: { hotspots: FlaggedHotspot[] }) => (
780
+ <div className="mt-3 w-full overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))]">
781
+ <div className="px-3 py-2 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-accent)/0.03)]">
782
+ Hotspots flagged on the canvas ({hotspots.length})
783
+ </div>
784
+ <ul>
785
+ {hotspots.map((hotspot) => {
786
+ const { label, color, icon: Icon } = HOTSPOT_SEVERITY[hotspot.severity];
787
+ return (
788
+ <li key={hotspot.id} className="border-t border-[rgb(var(--ec-page-border))]">
789
+ <CanvasLink
790
+ go={() => showHotspot(hotspot.id)}
791
+ 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)]"
792
+ >
793
+ <Icon size={14} className="flex-none" style={{ color }} aria-label={label} />
794
+ <span className="min-w-0 flex-1">{hotspot.title}</span>
795
+ <LocateFixed size={12} className="flex-none text-[rgb(var(--ec-icon-color))]" aria-hidden="true" />
796
+ </CanvasLink>
797
+ </li>
798
+ );
799
+ })}
800
+ </ul>
801
+ </div>
802
+ ));
803
+ FlaggedHotspots.displayName = 'FlaggedHotspots';
804
+
805
+ /** The panel's left edge: dragged to make it wider or narrower (or with the arrow keys), double-clicked back to the default */
806
+ const PanelResizeHandle = memo(
807
+ ({ panel, width, onResize }: { panel: RefObject<HTMLDivElement | null>; width: number; onResize: (width: number) => void }) => {
808
+ // Pressed, and whether it's moved since (a click, or each click of a double click, changes nothing)
809
+ const dragging = useRef<{ moved: boolean } | null>(null);
810
+ const [isDragging, setIsDragging] = useState(false);
811
+ const widthAt = (clientX: number) => clampPanelWidth(viewportWidth() - clientX, viewportWidth());
812
+ // While dragging, the panel and the page beside it are sized here, outside React, so the messages don't re-render
813
+ // on every move (and the page follows the pointer, without its slide)
814
+ const show = (next: number) => {
815
+ if (panel.current) panel.current.style.width = `min(${next}px, 100vw)`;
816
+ pageBesidePanel().forEach((el) => (el.style.paddingRight = `${next}px`));
817
+ };
818
+ const stop = (next: number) => {
819
+ const moved = dragging.current?.moved;
820
+ dragging.current = null;
821
+ setIsDragging(false);
822
+ show(moved ? next : width);
823
+ pageBesidePanel().forEach((el) => (el.style.transition = ''));
824
+ if (moved) onResize(next);
825
+ };
826
+ return (
827
+ <div
828
+ role="separator"
829
+ aria-orientation="vertical"
830
+ aria-label="Resize the assistant"
831
+ aria-valuemin={MIN_PANEL_WIDTH}
832
+ aria-valuemax={MAX_PANEL_WIDTH}
833
+ aria-valuenow={width}
834
+ tabIndex={0}
835
+ title="Drag to resize"
836
+ onPointerDown={(event) => {
837
+ if (event.button !== 0) return;
838
+ event.preventDefault();
839
+ // Keeps pointer events coming while the pointer is over the page (throws if the pointer has gone)
840
+ try {
841
+ event.currentTarget.setPointerCapture(event.pointerId);
842
+ } catch {}
843
+ dragging.current = { moved: false };
844
+ setIsDragging(true);
845
+ pageBesidePanel().forEach((el) => (el.style.transition = 'none'));
846
+ }}
847
+ onPointerMove={(event) => {
848
+ if (!dragging.current) return;
849
+ dragging.current.moved = true;
850
+ show(widthAt(event.clientX));
851
+ }}
852
+ onPointerUp={(event) => dragging.current && stop(widthAt(event.clientX))}
853
+ onPointerCancel={() => {
854
+ if (!dragging.current) return;
855
+ // Put back as it was
856
+ dragging.current.moved = false;
857
+ stop(width);
858
+ }}
859
+ onDoubleClick={() => onResize(PANEL_WIDTH)}
860
+ onKeyDown={(event) => {
861
+ const step = event.shiftKey ? 64 : 16;
862
+ if (event.key === 'ArrowLeft') onResize(clampPanelWidth(width + step, viewportWidth()));
863
+ else if (event.key === 'ArrowRight') onResize(clampPanelWidth(width - step, viewportWidth()));
864
+ else return;
865
+ event.preventDefault();
866
+ }}
867
+ className="group absolute inset-y-0 left-0 z-10 hidden w-1.5 cursor-col-resize outline-hidden lg:block"
868
+ >
869
+ <span
870
+ aria-hidden="true"
871
+ className={`absolute inset-y-0 left-0 w-0.5 transition-colors ${
872
+ isDragging
873
+ ? 'bg-[rgb(var(--ec-accent))]'
874
+ : 'group-hover:bg-[rgb(var(--ec-accent)/0.6)] group-focus-visible:bg-[rgb(var(--ec-accent))]'
875
+ }`}
876
+ />
877
+ </div>
878
+ );
879
+ }
880
+ );
881
+ PanelResizeHandle.displayName = 'PanelResizeHandle';
882
+
500
883
  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
- ),
884
+ a: ({ node: _node, ...props }: any) => {
885
+ const nodeIds = nodesInFocusLink(props.href);
886
+ if (nodeIds) return <FocusLink nodeIds={nodeIds}>{props.children}</FocusLink>;
887
+ const ref = canvasRefIn(props.href);
888
+ if (ref) return <CanvasRef {...ref}>{props.children}</CanvasRef>;
889
+ return (
890
+ <a
891
+ {...props}
892
+ target="_blank"
893
+ rel="noopener noreferrer"
894
+ className="text-[rgb(var(--ec-accent))] hover:text-[rgb(var(--ec-accent-hover))] underline"
895
+ />
896
+ );
897
+ },
509
898
  code: ({ children, className, ...props }: any) => {
510
899
  const isInline = !className;
511
900
  const match = /language-(\w+)/.exec(className || '');
@@ -541,6 +930,104 @@ const markdownComponents = {
541
930
  };
542
931
 
543
932
  // Modal version with slightly different code styling
933
+ export type WalkthroughStep = { title: string; description: string; nodeIds: string[] };
934
+ export type Walkthrough = { id: string; title: string; steps: WalkthroughStep[] };
935
+
936
+ /** The walkthroughs the assistant gave in a message (walkThrough), as the canvas had them */
937
+ export const getWalkthroughs = (message: { parts?: Array<any> }): Walkthrough[] =>
938
+ (message.parts ?? []).flatMap((part) =>
939
+ part.type === 'tool-walkThrough' && part.state === 'output-available' && Array.isArray(part.output?.steps)
940
+ ? [{ id: String(part.toolCallId), title: String(part.output.title ?? ''), steps: part.output.steps as WalkthroughStep[] }]
941
+ : []
942
+ );
943
+
944
+ /**
945
+ * A walkthrough, step by step: each step takes them to its nodes on the canvas (clicked, or the pointer resting on it),
946
+ * and Previous and Next go through them in turn
947
+ */
948
+ const WalkthroughSteps = memo(({ walkthrough }: { walkthrough: Walkthrough }) => {
949
+ const [current, setCurrent] = useState<number | null>(null);
950
+ const goTo = useCallback(
951
+ (index: number) => {
952
+ const step = walkthrough.steps[index];
953
+ if (!step) return;
954
+ setCurrent(index);
955
+ if (step.nodeIds.length) focusCanvas(step.nodeIds);
956
+ },
957
+ [walkthrough]
958
+ );
959
+ const count = walkthrough.steps.length;
960
+ return (
961
+ <div className="mt-3 w-full overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))]">
962
+ <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))]">
963
+ <span className="min-w-0 flex-1 truncate text-xs font-semibold text-[rgb(var(--ec-page-text))]">{walkthrough.title}</span>
964
+ <span className="flex-none text-[11px] text-[rgb(var(--ec-page-text-muted))]">
965
+ {current === null ? `${count} step${count === 1 ? '' : 's'}` : `Step ${current + 1} of ${count}`}
966
+ </span>
967
+ <button
968
+ type="button"
969
+ aria-label="Previous step"
970
+ disabled={current === null || current === 0}
971
+ onClick={() => goTo((current ?? 0) - 1)}
972
+ 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"
973
+ >
974
+ <ChevronLeft size={14} />
975
+ </button>
976
+ <button
977
+ type="button"
978
+ aria-label="Next step"
979
+ disabled={current === count - 1}
980
+ onClick={() => goTo(current === null ? 0 : current + 1)}
981
+ 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"
982
+ >
983
+ <ChevronRight size={14} />
984
+ </button>
985
+ </div>
986
+ <ol className="py-1">
987
+ {walkthrough.steps.map((step, index) => {
988
+ const active = index === current;
989
+ return (
990
+ <li key={index} className="relative">
991
+ {/* The line down to the next step */}
992
+ {index < count - 1 && (
993
+ <span aria-hidden="true" className="absolute left-[23px] top-8 bottom-0 w-px bg-[rgb(var(--ec-accent)/0.2)]" />
994
+ )}
995
+ <CanvasLink
996
+ go={() => goTo(index)}
997
+ className={`flex w-full items-start gap-3 px-3 py-2 text-left ${
998
+ active ? 'bg-[rgb(var(--ec-accent)/0.08)]' : 'hover:bg-[rgb(var(--ec-accent)/0.05)]'
999
+ }`}
1000
+ >
1001
+ <span
1002
+ className={`relative z-10 flex h-[22px] w-[22px] flex-none items-center justify-center rounded-full text-[11px] font-semibold ${
1003
+ active
1004
+ ? 'bg-[rgb(var(--ec-accent))] text-white'
1005
+ : 'bg-[rgb(var(--ec-card-bg))] text-[rgb(var(--ec-accent))] ring-1 ring-inset ring-[rgb(var(--ec-accent)/0.35)]'
1006
+ }`}
1007
+ >
1008
+ {index + 1}
1009
+ </span>
1010
+ <span className="min-w-0 flex-1">
1011
+ <span className="flex items-center gap-1.5 text-xs font-medium text-[rgb(var(--ec-page-text))]">
1012
+ {step.title}
1013
+ {step.nodeIds.length > 0 && (
1014
+ <LocateFixed size={11} className="flex-none text-[rgb(var(--ec-accent))] opacity-70" aria-hidden="true" />
1015
+ )}
1016
+ </span>
1017
+ <span className="mt-0.5 block text-xs leading-relaxed text-[rgb(var(--ec-page-text-muted))]">
1018
+ {step.description}
1019
+ </span>
1020
+ </span>
1021
+ </CanvasLink>
1022
+ </li>
1023
+ );
1024
+ })}
1025
+ </ol>
1026
+ </div>
1027
+ );
1028
+ });
1029
+ WalkthroughSteps.displayName = 'WalkthroughSteps';
1030
+
544
1031
  const modalMarkdownComponents = {
545
1032
  ...markdownComponents,
546
1033
  code: ({ children, className, ...props }: any) => {
@@ -589,6 +1076,24 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
589
1076
  const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
590
1077
  const [pathname, setPathname] = useState('');
591
1078
  const [isFullscreen, setIsFullscreen] = useState(false);
1079
+ // How wide they made the panel: the default until the width they left it at is read, after hydrating (within the
1080
+ // panel's own limits: the window's are applied as it's shown, so it's back to that width when the window's wide again)
1081
+ const panelRef = useRef<HTMLDivElement | null>(null);
1082
+ const [panelWidth, setPanelWidth] = useState(PANEL_WIDTH);
1083
+ useEffect(() => setPanelWidth(readPanelWidth(Infinity)), []);
1084
+ // How wide it's shown: that, leaving room for the page in this window (checked again as the window's resized; it only
1085
+ // re-renders when this changes)
1086
+ const [shownWidth, setShownWidth] = useState(PANEL_WIDTH);
1087
+ useEffect(() => {
1088
+ const fit = () => setShownWidth(clampPanelWidth(panelWidth, viewportWidth()));
1089
+ fit();
1090
+ window.addEventListener('resize', fit);
1091
+ return () => window.removeEventListener('resize', fit);
1092
+ }, [panelWidth]);
1093
+ const resizePanel = useCallback((width: number) => {
1094
+ setPanelWidth(width);
1095
+ savePanelWidth(width);
1096
+ }, []);
592
1097
  const [tools, setTools] = useState<ToolMetadata[]>([]);
593
1098
  const [showScrollButton, setShowScrollButton] = useState(false);
594
1099
  const chatApiUrl = useMemo(() => buildUrl('/api/chat', true), []);
@@ -631,6 +1136,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
631
1136
 
632
1137
  // Memoize suggested questions to avoid recalculating on every render
633
1138
  const suggestedQuestions = useMemo(() => getSuggestedQuestions(pathname), [pathname]);
1139
+ const canvasId = useMemo(() => getCanvasId(pathname), [pathname]);
634
1140
 
635
1141
  // Memoize page context to avoid recalculating on every render
636
1142
  const pageContext = useMemo(() => {
@@ -699,9 +1205,31 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
699
1205
  isWaitingForResponse ||
700
1206
  (messages.length > 0 && (status === 'submitted' || status === 'streaming') && !assistantHasContent);
701
1207
  const isLoading = isThinking || isStreaming;
1208
+ // Working, and not writing text right now (e.g. drawing on a canvas after saying what it'll do): still shown working
1209
+ const isWorking = isThinking || (isStreaming && isBetweenText(lastAssistantMessage));
1210
+
1211
+ // What it's done and is doing in the turn going on (the last message, once the assistant's started it)
1212
+ const lastMessage = messages.at(-1);
1213
+ const activitySteps = useMemo(() => getActivitySteps(lastMessage), [lastMessage]);
1214
+ const suggestingFollowUps = useMemo(() => isSuggestingFollowUps(lastMessage), [lastMessage]);
702
1215
 
703
- // Get currently running tools from the last assistant message
704
- const runningTools = useMemo(() => (lastAssistantMessage ? getRunningTools(lastAssistantMessage) : []), [lastAssistantMessage]);
1216
+ // The assistant showing them something on the canvas (showOnCanvas, a walkthrough's start): taken there, once
1217
+ const shownOnCanvas = useRef(new Set<string>());
1218
+ useEffect(() => {
1219
+ for (const part of (lastAssistantMessage?.parts ?? []) as Array<any>) {
1220
+ if (part.state !== 'output-available' || shownOnCanvas.current.has(part.toolCallId)) continue;
1221
+ // What it showed them, or a walkthrough's first step
1222
+ const nodeIds: string[] | undefined =
1223
+ part.type === 'tool-showOnCanvas'
1224
+ ? part.output?.nodeIds
1225
+ : part.type === 'tool-walkThrough'
1226
+ ? part.output?.steps?.[0]?.nodeIds
1227
+ : undefined;
1228
+ if (!nodeIds?.length) continue;
1229
+ shownOnCanvas.current.add(part.toolCallId);
1230
+ focusCanvas(nodeIds);
1231
+ }
1232
+ }, [lastAssistantMessage]);
705
1233
 
706
1234
  // Jump the messages (in the panel and the fullscreen modal) to the latest. Instantly: a smooth scroll restarted on
707
1235
  // every streamed token lands short of the growing answer and scrolls back and forth.
@@ -763,9 +1291,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
763
1291
  // Looked up each time: the panel stays open across ClientRouter navigations (the header persists), but each one
764
1292
  // swaps in a new page that needs pushing over too
765
1293
  const getElements = () => ({
766
- elements: [document.getElementById('eventcatalog-application'), document.getElementById('eventcatalog-header')].filter(
767
- Boolean
768
- ) as HTMLElement[],
1294
+ elements: pageBesidePanel(),
769
1295
  docsSidebarEl: document.getElementById('eventcatalog-docs-sidebar'),
770
1296
  });
771
1297
 
@@ -777,7 +1303,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
777
1303
  if (!el.style.transition) {
778
1304
  el.style.transition = 'padding-right 420ms cubic-bezier(0.16, 1, 0.3, 1)';
779
1305
  }
780
- el.style.paddingRight = pushContent ? `${PANEL_WIDTH}px` : '0';
1306
+ el.style.paddingRight = pushContent ? `${shownWidth}px` : '0';
781
1307
  });
782
1308
  if (docsSidebarEl) docsSidebarEl.style.display = pushContent ? 'none' : '';
783
1309
  };
@@ -798,7 +1324,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
798
1324
  docsSidebarEl.style.display = '';
799
1325
  }
800
1326
  };
801
- }, [isOpen, isFullscreen]);
1327
+ }, [isOpen, isFullscreen, shownWidth]);
802
1328
 
803
1329
  // Submit message handler
804
1330
  const submitMessage = useCallback(
@@ -829,9 +1355,11 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
829
1355
  return;
830
1356
  }
831
1357
  setIsWaitingForResponse(true);
832
- sendMessage({ text });
1358
+ // The canvas follows the assistant while it draws
1359
+ if (canvasId) window.dispatchEvent(new CustomEvent(ASSISTANT_DRAWING_EVENT, { detail: { canvasId } }));
1360
+ sendMessage({ text }, canvasId ? { body: { canvasId } } : undefined);
833
1361
  },
834
- [configured, isLoading, sendMessage, setMessages]
1362
+ [configured, isLoading, sendMessage, setMessages, canvasId]
835
1363
  );
836
1364
 
837
1365
  const stopResponse = useCallback(() => {
@@ -857,6 +1385,30 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
857
1385
  [submitMessage]
858
1386
  );
859
1387
 
1388
+ // A suggested question: asked, or answered here straight away (no need to wait on the model to ask something back)
1389
+ const handleSuggestedQuestion = useCallback(
1390
+ ({ prompt, reply }: SuggestedQuestion) => {
1391
+ if (!reply || isLoading) return handleSuggestedAction(prompt);
1392
+ followLatest.current = true;
1393
+ setMessages((previous) => [
1394
+ ...previous,
1395
+ { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: prompt }] },
1396
+ { id: crypto.randomUUID(), role: 'assistant', parts: [{ type: 'text', text: reply }] },
1397
+ ]);
1398
+ (isFullscreen ? modalInputRef : inputRef).current?.focus();
1399
+ },
1400
+ [handleSuggestedAction, isLoading, isFullscreen, setMessages]
1401
+ );
1402
+
1403
+ // The box grows with what's typed in it (up to its max-height, then scrolls), and shrinks back once it's sent
1404
+ useEffect(() => {
1405
+ for (const input of [inputRef.current, modalInputRef.current]) {
1406
+ if (!input) continue;
1407
+ input.style.height = 'auto';
1408
+ input.style.height = `${input.scrollHeight}px`;
1409
+ }
1410
+ }, [inputValue, isFullscreen, isOpen]);
1411
+
860
1412
  // Handle input enter key
861
1413
  const handleInputKeyDown = useCallback(
862
1414
  (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -893,12 +1445,13 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
893
1445
  {!isFullscreen && (
894
1446
  <div
895
1447
  ref={(element) => {
1448
+ panelRef.current = element;
896
1449
  element?.toggleAttribute('inert', !isOpen);
897
1450
  }}
898
1451
  aria-hidden={!isOpen}
899
1452
  className="ec-chat-surface fixed top-0 right-0 h-[100dvh] z-[200] border-l border-[rgb(var(--ec-page-border))] flex flex-col overflow-hidden"
900
1453
  style={{
901
- width: `min(${PANEL_WIDTH}px, 100vw)`,
1454
+ width: `min(${shownWidth}px, 100vw)`,
902
1455
  transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
903
1456
  transition: 'transform 420ms cubic-bezier(0.16, 1, 0.3, 1)',
904
1457
  background: `
@@ -907,6 +1460,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
907
1460
  `,
908
1461
  }}
909
1462
  >
1463
+ <PanelResizeHandle panel={panelRef} width={shownWidth} onResize={resizePanel} />
910
1464
  {/* Header */}
911
1465
  <div className="flex-none shrink-0 border-b border-[rgb(var(--ec-page-border))]">
912
1466
  <div className="flex items-center justify-between px-4 py-3">
@@ -1004,7 +1558,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1004
1558
  </div>
1005
1559
  <h2 className="text-lg font-medium text-[rgb(var(--ec-page-text))] mb-1">{greeting}</h2>
1006
1560
  <p className="text-sm font-normal text-[rgb(var(--ec-content-text))]">
1007
- I'm here to help with your architecture
1561
+ {canvasId
1562
+ ? "Tell me what to diagram and I'll draw it on the canvas"
1563
+ : "I'm here to help with your architecture"}
1008
1564
  </p>
1009
1565
  </div>
1010
1566
 
@@ -1013,7 +1569,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1013
1569
  {suggestedQuestions.map((question, index) => (
1014
1570
  <button
1015
1571
  key={index}
1016
- onClick={() => handleSuggestedAction(question.prompt)}
1572
+ onClick={() => handleSuggestedQuestion(question)}
1017
1573
  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
1574
  style={isOpen ? fadeInStyles.getQuestionStyle(index) : undefined}
1019
1575
  >
@@ -1029,6 +1585,8 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1029
1585
  const content = getMessageContent(message);
1030
1586
  const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : [];
1031
1587
  const completedTools = message.role === 'assistant' ? getCompletedTools(message) : [];
1588
+ const flaggedHotspots = message.role === 'assistant' ? getFlaggedHotspots(message) : [];
1589
+ const walkthroughs = message.role === 'assistant' ? getWalkthroughs(message) : [];
1032
1590
  const isLastMessage = messageIndex === messages.length - 1;
1033
1591
  return (
1034
1592
  <div
@@ -1067,6 +1625,10 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1067
1625
  </div>
1068
1626
  </div>
1069
1627
  {/* Follow-up suggestions - only show for last assistant message when not loading */}
1628
+ {walkthroughs.map((walkthrough) => (
1629
+ <WalkthroughSteps key={walkthrough.id} walkthrough={walkthrough} />
1630
+ ))}
1631
+ {flaggedHotspots.length > 0 && <FlaggedHotspots hotspots={flaggedHotspots} />}
1070
1632
  {isLastMessage && followUpSuggestions.length > 0 && !isLoading && (
1071
1633
  <div className="flex flex-wrap gap-2 mt-3 w-full">
1072
1634
  {followUpSuggestions.map((suggestion, index) => (
@@ -1088,9 +1650,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1088
1650
  })}
1089
1651
 
1090
1652
  {/* Skeleton loading indicator */}
1091
- {isThinking && (
1653
+ {isWorking && (
1092
1654
  <div className="w-full">
1093
- <SkeletonLoader label={runningTools.length > 0 ? `Using ${runningTools[0]}…` : undefined} />
1655
+ {suggestingFollowUps ? <FollowUpsLoading /> : <ActivityCard steps={activitySteps} />}
1094
1656
  </div>
1095
1657
  )}
1096
1658
 
@@ -1136,40 +1698,42 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1136
1698
  value={inputValue}
1137
1699
  onChange={(e) => setInputValue(e.target.value)}
1138
1700
  onKeyDown={handleInputKeyDown}
1139
- placeholder="Ask, search, or explain..."
1701
+ placeholder={canvasId ? 'Describe what to diagram...' : 'Ask, search, or explain...'}
1140
1702
  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"
1703
+ 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
1704
  />
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
- )}
1705
+ <div className="ec-chat-composer-footer">
1706
+ <div className="min-w-0 flex-1 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
1707
+ <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))]">
1708
+ AI
1709
+ </span>
1710
+ {configured
1711
+ ? pageContext
1712
+ ? `Based on ${pageContext.name}`
1713
+ : 'Your architecture, connected'
1714
+ : 'Explore your catalog with AI'}
1715
+ </div>
1716
+ <div className="flex-none">
1717
+ {isStreaming || isSetupThinking ? (
1718
+ <button
1719
+ type="button"
1720
+ onClick={stopResponse}
1721
+ className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
1722
+ aria-label="Stop generating"
1723
+ >
1724
+ <Square size={14} fill="currentColor" />
1725
+ </button>
1726
+ ) : (
1727
+ <button
1728
+ type="submit"
1729
+ disabled={!inputValue.trim() || isLoading}
1730
+ 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"
1731
+ aria-label="Send message"
1732
+ >
1733
+ Send
1734
+ </button>
1735
+ )}
1736
+ </div>
1173
1737
  </div>
1174
1738
  </div>
1175
1739
  </form>
@@ -1312,7 +1876,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1312
1876
  </div>
1313
1877
  <h2 className="text-2xl font-medium text-[rgb(var(--ec-page-text))] mb-2">{greeting}</h2>
1314
1878
  <p className="font-normal text-[rgb(var(--ec-content-text))] text-center">
1315
- I'm here to help with your architecture
1879
+ {canvasId
1880
+ ? "Tell me what to diagram and I'll draw it on the canvas"
1881
+ : "I'm here to help with your architecture"}
1316
1882
  </p>
1317
1883
  </div>
1318
1884
 
@@ -1321,7 +1887,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1321
1887
  {suggestedQuestions.map((question, index) => (
1322
1888
  <button
1323
1889
  key={index}
1324
- onClick={() => handleSuggestedAction(question.prompt)}
1890
+ onClick={() => handleSuggestedQuestion(question)}
1325
1891
  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
1892
  >
1327
1893
  {question.label}
@@ -1336,6 +1902,8 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1336
1902
  const content = getMessageContent(message);
1337
1903
  const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : [];
1338
1904
  const completedTools = message.role === 'assistant' ? getCompletedTools(message) : [];
1905
+ const flaggedHotspots = message.role === 'assistant' ? getFlaggedHotspots(message) : [];
1906
+ const walkthroughs = message.role === 'assistant' ? getWalkthroughs(message) : [];
1339
1907
  const isLastMessage = messageIndex === messages.length - 1;
1340
1908
  return (
1341
1909
  <div
@@ -1374,6 +1942,10 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1374
1942
  </div>
1375
1943
  </div>
1376
1944
  {/* Follow-up suggestions - only show for last assistant message when not loading */}
1945
+ {walkthroughs.map((walkthrough) => (
1946
+ <WalkthroughSteps key={walkthrough.id} walkthrough={walkthrough} />
1947
+ ))}
1948
+ {flaggedHotspots.length > 0 && <FlaggedHotspots hotspots={flaggedHotspots} />}
1377
1949
  {isLastMessage && followUpSuggestions.length > 0 && !isLoading && (
1378
1950
  <div className="flex flex-wrap gap-2 mt-3 w-full">
1379
1951
  {followUpSuggestions.map((suggestion, index) => (
@@ -1394,9 +1966,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1394
1966
  );
1395
1967
  })}
1396
1968
 
1397
- {isThinking && (
1969
+ {isWorking && (
1398
1970
  <div className="w-full max-w-md">
1399
- <SkeletonLoader label={runningTools.length > 0 ? `Using ${runningTools[0]}…` : undefined} />
1971
+ {suggestingFollowUps ? <FollowUpsLoading /> : <ActivityCard steps={activitySteps} />}
1400
1972
  </div>
1401
1973
  )}
1402
1974
 
@@ -1431,40 +2003,42 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
1431
2003
  value={inputValue}
1432
2004
  onChange={(e) => setInputValue(e.target.value)}
1433
2005
  onKeyDown={handleInputKeyDown}
1434
- placeholder="Ask, search, or explain..."
2006
+ placeholder={canvasId ? 'Describe what to diagram...' : 'Ask, search, or explain...'}
1435
2007
  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"
2008
+ 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
2009
  />
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
- )}
2010
+ <div className="ec-chat-composer-footer">
2011
+ <div className="min-w-0 flex-1 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
2012
+ <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))]">
2013
+ AI
2014
+ </span>
2015
+ {configured
2016
+ ? pageContext
2017
+ ? `Based on ${pageContext.name}`
2018
+ : 'Your architecture, connected'
2019
+ : 'Explore your catalog with AI'}
2020
+ </div>
2021
+ <div className="flex-none">
2022
+ {isStreaming || isSetupThinking ? (
2023
+ <button
2024
+ type="button"
2025
+ onClick={stopResponse}
2026
+ className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
2027
+ aria-label="Stop generating"
2028
+ >
2029
+ <Square size={14} fill="currentColor" />
2030
+ </button>
2031
+ ) : (
2032
+ <button
2033
+ type="submit"
2034
+ disabled={!inputValue.trim() || isLoading}
2035
+ 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"
2036
+ aria-label="Send message"
2037
+ >
2038
+ Send
2039
+ </button>
2040
+ )}
2041
+ </div>
1468
2042
  </div>
1469
2043
  </div>
1470
2044
  </form>