@agent-native/core 0.75.1 → 0.75.4

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/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +76 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +54 -0
  5. package/corpus/core/src/agent/production-agent.ts +15 -3
  6. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +36 -12
  7. package/corpus/core/src/deploy/build.ts +176 -41
  8. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +0 -8
  9. package/corpus/templates/analytics/actions/update-dashboard.ts +3 -1
  10. package/corpus/templates/analytics/app/components/SqlEditor.tsx +57 -0
  11. package/corpus/templates/analytics/app/components/SqlHighlight.tsx +27 -28
  12. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +130 -8
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -3
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/ViewSqlPopover.tsx +4 -4
  15. package/corpus/templates/analytics/changelog/2026-06-24-active-user-dashboard-panels-classify-templates-more-accurat.md +6 -0
  16. package/corpus/templates/analytics/changelog/2026-06-24-dashboard-panel-deletes-now-stay-deleted-after-refresh.md +6 -0
  17. package/corpus/templates/analytics/changelog/2026-06-24-sql-panel-editors-now-highlight-query-syntax-and-keep-one-cl.md +6 -0
  18. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +2 -2
  19. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +9 -11
  20. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -20
  21. package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +45 -33
  22. package/corpus/templates/plan/changelog/2026-06-24-invalid-generated-recap-blocks-now-show-a-clear-warning-with.md +6 -0
  23. package/corpus/templates/plan/server/plan-content.ts +53 -3
  24. package/dist/agent/durable-background.d.ts +49 -0
  25. package/dist/agent/durable-background.d.ts.map +1 -1
  26. package/dist/agent/durable-background.js +51 -0
  27. package/dist/agent/durable-background.js.map +1 -1
  28. package/dist/agent/production-agent.d.ts.map +1 -1
  29. package/dist/agent/production-agent.js +15 -3
  30. package/dist/agent/production-agent.js.map +1 -1
  31. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -1
  32. package/dist/cli/sync-builder-starter-manifest.js +21 -10
  33. package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
  34. package/dist/deploy/build.d.ts +61 -21
  35. package/dist/deploy/build.d.ts.map +1 -1
  36. package/dist/deploy/build.js +164 -41
  37. package/dist/deploy/build.js.map +1 -1
  38. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
2
2
  import { cn } from "@/lib/utils";
3
3
 
4
4
  const KEYWORDS = new Set([
@@ -266,36 +266,35 @@ const TOKEN_CLASS: Record<TokenType, string> = {
266
266
  plain: "",
267
267
  };
268
268
 
269
- interface SqlHighlightProps {
269
+ interface SqlHighlightProps extends HTMLAttributes<HTMLPreElement> {
270
270
  sql: string;
271
- className?: string;
272
271
  preClassName?: string;
273
272
  }
274
273
 
275
- export function SqlHighlight({
276
- sql,
277
- className,
278
- preClassName,
279
- }: SqlHighlightProps) {
280
- const tokens = tokenize(sql);
281
- const nodes: ReactNode[] = tokens.map((tok, idx) => {
282
- const cls = TOKEN_CLASS[tok.type];
283
- if (!cls) return tok.value;
274
+ export const SqlHighlight = forwardRef<HTMLPreElement, SqlHighlightProps>(
275
+ function SqlHighlight({ sql, className, preClassName, ...props }, ref) {
276
+ const tokens = tokenize(sql);
277
+ const nodes: ReactNode[] = tokens.map((tok, idx) => {
278
+ const cls = TOKEN_CLASS[tok.type];
279
+ if (!cls) return tok.value;
280
+ return (
281
+ <span key={idx} className={cls}>
282
+ {tok.value}
283
+ </span>
284
+ );
285
+ });
284
286
  return (
285
- <span key={idx} className={cls}>
286
- {tok.value}
287
- </span>
287
+ <pre
288
+ ref={ref}
289
+ {...props}
290
+ className={cn(
291
+ "font-mono text-xs leading-5 whitespace-pre-wrap break-words",
292
+ preClassName,
293
+ className,
294
+ )}
295
+ >
296
+ <code>{nodes}</code>
297
+ </pre>
288
298
  );
289
- });
290
- return (
291
- <pre
292
- className={cn(
293
- "font-mono text-xs leading-5 whitespace-pre-wrap break-words",
294
- preClassName,
295
- className,
296
- )}
297
- >
298
- <code>{nodes}</code>
299
- </pre>
300
- );
301
- }
299
+ },
300
+ );
@@ -3,11 +3,14 @@ import {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useMemo,
8
+ useRef,
7
9
  useState,
8
10
  type CSSProperties,
9
11
  type ReactNode,
10
12
  } from "react";
13
+ import { createPortal } from "react-dom";
11
14
  import {
12
15
  Area,
13
16
  AreaChart,
@@ -49,7 +52,6 @@ import {
49
52
 
50
53
  const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
51
54
  import { useSqlQuery } from "@/lib/sql-query";
52
- import { useChartTooltipFlip } from "@/hooks/use-chart-tooltip-flip";
53
55
  import type {
54
56
  SqlPanel,
55
57
  ChartType,
@@ -75,6 +77,11 @@ const CHART_TOOLTIP_WRAPPER_STYLE: CSSProperties = {
75
77
  pointerEvents: "none",
76
78
  };
77
79
 
80
+ const PORTAL_GUTTER_PADDING = 12;
81
+ const PORTAL_CURSOR_OFFSET = 24;
82
+ const useBrowserLayoutEffect =
83
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
84
+
78
85
  const CHART_TOOLTIP_PROPS = {
79
86
  allowEscapeViewBox: { x: true, y: true },
80
87
  wrapperStyle: CHART_TOOLTIP_WRAPPER_STYLE,
@@ -435,11 +442,15 @@ function ChartTooltip({
435
442
  seriesNameFormatter?: (value: string) => string;
436
443
  valueFormatter?: (value: number) => string;
437
444
  }) {
438
- const tooltipRef = useChartTooltipFlip<HTMLDivElement>();
445
+ const anchorRef = useRef<HTMLDivElement | null>(null);
446
+ const portalRef = useRef<HTMLDivElement | null>(null);
447
+ const [portalPosition, setPortalPosition] = useState<{
448
+ left: number;
449
+ top: number;
450
+ } | null>(null);
439
451
  const items = sortTooltipPayloadItems(
440
452
  payload?.filter((item) => item.value != null && item.value !== "") ?? [],
441
453
  );
442
- if (!active || items.length === 0) return null;
443
454
 
444
455
  const labelText =
445
456
  label == null
@@ -448,11 +459,96 @@ function ChartTooltip({
448
459
  ? labelFormatter(String(label))
449
460
  : String(label);
450
461
 
451
- return (
452
- <div
453
- ref={tooltipRef}
454
- className="min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg"
455
- >
462
+ useBrowserLayoutEffect(() => {
463
+ if (!active || items.length === 0 || typeof window === "undefined") {
464
+ setPortalPosition(null);
465
+ return;
466
+ }
467
+
468
+ const anchor = anchorRef.current;
469
+ const wrapper = anchor?.parentElement;
470
+ if (!anchor || !wrapper) return;
471
+
472
+ let frame = 0;
473
+ const apply = () => {
474
+ frame = 0;
475
+ if (!(wrapper as HTMLElement).style.transform) return;
476
+
477
+ const anchorRect = anchor.getBoundingClientRect();
478
+ const portalRect = portalRef.current?.getBoundingClientRect();
479
+ const width = portalRect?.width || anchorRect.width;
480
+ const height = portalRect?.height || anchorRect.height;
481
+ if (width === 0 || height === 0) return;
482
+
483
+ const sidebar = document.querySelector(".agent-sidebar-panel");
484
+ const sidebarRect = sidebar?.getBoundingClientRect();
485
+ const rightLimit =
486
+ sidebarRect && sidebarRect.width > 0 && sidebarRect.left > 0
487
+ ? sidebarRect.left - PORTAL_GUTTER_PADDING
488
+ : window.innerWidth - PORTAL_GUTTER_PADDING;
489
+ const bottomLimit = window.innerHeight - PORTAL_GUTTER_PADDING;
490
+
491
+ let left = anchorRect.left;
492
+ let top = anchorRect.top;
493
+
494
+ if (left + width > rightLimit) {
495
+ left = anchorRect.left - width - PORTAL_CURSOR_OFFSET;
496
+ }
497
+ left = Math.max(
498
+ PORTAL_GUTTER_PADDING,
499
+ Math.min(left, rightLimit - width),
500
+ );
501
+
502
+ if (top + height > bottomLimit) {
503
+ top = bottomLimit - height;
504
+ }
505
+ top = Math.max(PORTAL_GUTTER_PADDING, top);
506
+
507
+ setPortalPosition((prev) =>
508
+ prev &&
509
+ Math.abs(prev.left - left) < 0.5 &&
510
+ Math.abs(prev.top - top) < 0.5
511
+ ? prev
512
+ : { left, top },
513
+ );
514
+ };
515
+
516
+ const schedule = () => {
517
+ if (frame) return;
518
+ frame = window.requestAnimationFrame(apply);
519
+ };
520
+
521
+ schedule();
522
+
523
+ const mutationObserver = new MutationObserver(schedule);
524
+ mutationObserver.observe(wrapper, {
525
+ attributes: true,
526
+ attributeFilter: ["style", "class"],
527
+ });
528
+
529
+ const resizeObserver =
530
+ typeof ResizeObserver === "undefined"
531
+ ? null
532
+ : new ResizeObserver(schedule);
533
+ resizeObserver?.observe(anchor);
534
+ if (portalRef.current) resizeObserver?.observe(portalRef.current);
535
+
536
+ window.addEventListener("resize", schedule);
537
+ window.addEventListener("scroll", schedule, true);
538
+
539
+ return () => {
540
+ if (frame) window.cancelAnimationFrame(frame);
541
+ mutationObserver.disconnect();
542
+ resizeObserver?.disconnect();
543
+ window.removeEventListener("resize", schedule);
544
+ window.removeEventListener("scroll", schedule, true);
545
+ };
546
+ });
547
+
548
+ if (!active || items.length === 0) return null;
549
+
550
+ const tooltip = (
551
+ <div className="min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg">
456
552
  {labelText && (
457
553
  <div className="mb-1.5 truncate font-medium text-foreground">
458
554
  {labelText}
@@ -485,6 +581,32 @@ function ChartTooltip({
485
581
  </div>
486
582
  </div>
487
583
  );
584
+
585
+ return (
586
+ <>
587
+ <div ref={anchorRef} aria-hidden="true" className="invisible">
588
+ {tooltip}
589
+ </div>
590
+ {portalPosition && typeof document !== "undefined"
591
+ ? createPortal(
592
+ <div
593
+ ref={portalRef}
594
+ role="tooltip"
595
+ style={{
596
+ position: "fixed",
597
+ left: portalPosition.left,
598
+ top: portalPosition.top,
599
+ zIndex: 1000,
600
+ pointerEvents: "none",
601
+ }}
602
+ >
603
+ {tooltip}
604
+ </div>,
605
+ document.body,
606
+ )
607
+ : null}
608
+ </>
609
+ );
488
610
  }
489
611
 
490
612
  function detectKeys(
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
5
5
  import { Input } from "@/components/ui/input";
6
6
  import { Label } from "@/components/ui/label";
7
7
  import { Textarea } from "@/components/ui/textarea";
8
+ import { SqlEditor } from "@/components/SqlEditor";
8
9
  import {
9
10
  Dialog,
10
11
  DialogContent,
@@ -360,13 +361,11 @@ function PanelEditorContent({
360
361
  </Button>
361
362
  )}
362
363
  </div>
363
- <Textarea
364
+ <SqlEditor
364
365
  id="panel-sql"
365
366
  value={form.sql}
366
367
  onChange={(e) => setForm((f) => ({ ...f, sql: e.target.value }))}
367
368
  rows={10}
368
- spellCheck={false}
369
- className="font-mono text-xs resize-y min-h-[200px]"
370
369
  placeholder="SELECT ..."
371
370
  />
372
371
  <p className="text-xs text-muted-foreground">
@@ -7,8 +7,8 @@ import {
7
7
  PopoverTrigger,
8
8
  } from "@/components/ui/popover";
9
9
  import { Button } from "@/components/ui/button";
10
- import { Textarea } from "@/components/ui/textarea";
11
10
  import { Badge } from "@/components/ui/badge";
11
+ import { SqlEditor } from "@/components/SqlEditor";
12
12
  import {
13
13
  Tooltip,
14
14
  TooltipContent,
@@ -206,14 +206,14 @@ export function ViewSqlPopover({
206
206
  </div>
207
207
  </div>
208
208
 
209
- <Textarea
209
+ <SqlEditor
210
+ aria-label="SQL"
210
211
  value={draft}
211
212
  onChange={(e) => {
212
213
  if (canEditSql) setDraft(e.target.value);
213
214
  }}
214
215
  rows={12}
215
- spellCheck={false}
216
- className="font-mono text-xs resize-y min-h-[240px]"
216
+ className="min-h-[240px]"
217
217
  placeholder="SELECT ..."
218
218
  readOnly={!canEditSql}
219
219
  />
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Active-user dashboard panels classify templates more accurately instead of overusing the unknown bucket.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Dashboard panel deletes now stay deleted after refresh.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ SQL panel editors now highlight query syntax and keep one-click formatting available.
@@ -428,7 +428,7 @@
428
428
  "chartType": "area",
429
429
  "source": "first-party",
430
430
  "width": 2,
431
- "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') ORDER BY date, template",
431
+ "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') ORDER BY date, template",
432
432
  "config": {
433
433
  "xKey": "date",
434
434
  "yKey": "users",
@@ -448,7 +448,7 @@
448
448
  "chartType": "area",
449
449
  "source": "first-party",
450
450
  "width": 2,
451
- "sql": "WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template",
451
+ "sql": "WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template",
452
452
  "config": {
453
453
  "xKey": "date",
454
454
  "yKey": "users",
@@ -96,7 +96,7 @@ function fixed(sql: string): (window?: MetricWindow) => string {
96
96
  return () => sql;
97
97
  }
98
98
 
99
- const SIGNUP_TEMPLATE_EXPR =
99
+ const TEMPLATE_EXPR =
100
100
  "COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')";
101
101
  const DASHBOARD_TIME_RANGE_FILTER =
102
102
  "('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))";
@@ -142,10 +142,12 @@ export function usesFirstPartyDashboardFilters(sql: string): boolean {
142
142
  }
143
143
 
144
144
  const TOTAL_SIGNUPS_SQL = `SELECT COUNT(*) AS signups FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}`;
145
- const SIGNUPS_OVER_TIME_SQL = `WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY timestamp) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT substr(timestamp, 1, 10) AS date, ${SIGNUP_TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
146
- const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${SIGNUP_TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '1 day', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '1 day', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
147
- const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${SIGNUP_TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
148
- const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${SIGNUP_TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${SIGNUP_TEMPLATE_EXPR} ORDER BY count DESC`;
145
+ const SIGNUPS_OVER_TIME_SQL = `WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY timestamp) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT substr(timestamp, 1, 10) AS date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
146
+ const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '1 day', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '1 day', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
147
+ const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
148
+ const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${TEMPLATE_EXPR} ORDER BY count DESC`;
149
+ const DAU_BY_TEMPLATE_SQL = `SELECT substr(timestamp, 1, 10) AS date, ${TEMPLATE_EXPR} AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY substr(timestamp, 1, 10), ${TEMPLATE_EXPR} ORDER BY date, template`;
150
+ const WAU_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ${DASHBOARD_TIME_RANGE_FILTER.split("substr(timestamp, 1, 10)").join("event_date")}) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template`;
149
151
 
150
152
  /**
151
153
  * Catalog entries. Order here is the default panel order when a caller passes
@@ -578,9 +580,7 @@ const ENTRIES: FirstPartyMetric[] = [
578
580
  source: "first-party",
579
581
  width: 2,
580
582
  windowed: false,
581
- buildSql: fixed(
582
- "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') ORDER BY date, template",
583
- ),
583
+ buildSql: fixed(DAU_BY_TEMPLATE_SQL),
584
584
  config: {
585
585
  xKey: "date",
586
586
  yKey: "users",
@@ -602,9 +602,7 @@ const ENTRIES: FirstPartyMetric[] = [
602
602
  source: "first-party",
603
603
  width: 2,
604
604
  windowed: false,
605
- buildSql: fixed(
606
- "WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template",
607
- ),
605
+ buildSql: fixed(WAU_BY_TEMPLATE_SQL),
608
606
  config: {
609
607
  xKey: "date",
610
608
  yKey: "users",
@@ -2349,7 +2349,6 @@ export function AddProperty({
2349
2349
  },
2350
2350
  });
2351
2351
  const [open, setOpen] = useState(false);
2352
- const [name, setName] = useState("");
2353
2352
  const [typeQuery, setTypeQuery] = useState("");
2354
2353
  const filteredPropertyTypes = filterDocumentPropertyTypes(typeQuery);
2355
2354
  const firstFilteredPropertyType = filteredPropertyTypes[0] ?? null;
@@ -2377,19 +2376,18 @@ export function AddProperty({
2377
2376
  }),
2378
2377
  }))
2379
2378
  .filter((group) => group.fields.length > 0);
2380
- const addPropertyNameInputRef = useRef<HTMLInputElement>(null);
2379
+ const addPropertySearchInputRef = useRef<HTMLInputElement>(null);
2381
2380
 
2382
2381
  useEffect(() => {
2383
2382
  if (!open) return;
2384
2383
  const frame = requestAnimationFrame(() => {
2385
- addPropertyNameInputRef.current?.focus();
2386
- addPropertyNameInputRef.current?.select();
2384
+ addPropertySearchInputRef.current?.focus();
2385
+ addPropertySearchInputRef.current?.select();
2387
2386
  });
2388
2387
  return () => cancelAnimationFrame(frame);
2389
2388
  }, [open]);
2390
2389
 
2391
2390
  function closeAddPropertyPicker() {
2392
- setName("");
2393
2391
  setTypeQuery("");
2394
2392
  setOpen(false);
2395
2393
  }
@@ -2398,7 +2396,7 @@ export function AddProperty({
2398
2396
  const label = DOCUMENT_PROPERTY_TYPE_LABELS[type];
2399
2397
  await configure.mutateAsync({
2400
2398
  documentId,
2401
- name: name.trim() || label,
2399
+ name: label,
2402
2400
  type,
2403
2401
  options: defaultPropertyOptions(type),
2404
2402
  });
@@ -2445,23 +2443,11 @@ export function AddProperty({
2445
2443
  className="w-80 p-2"
2446
2444
  >
2447
2445
  <div className="grid gap-2">
2448
- <Input
2449
- ref={addPropertyNameInputRef}
2450
- aria-label="New property name"
2451
- autoFocus
2452
- value={name}
2453
- placeholder="Property name"
2454
- onChange={(event) => setName(event.target.value)}
2455
- onKeyDown={(event) => {
2456
- if (event.key === "Escape") {
2457
- event.preventDefault();
2458
- closeAddPropertyPicker();
2459
- }
2460
- }}
2461
- />
2462
2446
  <div className="flex h-8 items-center gap-1 rounded border border-border bg-background px-2">
2463
2447
  <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
2464
2448
  <Input
2449
+ ref={addPropertySearchInputRef}
2450
+ autoFocus
2465
2451
  value={typeQuery}
2466
2452
  placeholder="Search property types"
2467
2453
  aria-label="Search property types"
@@ -43,8 +43,8 @@ const LazyPlanMarkdownEditor = lazy(() =>
43
43
  /**
44
44
  * Marker prefix embedded in salvaged "unknown-block" callout bodies by the
45
45
  * server-side per-block salvage path in parsePlanContent. The renderer detects
46
- * this prefix and shows a "Unsupported block" placeholder card rather than a
47
- * generic callout.
46
+ * this prefix and shows an invalid-block warning card rather than a generic
47
+ * callout.
48
48
  *
49
49
  * Format: `__unknown_block__:<originalType>\n<errorSummary>`
50
50
  */
@@ -87,7 +87,7 @@ class BlockErrorBoundary extends Component<
87
87
  }
88
88
  }
89
89
 
90
- /** Muted "Unsupported block" card for unknown/salvaged/errored blocks. */
90
+ /** Muted warning card for unknown/salvaged/errored blocks. */
91
91
  function UnknownBlockPlaceholder({
92
92
  blockId,
93
93
  originalType,
@@ -103,26 +103,43 @@ function UnknownBlockPlaceholder({
103
103
  data-block-id={blockId}
104
104
  data-unknown-block-type={originalType}
105
105
  >
106
- <div className="flex items-start gap-2 rounded-lg border border-plan-line bg-plan-block/40 px-3 py-2.5 text-plan-muted">
106
+ <div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-3 text-plan-muted">
107
107
  <IconAlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
108
- <span className="min-w-0 text-sm">
109
- <span className="font-medium text-plan-text">
110
- Unsupported block: {originalType}
111
- </span>
112
- <details className="mt-1">
113
- <summary className="cursor-pointer text-xs opacity-60 hover:opacity-80">
114
- Show details
115
- </summary>
116
- <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-xs opacity-70">
117
- {errorSummary}
118
- </pre>
119
- </details>
120
- </span>
108
+ <div className="min-w-0 text-sm">
109
+ <div className="font-medium text-plan-text">
110
+ Invalid {originalType} block
111
+ </div>
112
+ <p className="mt-1 leading-5">
113
+ This generated block did not match the Plan schema, so it was left
114
+ out while the rest of the recap stayed visible.
115
+ </p>
116
+ {errorSummary && (
117
+ <details className="mt-2">
118
+ <summary className="cursor-pointer text-xs opacity-70 hover:opacity-90">
119
+ Validation details
120
+ </summary>
121
+ <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border border-plan-line bg-plan-bg/60 p-2 font-mono text-xs opacity-80">
122
+ {errorSummary}
123
+ </pre>
124
+ </details>
125
+ )}
126
+ </div>
121
127
  </div>
122
128
  </section>
123
129
  );
124
130
  }
125
131
 
132
+ function parseUnknownBlockMarker(block: PlanBlock) {
133
+ if (block.type !== "callout") return null;
134
+ if (!block.data.body.startsWith(UNKNOWN_BLOCK_MARKER)) return null;
135
+ const rest = block.data.body.slice(UNKNOWN_BLOCK_MARKER.length);
136
+ const newline = rest.indexOf("\n");
137
+ return {
138
+ originalType: newline >= 0 ? rest.slice(0, newline) : rest,
139
+ errorSummary: newline >= 0 ? rest.slice(newline + 1) : "",
140
+ };
141
+ }
142
+
126
143
  type PlanBlockViewProps = {
127
144
  block: PlanBlock;
128
145
  onChange?: (block: PlanBlock) => Promise<void> | void;
@@ -163,6 +180,17 @@ function PlanBlockViewInner({
163
180
  planId,
164
181
  collabUser,
165
182
  }: PlanBlockViewProps) {
183
+ const unknownBlock = parseUnknownBlockMarker(block);
184
+ if (unknownBlock) {
185
+ return (
186
+ <UnknownBlockPlaceholder
187
+ blockId={block.id}
188
+ originalType={unknownBlock.originalType}
189
+ errorSummary={unknownBlock.errorSummary}
190
+ />
191
+ );
192
+ }
193
+
166
194
  // Registry-first dispatch. If the block type is registered, render through the
167
195
  // block registry (`BlockView` → spec `Read`, or in edit mode the spec `Edit`
168
196
  // or the schema-driven auto-editor). Unregistered types fall through to the
@@ -230,22 +258,6 @@ function PlanBlockViewInner({
230
258
  );
231
259
  }
232
260
  if (block.type === "callout") {
233
- // Detect the server-side per-block salvage marker. The marker starts with a
234
- // zero-width-space followed by `__unknown_block__:<type>` to avoid collision
235
- // with real callout content (real callouts never start with a ZWSP).
236
- if (block.data.body.startsWith(UNKNOWN_BLOCK_MARKER)) {
237
- const rest = block.data.body.slice(UNKNOWN_BLOCK_MARKER.length);
238
- const newline = rest.indexOf("\n");
239
- const originalType = newline >= 0 ? rest.slice(0, newline) : rest;
240
- const errorSummary = newline >= 0 ? rest.slice(newline + 1) : "";
241
- return (
242
- <UnknownBlockPlaceholder
243
- blockId={block.id}
244
- originalType={originalType}
245
- errorSummary={errorSummary}
246
- />
247
- );
248
- }
249
261
  return (
250
262
  <section className="plan-block plan-callout" data-block-id={block.id}>
251
263
  {block.title && <div className="plan-block-label">{block.title}</div>}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Invalid generated recap blocks now show a clear warning with validation details instead of raw block markers.