@antelopejs/dms-ai 0.3.0 → 0.4.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 (67) hide show
  1. package/README.md +67 -206
  2. package/dist/index.js +3 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/lifecycle/skill-sources.d.ts +1 -1
  5. package/dist/lifecycle/skill-sources.js +29 -13
  6. package/dist/lifecycle/skill-sources.js.map +1 -1
  7. package/frontend-vue/app/chat/components/BlockPreviewCard.vue +172 -0
  8. package/frontend-vue/app/chat/components/BlockPreviewModal.vue +324 -0
  9. package/frontend-vue/app/chat/components/BlockPreviewThumb.vue +141 -0
  10. package/frontend-vue/app/chat/components/MessageList.vue +20 -0
  11. package/frontend-vue/app/chat/components/SketchFrame.vue +59 -0
  12. package/frontend-vue/app/chat/composables/conversation-events.ts +23 -1
  13. package/frontend-vue/app/chat/composables/useBlockPreviewComponent.ts +15 -0
  14. package/frontend-vue/app/chat/composables/useDashboardScheme.ts +42 -0
  15. package/frontend-vue/app/chat/constants/block-preview.ts +27 -0
  16. package/frontend-vue/app/chat/constants/conversation.ts +1 -0
  17. package/frontend-vue/app/chat/constants/protocol.ts +1 -0
  18. package/frontend-vue/app/chat/constants/sketch.ts +105 -0
  19. package/frontend-vue/app/chat/types/conversation.ts +11 -1
  20. package/frontend-vue/app/chat/types/protocol.ts +56 -0
  21. package/frontend-vue/app/chat/utils/block-preview.ts +145 -0
  22. package/frontend-vue/app/chat/utils/conversation-messages.ts +23 -1
  23. package/frontend-vue/app/chat/utils/render-items.ts +13 -2
  24. package/frontend-vue/app/chat/utils/sketch.ts +56 -0
  25. package/frontend-vue/app/chat/utils/tool-lexicon.ts +7 -0
  26. package/frontend-vue/app/runtime/session-gate.ts +11 -5
  27. package/frontend-vue/i18n/locales/dms-ai-panel-en-GB.json +23 -1
  28. package/frontend-vue/i18n/locales/dms-ai-panel-fr-FR.json +23 -1
  29. package/frontend-vue/package.json +1 -1
  30. package/frontend-vue/tests/chat/block-preview.test.ts +448 -0
  31. package/frontend-vue/tests/session-gate.test.ts +28 -1
  32. package/package.json +22 -8
  33. package/sidecar/dist/constants/mcp.js +2 -0
  34. package/sidecar/dist/constants/mcp.js.map +1 -1
  35. package/sidecar/dist/constants/preview.d.ts +39 -0
  36. package/sidecar/dist/constants/preview.js +55 -0
  37. package/sidecar/dist/constants/preview.js.map +1 -0
  38. package/sidecar/dist/constants/tool-kinds.js +2 -0
  39. package/sidecar/dist/constants/tool-kinds.js.map +1 -1
  40. package/sidecar/dist/mcp/tool-definitions.js +5 -0
  41. package/sidecar/dist/mcp/tool-definitions.js.map +1 -1
  42. package/sidecar/dist/mcp/tools/preview-blocks.d.ts +65 -0
  43. package/sidecar/dist/mcp/tools/preview-blocks.js +176 -0
  44. package/sidecar/dist/mcp/tools/preview-blocks.js.map +1 -0
  45. package/sidecar/dist/mcp/types.d.ts +3 -0
  46. package/sidecar/dist/prompts/agent.d.ts +1 -1
  47. package/sidecar/dist/prompts/agent.js +3 -2
  48. package/sidecar/dist/prompts/agent.js.map +1 -1
  49. package/sidecar/dist/protocol/events.d.ts +313 -0
  50. package/sidecar/dist/protocol/events.js +9 -0
  51. package/sidecar/dist/protocol/events.js.map +1 -1
  52. package/sidecar/dist/protocol/preview.d.ts +130 -0
  53. package/sidecar/dist/protocol/preview.js +91 -0
  54. package/sidecar/dist/protocol/preview.js.map +1 -0
  55. package/sidecar/dist/server/chat-events.d.ts +3 -0
  56. package/sidecar/dist/server/chat-events.js +21 -0
  57. package/sidecar/dist/server/chat-events.js.map +1 -1
  58. package/sidecar/dist/server/ws.js +3 -1
  59. package/sidecar/dist/server/ws.js.map +1 -1
  60. package/sidecar/dist/state/types.d.ts +3 -1
  61. package/sidecar/dist/state/types.js +1 -0
  62. package/sidecar/dist/state/types.js.map +1 -1
  63. package/sidecar/package.json +1 -1
  64. package/skills/dms-ai/SKILL.md +30 -0
  65. package/skills/dms-ai-code-mode/SKILL.md +51 -0
  66. package/skills/dms-builder-safe/SKILL.md +9 -2
  67. package/skills/dms-builder-page/SKILL.md +0 -80
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { useDashboardScheme } from "../composables/useDashboardScheme";
4
+ import { SKETCH_DEFAULT_HEIGHT_PX } from "../constants/sketch";
5
+ import type { PreviewSketch } from "../types/protocol";
6
+ import {
7
+ buildSketchDocument,
8
+ computedStyleReader,
9
+ sketchTokensCss,
10
+ } from "../utils/sketch";
11
+
12
+ interface Props {
13
+ sketch: PreviewSketch;
14
+ title: string;
15
+ /** Overrides the sketch's own height, e.g. for a thumbnail. */
16
+ height?: number;
17
+ }
18
+
19
+ const props = defineProps<Props>();
20
+ const host = ref<HTMLElement | null>(null);
21
+ const { scheme, revision } = useDashboardScheme();
22
+
23
+ const frameHeight = computed<string>(
24
+ () => `${props.height ?? props.sketch.height ?? SKETCH_DEFAULT_HEIGHT_PX}px`,
25
+ );
26
+
27
+ const srcdoc = computed<string>(() => {
28
+ if (revision.value === 0 || host.value === null) return "";
29
+ const tokens = sketchTokensCss(scheme.value, computedStyleReader(host.value));
30
+ return buildSketchDocument(props.sketch, tokens);
31
+ });
32
+ </script>
33
+
34
+ <template>
35
+ <div ref="host" class="sketch-frame">
36
+ <iframe
37
+ v-if="srcdoc"
38
+ class="sketch-frame__doc"
39
+ sandbox=""
40
+ referrerpolicy="no-referrer"
41
+ :title="title"
42
+ :srcdoc="srcdoc"
43
+ :style="{ height: frameHeight }"
44
+ />
45
+ </div>
46
+ </template>
47
+
48
+ <style scoped>
49
+ .sketch-frame {
50
+ display: block;
51
+ width: 100%;
52
+ }
53
+
54
+ .sketch-frame__doc {
55
+ display: block;
56
+ width: 100%;
57
+ border: 0;
58
+ }
59
+ </style>
@@ -7,6 +7,7 @@ import type {
7
7
  import type {
8
8
  AllowedBy,
9
9
  AttachmentMeta,
10
+ BlockPreview,
10
11
  ChangeSetSummary,
11
12
  ConversationModeState,
12
13
  FullAutoState,
@@ -19,6 +20,7 @@ import type {
19
20
  import type { PendingAttachment } from "../utils/attachments";
20
21
  import {
21
22
  buildAssistantMessage,
23
+ buildBlockPreviewMessage,
22
24
  buildErrorMessage,
23
25
  buildUserMessage,
24
26
  closedStatus,
@@ -91,6 +93,10 @@ interface ChangeSetEvent {
91
93
  changeSet?: ChangeSetSummary;
92
94
  }
93
95
 
96
+ interface BlockPreviewEvent {
97
+ preview?: BlockPreview;
98
+ }
99
+
94
100
  interface UsageEvent {
95
101
  totalTokens?: number;
96
102
  }
@@ -197,7 +203,8 @@ function isTurnStart(msg: ConversationMessage): boolean {
197
203
  function isOutOfTurn(msg: ConversationMessage): boolean {
198
204
  return (
199
205
  msg.role === MESSAGE_ROLES.NOTICE ||
200
- msg.role === MESSAGE_ROLES.QUESTION_ANSWER
206
+ msg.role === MESSAGE_ROLES.QUESTION_ANSWER ||
207
+ msg.role === MESSAGE_ROLES.BLOCK_PREVIEW
201
208
  );
202
209
  }
203
210
 
@@ -357,6 +364,20 @@ function applyChangeSet(state: ConversationState, event: ChangeSetEvent): void {
357
364
  });
358
365
  }
359
366
 
367
+ function appendBlockPreview(
368
+ state: ConversationState,
369
+ event: BlockPreviewEvent,
370
+ ): void {
371
+ const preview = event.preview;
372
+ if (preview === undefined) return;
373
+ const isKnown = state.messages.value.some(
374
+ (msg) =>
375
+ msg.role === MESSAGE_ROLES.BLOCK_PREVIEW && msg.preview.id === preview.id,
376
+ );
377
+ if (isKnown) return;
378
+ appendMessage(state, buildBlockPreviewMessage(preview));
379
+ }
380
+
360
381
  function applyUsage(state: ConversationState, event: UsageEvent): void {
361
382
  if (typeof event.totalTokens !== "number") return;
362
383
  state.totalTokens.value = event.totalTokens;
@@ -377,6 +398,7 @@ const EVENT_HANDLERS: Record<string, EventHandler> = {
377
398
  [SERVER_EVENT_TYPES.NOTICE]: appendNotice,
378
399
  [SERVER_EVENT_TYPES.CHANGE_SET]: applyChangeSet,
379
400
  [SERVER_EVENT_TYPES.USAGE]: applyUsage,
401
+ [SERVER_EVENT_TYPES.BLOCK_PREVIEW]: appendBlockPreview,
380
402
  };
381
403
 
382
404
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -0,0 +1,15 @@
1
+ import { type Component, getCurrentInstance } from "vue";
2
+ import { BLOCK_PREVIEW_COMPONENT_NAMES } from "../constants/block-preview";
3
+
4
+ /**
5
+ * The DMS's `DmsBlockPreview`, looked up among the app's components rather
6
+ * than imported: the chat must keep working on a DMS that predates it, where
7
+ * the preview falls back to the tree as JSON.
8
+ */
9
+ export function useBlockPreviewComponent(): Component | null {
10
+ const components = getCurrentInstance()?.appContext.components ?? {};
11
+ const name = BLOCK_PREVIEW_COMPONENT_NAMES.find(
12
+ (candidate) => components[candidate] !== undefined,
13
+ );
14
+ return name === undefined ? null : components[name];
15
+ }
@@ -0,0 +1,42 @@
1
+ import { onBeforeUnmount, onMounted, type Ref, ref } from "vue";
2
+ import { DARK_THEME_CLASS, type SketchScheme } from "../constants/sketch";
3
+
4
+ const OBSERVED_ATTRIBUTES = ["class", "style", "data-scale"];
5
+
6
+ function currentScheme(): SketchScheme {
7
+ return document.documentElement.classList.contains(DARK_THEME_CLASS)
8
+ ? "dark"
9
+ : "light";
10
+ }
11
+
12
+ export interface DashboardScheme {
13
+ scheme: Ref<SketchScheme>;
14
+ revision: Ref<number>;
15
+ }
16
+
17
+ /**
18
+ * The dashboard's light or dark theme, followed live. `revision` also moves
19
+ * when the root's styles change, so readers of its CSS variables re-read them.
20
+ */
21
+ export function useDashboardScheme(): DashboardScheme {
22
+ const scheme = ref<SketchScheme>("light");
23
+ const revision = ref(0);
24
+ let observer: MutationObserver | null = null;
25
+
26
+ const sync = () => {
27
+ scheme.value = currentScheme();
28
+ revision.value += 1;
29
+ };
30
+
31
+ onMounted(() => {
32
+ sync();
33
+ observer = new MutationObserver(sync);
34
+ observer.observe(document.documentElement, {
35
+ attributes: true,
36
+ attributeFilter: OBSERVED_ATTRIBUTES,
37
+ });
38
+ });
39
+ onBeforeUnmount(() => observer?.disconnect());
40
+
41
+ return { scheme, revision };
42
+ }
@@ -0,0 +1,27 @@
1
+ /** Names the DMS may register its block preview component under. */
2
+ export const BLOCK_PREVIEW_COMPONENT_NAMES = [
3
+ "DmsBlockPreview",
4
+ "dms-block-preview",
5
+ ] as const;
6
+
7
+ export const BLOCK_PREVIEW_MAIN_TAB = "proposed";
8
+ export const BLOCK_PREVIEW_VARIANT_TAB_PREFIX = "variant-";
9
+
10
+ export const JSON_INDENT = 2;
11
+
12
+ /** Mirror of the sidecar's reference URL rules (sidecar/src/constants/preview.ts). */
13
+ export const SAME_ORIGIN_PATH = /^\/(?![/\\])/;
14
+ export const REMOTE_URL = /^https?:\/\//i;
15
+
16
+ /** Width the card's live thumbnail is laid out at before it is scaled down. */
17
+ export const THUMBNAIL_CANVAS_WIDTH_PX = 560;
18
+ export const THUMBNAIL_SCALE = 0.2;
19
+ /** Height a sketch is drawn at in the thumbnail, before the scale. */
20
+ export const THUMBNAIL_SKETCH_HEIGHT_PX = 360;
21
+
22
+ /** Most block types the card names before it counts the rest. */
23
+ export const CARD_TYPE_LIMIT = 2;
24
+
25
+ /** The schematic thumbnail drawn when the DMS cannot render the blocks. */
26
+ export const PLAN_MAX_ROWS = 3;
27
+ export const PLAN_MAX_CELLS = 4;
@@ -6,6 +6,7 @@ export const MESSAGE_ROLES = {
6
6
  NOTICE: "notice",
7
7
  CHANGE_SET: "change_set",
8
8
  QUESTION_ANSWER: "question_answer",
9
+ BLOCK_PREVIEW: "block_preview",
9
10
  } as const;
10
11
 
11
12
  export const STORED_TOOL_USE_ROLE = "tool_use";
@@ -52,6 +52,7 @@ export const SERVER_EVENT_TYPES = {
52
52
  NOTICE: "notice",
53
53
  CHANGE_SET: "change_set",
54
54
  USAGE: "usage",
55
+ BLOCK_PREVIEW: "block_preview",
55
56
  } as const;
56
57
 
57
58
  /** The role the chat says hello with. */
@@ -0,0 +1,105 @@
1
+ /**
2
+ * How an agent's sketch is drawn. The frame is the security boundary: an empty
3
+ * `sandbox` (no scripts, opaque origin, no forms, popups or top navigation)
4
+ * and a CSP that loads nothing but inline styles and `data:` images and fonts.
5
+ */
6
+ export const SKETCH_CSP =
7
+ "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
8
+
9
+ /** Mirror of the sidecar's default (sidecar/src/constants/preview.ts). */
10
+ export const SKETCH_DEFAULT_HEIGHT_PX = 320;
11
+
12
+ export type SketchScheme = "light" | "dark";
13
+
14
+ /** Class the dashboard puts on `<html>` in its dark theme. */
15
+ export const DARK_THEME_CLASS = "dark";
16
+
17
+ /**
18
+ * The `--sketch-*` tokens a sketch's CSS may use, read from the dashboard's
19
+ * own variables (a sandboxed document inherits none of them), with the
20
+ * dashboard's default values when a variable is unset.
21
+ */
22
+ export interface SketchToken {
23
+ name: string;
24
+ source: string;
25
+ light: string;
26
+ dark: string;
27
+ }
28
+
29
+ const SANS_STACK =
30
+ '"Geist", "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif';
31
+ const MONO_STACK = '"Geist Mono", ui-monospace, "SF Mono", Menlo, monospace';
32
+
33
+ export const SKETCH_TOKENS: readonly SketchToken[] = [
34
+ {
35
+ name: "canvas",
36
+ source: "--ui-bg-muted",
37
+ light: "#f4f6f7",
38
+ dark: "#07090b",
39
+ },
40
+ {
41
+ name: "surface",
42
+ source: "--dms-surface-card",
43
+ light: "#ffffff",
44
+ dark: "#10151a",
45
+ },
46
+ {
47
+ name: "surface-muted",
48
+ source: "--dms-bg-muted",
49
+ light: "#f7f9fa",
50
+ dark: "#0d1114",
51
+ },
52
+ { name: "border", source: "--ui-border", light: "#e2e7ea", dark: "#262e36" },
53
+ { name: "text", source: "--ui-text", light: "#2a333b", dark: "#c7d0d8" },
54
+ {
55
+ name: "text-strong",
56
+ source: "--ui-text-highlighted",
57
+ light: "#0b1114",
58
+ dark: "#eef3f6",
59
+ },
60
+ {
61
+ name: "text-muted",
62
+ source: "--ui-text-muted",
63
+ light: "#49525b",
64
+ dark: "#9ea7b0",
65
+ },
66
+ { name: "accent", source: "--ui-primary", light: "#167f8a", dark: "#4dd9e6" },
67
+ {
68
+ name: "accent-fill",
69
+ source: "--dms-accent-fill",
70
+ light: "#4dd9e6",
71
+ dark: "#4dd9e6",
72
+ },
73
+ {
74
+ name: "success",
75
+ source: "--ui-success",
76
+ light: "#15803d",
77
+ dark: "#4ade80",
78
+ },
79
+ { name: "error", source: "--ui-error", light: "#b91c1c", dark: "#f87171" },
80
+ {
81
+ name: "radius",
82
+ source: "--dms-radius-card",
83
+ light: "0.875rem",
84
+ dark: "0.875rem",
85
+ },
86
+ { name: "font", source: "--font-sans", light: SANS_STACK, dark: SANS_STACK },
87
+ {
88
+ name: "font-mono",
89
+ source: "--font-mono",
90
+ light: MONO_STACK,
91
+ dark: MONO_STACK,
92
+ },
93
+ ];
94
+
95
+ /** A dashboard value that could end the style element it is written into. */
96
+ export const UNSAFE_TOKEN_VALUE = /[<>{};]/;
97
+
98
+ /** Neutral base under the agent's CSS, so a bare sketch already looks native. */
99
+ export const SKETCH_BASE_CSS = `*,*::before,*::after{box-sizing:border-box}
100
+ html{color-scheme:var(--sketch-scheme);background:var(--sketch-canvas)}
101
+ body{margin:0;padding:16px;font:14px/1.5 var(--sketch-font);color:var(--sketch-text);-webkit-font-smoothing:antialiased}
102
+ h1,h2,h3,h4{margin:0 0 .5em;color:var(--sketch-text-strong);line-height:1.25}
103
+ p{margin:0 0 .5em}
104
+ img,svg{max-width:100%}
105
+ code,kbd,pre{font-family:var(--sketch-font-mono)}`;
@@ -2,6 +2,7 @@ import type { ACTIVITY_KINDS } from "../constants/run-status";
2
2
  import type { PendingAttachment } from "../utils/attachments";
3
3
  import type {
4
4
  AllowedBy,
5
+ BlockPreview,
5
6
  Notice,
6
7
  ProviderName,
7
8
  QuestionAnswerRecord,
@@ -91,6 +92,14 @@ export interface QuestionAnswerMessage {
91
92
  timestampMs: number;
92
93
  }
93
94
 
95
+ /** A block preview the agent shared, drawn as an attachment card. */
96
+ export interface BlockPreviewMessage {
97
+ id: string;
98
+ role: "block_preview";
99
+ preview: BlockPreview;
100
+ timestampMs: number;
101
+ }
102
+
94
103
  /**
95
104
  * What the running turn is doing, as last reported by the sidecar.
96
105
  * `receivedAtMs` is the local time of that report, from which the elapsed and
@@ -126,7 +135,8 @@ export type ConversationMessage =
126
135
  | ErrorMessage
127
136
  | NoticeMessage
128
137
  | ChangeSetMessage
129
- | QuestionAnswerMessage;
138
+ | QuestionAnswerMessage
139
+ | BlockPreviewMessage;
130
140
 
131
141
  export interface QueuedMessage {
132
142
  id: string;
@@ -203,6 +203,61 @@ export interface AttachmentMeta {
203
203
  size: number;
204
204
  }
205
205
 
206
+ /** One block of a preview tree, as the DMS `DmsBlockPreview` takes it. */
207
+ export interface PreviewNode {
208
+ type?: string;
209
+ componentName?: string;
210
+ options?: Record<string, unknown>;
211
+ id?: string;
212
+ slot?: string;
213
+ colSpan?: number;
214
+ children?: PreviewNode[];
215
+ }
216
+
217
+ export type PreviewTree = PreviewNode | PreviewNode[];
218
+
219
+ /**
220
+ * A custom component the agent proposes as static HTML and CSS, drawn in a
221
+ * sandboxed frame. Never a DMS block.
222
+ */
223
+ export interface PreviewSketch {
224
+ html: string;
225
+ css?: string;
226
+ caption?: string;
227
+ height?: number;
228
+ }
229
+
230
+ /** What one tab of a preview draws: DMS blocks, a sketch, or both. */
231
+ export interface PreviewView {
232
+ tree?: PreviewTree;
233
+ sketch?: PreviewSketch;
234
+ }
235
+
236
+ export interface BlockPreviewVariant extends PreviewView {
237
+ label: string;
238
+ }
239
+
240
+ /**
241
+ * The user's mockup a preview is compared with: an image they attached in the
242
+ * conversation (by name) or a URL.
243
+ */
244
+ export interface BlockPreviewReference {
245
+ attachmentName?: string;
246
+ url?: string;
247
+ caption?: string;
248
+ }
249
+
250
+ /** A block preview the agent shared with its PreviewBlocks tool. */
251
+ export interface BlockPreview extends PreviewView {
252
+ id: string;
253
+ title: string;
254
+ description?: string;
255
+ label?: string;
256
+ reference?: BlockPreviewReference;
257
+ variants?: BlockPreviewVariant[];
258
+ timestampMs: number;
259
+ }
260
+
206
261
  export interface SnapshotMessage {
207
262
  role: string;
208
263
  content: string;
@@ -217,6 +272,7 @@ export interface SnapshotMessage {
217
272
  changeSetId?: string;
218
273
  notice?: Notice;
219
274
  answers?: QuestionAnswerRecord[];
275
+ preview?: BlockPreview;
220
276
  }
221
277
 
222
278
  export interface QueuedItemWire {
@@ -0,0 +1,145 @@
1
+ import { MESSAGE_ROLES } from "../constants/conversation";
2
+ import { lastMatching } from "./arrays";
3
+ import {
4
+ BLOCK_PREVIEW_MAIN_TAB,
5
+ BLOCK_PREVIEW_VARIANT_TAB_PREFIX,
6
+ JSON_INDENT,
7
+ REMOTE_URL,
8
+ SAME_ORIGIN_PATH,
9
+ } from "../constants/block-preview";
10
+ import type { ConversationMessage, UserMessage } from "../types/conversation";
11
+ import type {
12
+ BlockPreview,
13
+ BlockPreviewReference,
14
+ PreviewNode,
15
+ PreviewTree,
16
+ PreviewView,
17
+ } from "../types/protocol";
18
+
19
+ export interface BlockPreviewTab extends PreviewView {
20
+ value: string;
21
+ /** The view's own label; null for an unlabelled main view, named by the chat. */
22
+ label: string | null;
23
+ }
24
+
25
+ /**
26
+ * How the mockup is drawn: an image the page may load (an attachment of this
27
+ * session, or a same-origin path), a link for an external URL — loading it
28
+ * could send what the agent read to its address — or a note when the
29
+ * attachment is gone (only its name survives a reload).
30
+ */
31
+ export type ReferenceView = ImageReference | LinkReference | MissingReference;
32
+
33
+ interface ImageReference {
34
+ kind: "image";
35
+ src: string;
36
+ name: string;
37
+ }
38
+
39
+ interface LinkReference {
40
+ kind: "link";
41
+ href: string;
42
+ }
43
+
44
+ interface MissingReference {
45
+ kind: "missing";
46
+ name: string;
47
+ }
48
+
49
+ export function rootsOf(tree: PreviewTree): PreviewNode[] {
50
+ return Array.isArray(tree) ? tree : [tree];
51
+ }
52
+
53
+ function countIn(node: PreviewNode): number {
54
+ return 1 + (node.children ?? []).reduce((sum, c) => sum + countIn(c), 0);
55
+ }
56
+
57
+ /** The blocks with no children under the node: what a row actually shows. */
58
+ export function countLeaves(node: PreviewNode): number {
59
+ const children = node.children ?? [];
60
+ if (children.length === 0) return 1;
61
+ return children.reduce((sum, child) => sum + countLeaves(child), 0);
62
+ }
63
+
64
+ /** Every block of the tree, nested ones included; none without a tree. */
65
+ export function countBlocks(tree: PreviewTree | undefined): number {
66
+ if (tree === undefined) return 0;
67
+ return rootsOf(tree).reduce((sum, node) => sum + countIn(node), 0);
68
+ }
69
+
70
+ /** The block types at the top of the tree, for the card's subtitle. */
71
+ export function topBlockTypes(tree: PreviewTree | undefined): string[] {
72
+ if (tree === undefined) return [];
73
+ const names = rootsOf(tree).map(
74
+ (node) => node.type ?? node.componentName ?? "",
75
+ );
76
+ return [...new Set(names.filter((name) => name !== ""))];
77
+ }
78
+
79
+ export function previewTabs(preview: BlockPreview): BlockPreviewTab[] {
80
+ const variants = (preview.variants ?? []).map((variant, index) => ({
81
+ value: `${BLOCK_PREVIEW_VARIANT_TAB_PREFIX}${index}`,
82
+ label: variant.label,
83
+ tree: variant.tree,
84
+ sketch: variant.sketch,
85
+ }));
86
+ const main = {
87
+ value: BLOCK_PREVIEW_MAIN_TAB,
88
+ label: preview.label ?? null,
89
+ tree: preview.tree,
90
+ sketch: preview.sketch,
91
+ };
92
+ return [main, ...variants];
93
+ }
94
+
95
+ /** Whether any view of the preview proposes a custom sketch. */
96
+ export function hasSketch(preview: BlockPreview): boolean {
97
+ return [preview, ...(preview.variants ?? [])].some(
98
+ (view) => view.sketch !== undefined,
99
+ );
100
+ }
101
+
102
+ export function formatTree(tree: PreviewTree): string {
103
+ return JSON.stringify(tree, null, JSON_INDENT);
104
+ }
105
+
106
+ function isUser(message: ConversationMessage): message is UserMessage {
107
+ return message.role === MESSAGE_ROLES.USER;
108
+ }
109
+
110
+ /** The latest image of that name the user attached in this session. */
111
+ function findAttachedImage(
112
+ name: string,
113
+ messages: readonly ConversationMessage[],
114
+ ): string | null {
115
+ const attachments = messages
116
+ .filter(isUser)
117
+ .flatMap((message) => message.attachments ?? []);
118
+ const match = lastMatching(
119
+ attachments,
120
+ (attachment) =>
121
+ attachment.name === name && attachment.dataUrl !== undefined,
122
+ );
123
+ return match?.dataUrl ?? null;
124
+ }
125
+
126
+ function viewOfUrl(url: string): ReferenceView | null {
127
+ if (SAME_ORIGIN_PATH.test(url)) return { kind: "image", src: url, name: url };
128
+ if (REMOTE_URL.test(url)) return { kind: "link", href: url };
129
+ return null;
130
+ }
131
+
132
+ export function referenceView(
133
+ reference: BlockPreviewReference | undefined,
134
+ messages: readonly ConversationMessage[],
135
+ ): ReferenceView | null {
136
+ if (reference === undefined) return null;
137
+ const name = reference.attachmentName;
138
+ if (name !== undefined) {
139
+ const src = findAttachedImage(name, messages);
140
+ return src === null
141
+ ? { kind: "missing", name }
142
+ : { kind: "image", src, name };
143
+ }
144
+ return reference.url === undefined ? null : viewOfUrl(reference.url);
145
+ }
@@ -7,6 +7,7 @@ import {
7
7
  } from "../constants/conversation";
8
8
  import type {
9
9
  AssistantMessage,
10
+ BlockPreviewMessage,
10
11
  ConversationMessage,
11
12
  ErrorMessage,
12
13
  MessageAttachment,
@@ -14,7 +15,11 @@ import type {
14
15
  ToolStatus,
15
16
  UserMessage,
16
17
  } from "../types/conversation";
17
- import type { SnapshotMessage, ToolOutcome } from "../types/protocol";
18
+ import type {
19
+ BlockPreview,
20
+ SnapshotMessage,
21
+ ToolOutcome,
22
+ } from "../types/protocol";
18
23
  import { newUuid } from "./ids";
19
24
 
20
25
  const STOPPED_OUTCOME: ToolOutcome = "stopped";
@@ -81,6 +86,17 @@ export function buildLocalError(
81
86
  };
82
87
  }
83
88
 
89
+ export function buildBlockPreviewMessage(
90
+ preview: BlockPreview,
91
+ ): BlockPreviewMessage {
92
+ return {
93
+ id: newUuid(),
94
+ role: MESSAGE_ROLES.BLOCK_PREVIEW,
95
+ preview,
96
+ timestampMs: preview.timestampMs,
97
+ };
98
+ }
99
+
84
100
  function normalizeToolStatus(raw: string | undefined): ToolStatus {
85
101
  if (raw === TOOL_STATUS.SUCCESS) return TOOL_STATUS.SUCCESS;
86
102
  if (raw === TOOL_STATUS.ERROR) return TOOL_STATUS.ERROR;
@@ -223,6 +239,11 @@ function addQuestionAnswer(build: SnapshotBuild, snap: SnapshotMessage): void {
223
239
  });
224
240
  }
225
241
 
242
+ function addBlockPreview(build: SnapshotBuild, snap: SnapshotMessage): void {
243
+ if (snap.preview === undefined) return;
244
+ build.out.push(buildBlockPreviewMessage(snap.preview));
245
+ }
246
+
226
247
  const SNAPSHOT_BUILDERS: Record<
227
248
  string,
228
249
  (build: SnapshotBuild, snap: SnapshotMessage) => void
@@ -235,6 +256,7 @@ const SNAPSHOT_BUILDERS: Record<
235
256
  [MESSAGE_ROLES.NOTICE]: addNotice,
236
257
  [MESSAGE_ROLES.CHANGE_SET]: addChangeSet,
237
258
  [MESSAGE_ROLES.QUESTION_ANSWER]: addQuestionAnswer,
259
+ [MESSAGE_ROLES.BLOCK_PREVIEW]: addBlockPreview,
238
260
  };
239
261
 
240
262
  /**
@@ -4,6 +4,7 @@ import {
4
4
  } from "../constants/conversation";
5
5
  import type {
6
6
  AssistantMessage,
7
+ BlockPreviewMessage,
7
8
  ChangeSetMessage,
8
9
  ConversationMessage,
9
10
  ErrorMessage,
@@ -29,7 +30,14 @@ interface WhoItem {
29
30
  }
30
31
 
31
32
  interface MessageItem {
32
- kind: "user" | "assistant" | "error" | "notice" | "change" | "answers";
33
+ kind:
34
+ | "user"
35
+ | "assistant"
36
+ | "error"
37
+ | "notice"
38
+ | "change"
39
+ | "answers"
40
+ | "preview";
33
41
  key: string;
34
42
  message:
35
43
  | UserMessage
@@ -37,7 +45,8 @@ interface MessageItem {
37
45
  | ErrorMessage
38
46
  | NoticeMessage
39
47
  | ChangeSetMessage
40
- | QuestionAnswerMessage;
48
+ | QuestionAnswerMessage
49
+ | BlockPreviewMessage;
41
50
  }
42
51
 
43
52
  interface ToolsItem {
@@ -66,6 +75,7 @@ const KIND_BY_ROLE: Record<string, MessageItem["kind"]> = {
66
75
  [MESSAGE_ROLES.NOTICE]: "notice",
67
76
  [MESSAGE_ROLES.CHANGE_SET]: "change",
68
77
  [MESSAGE_ROLES.QUESTION_ANSWER]: "answers",
78
+ [MESSAGE_ROLES.BLOCK_PREVIEW]: "preview",
69
79
  };
70
80
 
71
81
  /** Roles drawn on the agent's side, under one "✦ Assistant" line per turn. */
@@ -73,6 +83,7 @@ const AGENT_ROLES: ReadonlySet<string> = new Set([
73
83
  MESSAGE_ROLES.ASSISTANT,
74
84
  MESSAGE_ROLES.TOOL,
75
85
  MESSAGE_ROLES.CHANGE_SET,
86
+ MESSAGE_ROLES.BLOCK_PREVIEW,
76
87
  ]);
77
88
 
78
89
  function startOfDay(ms: number): number {