agent-canvas 0.1.33 → 0.1.36

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.
@@ -20,7 +20,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
20
20
  import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
21
21
  import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
22
22
  import type { Annotation } from "#canvas/runtime";
23
- import { MODE, metaUrl, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
23
+ import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
24
24
 
25
25
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
26
26
 
@@ -309,8 +309,7 @@ function App() {
309
309
  // Fetch initial meta
310
310
  useEffect(() => {
311
311
  if (!sessionId) return;
312
- fetch(metaUrl())
313
- .then((r) => r.json())
312
+ fetchMeta()
314
313
  .then((data: any) => {
315
314
  if (data.currentRevision) {
316
315
  setCurrentRevision(data.currentRevision);
@@ -6,7 +6,7 @@ import { extractContext } from "./annotationContext";
6
6
  import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
7
  import { generateAnnotationId } from "./utils";
8
8
  import { useTextAnnotation } from "./useTextAnnotation";
9
- import { canvasJsUrl } from "./clientApi";
9
+ import { loadCanvasModule } from "./clientApi";
10
10
 
11
11
  /** All navigable blocks (keyboard arrows) */
12
12
  const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -42,7 +42,7 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
42
42
  setError(null);
43
43
  setFocusedBlockIndex(null);
44
44
  void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
45
- import(/* @vite-ignore */ canvasJsUrl(filename, revision))
45
+ loadCanvasModule(filename, revision)
46
46
  .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
47
47
  .catch((e) => { setError(e.message); setLoading(false); });
48
48
  }, [sessionId, revision, filename]);
@@ -8,6 +8,9 @@ export interface ShareEntry {
8
8
  ownerToken?: string;
9
9
  expiresAt?: string;
10
10
  lastFeedbackAt?: string;
11
+ /** Present iff the share is end-to-end encrypted; the URL itself
12
+ * carries the key in the `#k=...` fragment. */
13
+ encryptionKey?: string;
11
14
  }
12
15
 
13
16
  interface ShareDialogProps {
@@ -160,6 +163,15 @@ export function ShareDialog({
160
163
  Expires {new Date(shareForRev.expiresAt).toLocaleDateString()}.
161
164
  </p>
162
165
  )}
166
+ {shareForRev.encryptionKey && (
167
+ <p className="text-[11px] text-accent-green font-body flex items-center gap-1.5">
168
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
169
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
170
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
171
+ </svg>
172
+ End-to-end encrypted — the key is part of the link and never leaves your browser.
173
+ </p>
174
+ )}
163
175
  <p className="text-[11px] text-text-tertiary font-body">
164
176
  Anyone with this link can view and leave feedback. Reviewer
165
177
  feedback will appear in your annotation sidebar within seconds.
@@ -8,8 +8,27 @@
8
8
  * `window.__CANVAS_SHARE__`, which the CF Worker injects into the HTML
9
9
  * shell it serves. Local daemon never sets this, so the app defaults to
10
10
  * local mode.
11
+ *
12
+ * End-to-end encryption
13
+ * ---------------------
14
+ * When the share URL carries a `#k=<base64url>` fragment, every secret
15
+ * payload going over the wire is encrypted with that key:
16
+ * - canvas JS bodies (decrypted before being imported as ES modules),
17
+ * - origin.sessionId / origin.label in the meta response,
18
+ * - feedback fields submitted by the reviewer.
19
+ * The key is never sent to the worker — browsers strip URL fragments
20
+ * before issuing requests. Legacy shares with no fragment continue to
21
+ * work in cleartext.
11
22
  */
12
23
 
24
+ import {
25
+ importShareKey,
26
+ encryptString,
27
+ decryptString,
28
+ ENCRYPTION_META,
29
+ type EncryptionMeta,
30
+ } from "./shareCrypto";
31
+
13
32
  declare global {
14
33
  interface Window {
15
34
  __CANVAS_SHARE__?: {
@@ -22,6 +41,8 @@ declare global {
22
41
  export interface SharedModeInfo {
23
42
  isShared: true;
24
43
  shareId: string;
44
+ /** Lazy-imported AES-GCM key parsed from the URL fragment, if any. */
45
+ encryptionKey: Promise<CryptoKey> | null;
25
46
  }
26
47
 
27
48
  export interface LocalModeInfo {
@@ -31,11 +52,28 @@ export interface LocalModeInfo {
31
52
 
32
53
  export type ModeInfo = SharedModeInfo | LocalModeInfo;
33
54
 
55
+ /** Strip the `#k=...` fragment and parse out the key (base64url). */
56
+ function readFragmentKey(): string | null {
57
+ if (typeof window === "undefined") return null;
58
+ const hash = window.location.hash.replace(/^#/, "");
59
+ if (!hash) return null;
60
+ for (const part of hash.split("&")) {
61
+ const [k, v] = part.split("=");
62
+ if (k === "k" && v) return decodeURIComponent(v);
63
+ }
64
+ return null;
65
+ }
66
+
34
67
  /** One-time detection. In shared mode the daemon-provided sessionId from
35
68
  * the URL path is ignored in favor of the worker-injected shareId. */
36
69
  export function detectMode(): ModeInfo {
37
70
  if (typeof window !== "undefined" && window.__CANVAS_SHARE__?.shareId) {
38
- return { isShared: true, shareId: window.__CANVAS_SHARE__.shareId };
71
+ const encoded = readFragmentKey();
72
+ return {
73
+ isShared: true,
74
+ shareId: window.__CANVAS_SHARE__.shareId,
75
+ encryptionKey: encoded ? importShareKey(encoded) : null,
76
+ };
39
77
  }
40
78
  // Local: session id is the path suffix /s/:sessionId
41
79
  const sessionId = typeof window !== "undefined"
@@ -46,6 +84,11 @@ export function detectMode(): ModeInfo {
46
84
 
47
85
  export const MODE: ModeInfo = detectMode();
48
86
 
87
+ /** True iff we're in a shared canvas AND have a fragment key. */
88
+ export function isEncryptedShare(): boolean {
89
+ return MODE.isShared && MODE.encryptionKey !== null;
90
+ }
91
+
49
92
  /** Build a canonical id-ish string used as React keys / identifiers. */
50
93
  export function getIdentifier(): string {
51
94
  return MODE.isShared ? MODE.shareId : MODE.sessionId;
@@ -75,6 +118,73 @@ export function uploadUrl(): string {
75
118
  return `/api/session/${MODE.sessionId}/upload`;
76
119
  }
77
120
 
121
+ // --- Meta fetch (with optional decryption) ---------------------------------
122
+
123
+ interface RawMetaResponse {
124
+ encryption?: EncryptionMeta;
125
+ origin?: { sessionId?: string; label?: string; revision?: number; createdAt?: string };
126
+ [key: string]: unknown;
127
+ }
128
+
129
+ /** Fetch + parse meta, decrypting origin.sessionId/label for encrypted
130
+ * shares. The shape returned is the same as the worker/daemon emits, so
131
+ * callers don't have to branch. */
132
+ export async function fetchMeta(): Promise<any> {
133
+ const res = await fetch(metaUrl());
134
+ if (!res.ok) throw new Error(`meta ${res.status}`);
135
+ const data = (await res.json()) as RawMetaResponse;
136
+
137
+ if (data.encryption && MODE.isShared && MODE.encryptionKey) {
138
+ const key = await MODE.encryptionKey;
139
+ if (data.origin) {
140
+ const o: any = { ...data.origin };
141
+ if (o.sessionId) o.sessionId = await decryptString(key, o.sessionId);
142
+ if (o.label) o.label = await decryptString(key, o.label);
143
+ data.origin = o;
144
+ }
145
+ // The worker synthesizes a one-element revisions array from the same
146
+ // ShareRecord.origin, so its `label` is also ciphertext — decrypt it
147
+ // in lockstep with origin.label above.
148
+ const revs = (data as any).revisions;
149
+ if (Array.isArray(revs)) {
150
+ for (const r of revs) {
151
+ if (r.label) {
152
+ try { r.label = await decryptString(key, r.label); } catch {}
153
+ }
154
+ }
155
+ }
156
+ }
157
+ return data;
158
+ }
159
+
160
+ // --- Canvas module loading -------------------------------------------------
161
+
162
+ /** Dynamically import the compiled canvas JS for a revision. In encrypted
163
+ * shared mode the body is ciphertext, so we fetch it, decrypt to plain JS
164
+ * source, then materialize as a Blob URL the browser can `import()`.
165
+ * Import maps still apply to Blob-URL module imports, so the resulting
166
+ * module's `#canvas/runtime` / `#canvas/components` imports resolve
167
+ * exactly as they would for an in-place script. */
168
+ export async function loadCanvasModule(filename: string, revision?: number): Promise<any> {
169
+ const url = canvasJsUrl(filename, revision);
170
+ if (!isEncryptedShare()) {
171
+ return import(/* @vite-ignore */ url);
172
+ }
173
+ const res = await fetch(url);
174
+ if (!res.ok) throw new Error(`canvas ${res.status}`);
175
+ const ciphertext = await res.text();
176
+ const key = await (MODE as SharedModeInfo).encryptionKey!;
177
+ const js = await decryptString(key, ciphertext);
178
+ const blob = new Blob([js], { type: "application/javascript" });
179
+ const blobUrl = URL.createObjectURL(blob);
180
+ try {
181
+ return await import(/* @vite-ignore */ blobUrl);
182
+ } finally {
183
+ // Revoke after a tick so the import has fully resolved its source.
184
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
185
+ }
186
+ }
187
+
78
188
  // --- Feedback submission ----------------------------------------------------
79
189
 
80
190
  /**
@@ -115,17 +225,60 @@ export interface SharedFeedbackPayload {
115
225
  generalNote?: string;
116
226
  }
117
227
 
228
+ /** Encrypt the secret fields of a feedback payload in-place semantics. */
229
+ async function encryptFeedback(
230
+ payload: SharedFeedbackPayload,
231
+ key: CryptoKey,
232
+ ): Promise<SharedFeedbackPayload & { encryption: EncryptionMeta }> {
233
+ const annotations = await Promise.all(
234
+ payload.annotations.map(async (a) => {
235
+ const out: Record<string, unknown> = {
236
+ id: a.id,
237
+ createdAt: a.createdAt,
238
+ snippet: await encryptString(key, String(a.snippet ?? "")),
239
+ note: await encryptString(key, String(a.note ?? "")),
240
+ };
241
+ if (a.filePath) out.filePath = await encryptString(key, String(a.filePath));
242
+ if (a.canvasFile) out.canvasFile = await encryptString(key, String(a.canvasFile));
243
+ if (a.context !== undefined && a.context !== null) {
244
+ out.context = await encryptString(key, JSON.stringify(a.context));
245
+ }
246
+ // Image attachments are content-addressed worker URLs — already public
247
+ // by virtue of being served by the worker, so leaving them plaintext
248
+ // is fine and avoids breaking client-side rendering of the URL.
249
+ if (a.attachments) out.attachments = a.attachments;
250
+ return out;
251
+ }),
252
+ );
253
+ return {
254
+ author: {
255
+ id: payload.author.id,
256
+ name: await encryptString(key, payload.author.name),
257
+ },
258
+ revision: payload.revision,
259
+ annotations,
260
+ ...(payload.generalNote ? { generalNote: await encryptString(key, payload.generalNote) } : {}),
261
+ encryption: ENCRYPTION_META,
262
+ };
263
+ }
264
+
118
265
  /**
119
266
  * Submit feedback in shared mode. Throws on HTTP error. Caller is
120
267
  * responsible for collecting + prompting for the reviewer's name before
121
- * invoking this.
268
+ * invoking this. Transparently encrypts when the share URL carried a
269
+ * fragment key.
122
270
  */
123
271
  export async function submitSharedFeedback(payload: SharedFeedbackPayload): Promise<void> {
124
272
  if (!MODE.isShared) throw new Error("submitSharedFeedback called outside shared mode");
273
+ let body: unknown = payload;
274
+ if (MODE.encryptionKey) {
275
+ const key = await MODE.encryptionKey;
276
+ body = await encryptFeedback(payload, key);
277
+ }
125
278
  const res = await fetch(`/s/${MODE.shareId}/feedback`, {
126
279
  method: "POST",
127
280
  headers: { "Content-Type": "application/json" },
128
- body: JSON.stringify(payload),
281
+ body: JSON.stringify(body),
129
282
  });
130
283
  if (!res.ok) {
131
284
  const text = await res.text().catch(() => "");
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { renderMarkdownChildren } from "./renderMarkdownChildren";
2
3
 
3
4
  interface CalloutProps {
4
5
  type?: "info" | "warning" | "danger" | "tip";
@@ -19,7 +20,7 @@ export function Callout({ type = "info", children }: CalloutProps) {
19
20
  <span className={`${cfg.iconColor} opacity-70 text-base leading-relaxed flex-shrink-0 mt-px`}>
20
21
  {cfg.icon}
21
22
  </span>
22
- <div className="text-body text-text-secondary leading-relaxed">{children}</div>
23
+ <div className="text-body text-text-secondary leading-relaxed">{renderMarkdownChildren(children)}</div>
23
24
  </div>
24
25
  );
25
26
  }
@@ -1,17 +1,30 @@
1
- import React, { useEffect, useRef, useState } from "react";
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useAnnotations } from "#canvas/runtime";
3
3
  import { ResponseNote } from "./ResponseNote";
4
4
 
5
+ export type ChoiceOption = string | { value: string; label?: string };
6
+
7
+ type NormalizedOption = { value: string; label: string };
8
+
9
+ function normalizeOptions(options: ChoiceOption[]): NormalizedOption[] {
10
+ return options.map((opt) =>
11
+ typeof opt === "string"
12
+ ? { value: opt, label: opt }
13
+ : { value: opt.value, label: opt.label ?? opt.value },
14
+ );
15
+ }
16
+
5
17
  /** Radio — pick one option */
6
18
  interface ChoiceProps {
7
19
  id: string;
8
20
  label: string;
9
- options: string[];
21
+ options: ChoiceOption[];
10
22
  required?: boolean;
11
23
  }
12
24
 
13
25
  export function Choice({ id, label, options, required }: ChoiceProps) {
14
26
  const { responses, setResponse } = useAnnotations();
27
+ const normalized = useMemo(() => normalizeOptions(options), [options]);
15
28
  const current = responses.get(id);
16
29
  const selected = current?.value as string | undefined;
17
30
  const note = current?.note || "";
@@ -19,11 +32,11 @@ export function Choice({ id, label, options, required }: ChoiceProps) {
19
32
  const wrapperRef = useRef<HTMLDivElement>(null);
20
33
 
21
34
  if (!responses.has(id)) {
22
- setResponse(id, { id, type: "radio", label, value: null, options, required });
35
+ setResponse(id, { id, type: "radio", label, value: null, options: normalized.map((o) => o.value), required });
23
36
  }
24
37
 
25
- const handleSelect = (opt: string) => {
26
- setResponse(id, { ...current!, value: opt });
38
+ const handleSelect = (value: string) => {
39
+ setResponse(id, { ...current!, value });
27
40
  };
28
41
 
29
42
  // Keyboard selection via custom event from PlanRenderer
@@ -32,12 +45,13 @@ export function Choice({ id, label, options, required }: ChoiceProps) {
32
45
  if (!el) return;
33
46
  const handler = (e: Event) => {
34
47
  const target = (e.target as HTMLElement).closest("[data-md-label]");
35
- const opt = target?.getAttribute("data-md-label");
36
- if (opt && options.includes(opt)) handleSelect(opt);
48
+ const labelAttr = target?.getAttribute("data-md-label");
49
+ const match = normalized.find((o) => o.label === labelAttr);
50
+ if (match) handleSelect(match.value);
37
51
  };
38
52
  el.addEventListener("kb-select", handler);
39
53
  return () => el.removeEventListener("kb-select", handler);
40
- }, [options, handleSelect]);
54
+ }, [normalized, handleSelect]);
41
55
 
42
56
  const showError = current?.required && !selected;
43
57
 
@@ -48,26 +62,26 @@ export function Choice({ id, label, options, required }: ChoiceProps) {
48
62
  {required && <span className="text-[10px] text-accent-red font-body">*</span>}
49
63
  </div>
50
64
  <div className="space-y-1">
51
- {options.map((opt) => (
65
+ {normalized.map(({ value, label: optLabel }) => (
52
66
  <label
53
- key={opt}
67
+ key={value}
54
68
  data-md="choice-option"
55
- data-md-label={opt}
56
- onClick={() => handleSelect(opt)}
69
+ data-md-label={optLabel}
70
+ onClick={() => handleSelect(value)}
57
71
  className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
58
- selected === opt
72
+ selected === value
59
73
  ? "bg-highlight-selected"
60
74
  : "hover:bg-bg-elevated"
61
75
  }`}
62
76
  >
63
77
  <span className={`w-4 h-4 rounded-full border-[1.5px] flex items-center justify-center flex-shrink-0 transition-all ${
64
- selected === opt
78
+ selected === value
65
79
  ? "border-accent-amber bg-accent-amber"
66
80
  : "border-border-strong"
67
81
  }`}>
68
- {selected === opt && <span className="w-1.5 h-1.5 rounded-full bg-text-inverse" />}
82
+ {selected === value && <span className="w-1.5 h-1.5 rounded-full bg-text-inverse" />}
69
83
  </span>
70
- <span className="text-[13px] font-body text-text-secondary">{opt}</span>
84
+ <span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
71
85
  </label>
72
86
  ))}
73
87
  </div>
@@ -81,12 +95,13 @@ export function Choice({ id, label, options, required }: ChoiceProps) {
81
95
  interface MultiChoiceProps {
82
96
  id: string;
83
97
  label: string;
84
- options: string[];
98
+ options: ChoiceOption[];
85
99
  required?: boolean;
86
100
  }
87
101
 
88
102
  export function MultiChoice({ id, label, options, required }: MultiChoiceProps) {
89
103
  const { responses, setResponse } = useAnnotations();
104
+ const normalized = useMemo(() => normalizeOptions(options), [options]);
90
105
  const current = responses.get(id);
91
106
  const selected: string[] = (current?.value as string[]) || [];
92
107
  const note = current?.note || "";
@@ -94,11 +109,11 @@ export function MultiChoice({ id, label, options, required }: MultiChoiceProps)
94
109
  const wrapperRef = useRef<HTMLDivElement>(null);
95
110
 
96
111
  if (!responses.has(id)) {
97
- setResponse(id, { id, type: "checkbox", label, value: [], options, required });
112
+ setResponse(id, { id, type: "checkbox", label, value: [], options: normalized.map((o) => o.value), required });
98
113
  }
99
114
 
100
- const toggle = (opt: string) => {
101
- const next = selected.includes(opt) ? selected.filter((o) => o !== opt) : [...selected, opt];
115
+ const toggle = (value: string) => {
116
+ const next = selected.includes(value) ? selected.filter((o) => o !== value) : [...selected, value];
102
117
  setResponse(id, { ...current!, value: next });
103
118
  };
104
119
 
@@ -108,12 +123,13 @@ export function MultiChoice({ id, label, options, required }: MultiChoiceProps)
108
123
  if (!el) return;
109
124
  const handler = (e: Event) => {
110
125
  const target = (e.target as HTMLElement).closest("[data-md-label]");
111
- const opt = target?.getAttribute("data-md-label");
112
- if (opt && options.includes(opt)) toggle(opt);
126
+ const labelAttr = target?.getAttribute("data-md-label");
127
+ const match = normalized.find((o) => o.label === labelAttr);
128
+ if (match) toggle(match.value);
113
129
  };
114
130
  el.addEventListener("kb-select", handler);
115
131
  return () => el.removeEventListener("kb-select", handler);
116
- }, [options, toggle]);
132
+ }, [normalized, toggle]);
117
133
 
118
134
  const showError = current?.required && selected.length === 0;
119
135
 
@@ -124,14 +140,14 @@ export function MultiChoice({ id, label, options, required }: MultiChoiceProps)
124
140
  {required && <span className="text-[10px] text-accent-red font-body">*</span>}
125
141
  </div>
126
142
  <div className="space-y-1">
127
- {options.map((opt) => {
128
- const checked = selected.includes(opt);
143
+ {normalized.map(({ value, label: optLabel }) => {
144
+ const checked = selected.includes(value);
129
145
  return (
130
146
  <label
131
- key={opt}
147
+ key={value}
132
148
  data-md="multichoice-option"
133
- data-md-label={opt}
134
- onClick={() => toggle(opt)}
149
+ data-md-label={optLabel}
150
+ onClick={() => toggle(value)}
135
151
  className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
136
152
  checked ? "bg-highlight-selected" : "hover:bg-bg-elevated"
137
153
  }`}
@@ -147,7 +163,7 @@ export function MultiChoice({ id, label, options, required }: MultiChoiceProps)
147
163
  </svg>
148
164
  )}
149
165
  </span>
150
- <span className="text-[13px] font-body text-text-secondary">{opt}</span>
166
+ <span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
151
167
  </label>
152
168
  );
153
169
  })}
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { renderMarkdownChildren } from "./renderMarkdownChildren";
2
3
 
3
4
  interface NoteProps {
4
5
  children?: React.ReactNode;
@@ -7,7 +8,7 @@ interface NoteProps {
7
8
  export function Note({ children }: NoteProps) {
8
9
  return (
9
10
  <div className="relative bg-bg-elevated rounded-lg px-5 py-4 mt-3 text-body text-text-secondary italic leading-relaxed" data-md="note">
10
- {children}
11
+ {renderMarkdownChildren(children)}
11
12
  </div>
12
13
  );
13
14
  }
@@ -1,4 +1,5 @@
1
1
  import React, { useState } from "react";
2
+ import { renderMarkdownChildren } from "./renderMarkdownChildren";
2
3
 
3
4
  interface SectionProps {
4
5
  title: string;
@@ -26,7 +27,7 @@ export function Section({ title, children }: SectionProps) {
26
27
  {title}
27
28
  </h2>
28
29
  </div>
29
- <div className={collapsed ? "mt-6 hidden" : "mt-6"}>{children}</div>
30
+ <div className={collapsed ? "mt-6 hidden" : "mt-6"}>{renderMarkdownChildren(children)}</div>
30
31
  </div>
31
32
  );
32
33
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { renderMarkdownChildren } from "./renderMarkdownChildren";
2
3
 
3
4
  type Status = "todo" | "done" | "blocked" | "in-progress";
4
5
  type BadgeVariant = "info" | "success" | "warning" | "danger" | "neutral";
@@ -61,7 +62,7 @@ export function Item({ id, label, status, badge, badgeVariant = "neutral", child
61
62
  )}
62
63
  </div>
63
64
  {children && (
64
- <div className="text-body text-text-secondary mt-1 leading-relaxed">{children}</div>
65
+ <div className="text-body text-text-secondary mt-1 leading-relaxed">{renderMarkdownChildren(children)}</div>
65
66
  )}
66
67
  </div>
67
68
  </div>
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+ import { marked } from "marked";
3
+
4
+ marked.setOptions({ gfm: true, breaks: false });
5
+
6
+ function renderTextAsMarkdown(text: string, key: string): React.ReactNode {
7
+ const trimmed = text.trim();
8
+ if (!trimmed) return null;
9
+ const html = marked.parse(trimmed, { async: false }) as string;
10
+ return (
11
+ <div
12
+ key={key}
13
+ className="prose-canvas"
14
+ data-md="markdown"
15
+ data-md-source={trimmed}
16
+ dangerouslySetInnerHTML={{ __html: html }}
17
+ />
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Auto-render markdown for string children, passing JSX children through.
23
+ * Consecutive string runs are grouped into a single markdown block so multi-paragraph
24
+ * prose between JSX elements renders correctly.
25
+ */
26
+ export function renderMarkdownChildren(children: React.ReactNode): React.ReactNode {
27
+ const kids = React.Children.toArray(children);
28
+ if (kids.length === 0) return null;
29
+
30
+ const result: React.ReactNode[] = [];
31
+ let buffer = "";
32
+ let bufferIdx = 0;
33
+
34
+ const flush = () => {
35
+ if (buffer) {
36
+ const node = renderTextAsMarkdown(buffer, `md-${bufferIdx++}`);
37
+ if (node) result.push(node);
38
+ buffer = "";
39
+ }
40
+ };
41
+
42
+ for (const child of kids) {
43
+ if (typeof child === "string" || typeof child === "number") {
44
+ buffer += String(child);
45
+ } else {
46
+ flush();
47
+ result.push(child);
48
+ }
49
+ }
50
+ flush();
51
+ return result;
52
+ }
@@ -70,6 +70,7 @@ function walkNode(el: HTMLElement, lines: string[], depth: number) {
70
70
  case "codeblock": return handleCodeBlock(el, lines);
71
71
  case "callout": return handleCallout(el, lines);
72
72
  case "note": return handleNote(el, lines);
73
+ case "markdown": return handleMarkdown(el, lines);
73
74
  case "table": return handleTable(el, lines);
74
75
  case "checklist": return handleChecklist(el, lines);
75
76
  case "diff": return handleDiff(el, lines);
@@ -208,6 +209,11 @@ function handleMermaid(el: HTMLElement, lines: string[]) {
208
209
  }
209
210
  }
210
211
 
212
+ function handleMarkdown(el: HTMLElement, lines: string[]) {
213
+ const source = el.getAttribute("data-md-source") || "";
214
+ if (source) lines.push(source, "");
215
+ }
216
+
211
217
  function handleChoice(el: HTMLElement, lines: string[]) {
212
218
  const label = el.getAttribute("data-md-label") || "";
213
219
  lines.push(`**${label}:**`);