@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +76 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +54 -0
- package/corpus/core/src/agent/production-agent.ts +15 -3
- package/corpus/core/src/cli/sync-builder-starter-manifest.ts +36 -12
- package/corpus/core/src/deploy/build.ts +176 -41
- package/corpus/templates/analytics/actions/get-sql-dashboard.ts +0 -8
- package/corpus/templates/analytics/actions/update-dashboard.ts +3 -1
- package/corpus/templates/analytics/app/components/SqlEditor.tsx +57 -0
- package/corpus/templates/analytics/app/components/SqlHighlight.tsx +27 -28
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +130 -8
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/ViewSqlPopover.tsx +4 -4
- package/corpus/templates/analytics/changelog/2026-06-24-active-user-dashboard-panels-classify-templates-more-accurat.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-24-dashboard-panel-deletes-now-stay-deleted-after-refresh.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-24-sql-panel-editors-now-highlight-query-syntax-and-keep-one-cl.md +6 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +2 -2
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +9 -11
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -20
- package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +45 -33
- package/corpus/templates/plan/changelog/2026-06-24-invalid-generated-recap-blocks-now-show-a-clear-warning-with.md +6 -0
- package/corpus/templates/plan/server/plan-content.ts +53 -3
- package/dist/agent/durable-background.d.ts +49 -0
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +51 -0
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +15 -3
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.js +21 -10
- package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
- package/dist/deploy/build.d.ts +61 -21
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +164 -41
- package/dist/deploy/build.js.map +1 -1
- 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
|
|
276
|
-
sql,
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
<
|
|
286
|
-
{
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
/>
|
|
@@ -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
|
|
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, ${
|
|
146
|
-
const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${
|
|
147
|
-
const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${
|
|
148
|
-
const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${
|
|
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
|
|
2379
|
+
const addPropertySearchInputRef = useRef<HTMLInputElement>(null);
|
|
2381
2380
|
|
|
2382
2381
|
useEffect(() => {
|
|
2383
2382
|
if (!open) return;
|
|
2384
2383
|
const frame = requestAnimationFrame(() => {
|
|
2385
|
-
|
|
2386
|
-
|
|
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:
|
|
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
|
|
47
|
-
*
|
|
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
|
|
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-
|
|
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
|
-
<
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
</
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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>}
|