@agent-native/toolkit 0.16.10 → 0.16.12

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 (56) hide show
  1. package/dist/collab-ui/lead-client.d.ts.map +1 -1
  2. package/dist/collab-ui/lead-client.js +11 -0
  3. package/dist/collab-ui/lead-client.js.map +1 -1
  4. package/dist/collab-ui/lead-client.spec.d.ts +2 -0
  5. package/dist/collab-ui/lead-client.spec.d.ts.map +1 -0
  6. package/dist/collab-ui/lead-client.spec.js +16 -0
  7. package/dist/collab-ui/lead-client.spec.js.map +1 -0
  8. package/dist/composer/TiptapComposer.d.ts +12 -0
  9. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  10. package/dist/composer/TiptapComposer.js +59 -18
  11. package/dist/composer/TiptapComposer.js.map +1 -1
  12. package/dist/composer/TiptapComposer.spec.js +31 -1
  13. package/dist/composer/TiptapComposer.spec.js.map +1 -1
  14. package/dist/design-tweaks/scrub-input.d.ts.map +1 -1
  15. package/dist/design-tweaks/scrub-input.js +3 -2
  16. package/dist/design-tweaks/scrub-input.js.map +1 -1
  17. package/dist/design-tweaks/visual-style-controls.spec.js +19 -0
  18. package/dist/design-tweaks/visual-style-controls.spec.js.map +1 -1
  19. package/dist/editor/RegistryBlockContext.d.ts +4 -0
  20. package/dist/editor/RegistryBlockContext.d.ts.map +1 -1
  21. package/dist/editor/RegistryBlockContext.js.map +1 -1
  22. package/dist/editor/RegistryBlockNode.d.ts +13 -0
  23. package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
  24. package/dist/editor/RegistryBlockNode.js +47 -20
  25. package/dist/editor/RegistryBlockNode.js.map +1 -1
  26. package/dist/editor/RegistryBlockNode.spec.js +61 -1
  27. package/dist/editor/RegistryBlockNode.spec.js.map +1 -1
  28. package/dist/editor/useCollabReconcile.concurrent.spec.js +63 -0
  29. package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
  30. package/dist/editor/useCollabReconcile.d.ts.map +1 -1
  31. package/dist/editor/useCollabReconcile.js +15 -2
  32. package/dist/editor/useCollabReconcile.js.map +1 -1
  33. package/dist/ui/index.d.ts +1 -0
  34. package/dist/ui/index.d.ts.map +1 -1
  35. package/dist/ui/index.js +1 -0
  36. package/dist/ui/index.js.map +1 -1
  37. package/dist/ui/pointer-lock.d.ts +2 -0
  38. package/dist/ui/pointer-lock.d.ts.map +1 -0
  39. package/dist/ui/pointer-lock.js +22 -0
  40. package/dist/ui/pointer-lock.js.map +1 -0
  41. package/dist/ui/tooltip.js +1 -1
  42. package/package.json +1 -1
  43. package/src/collab-ui/lead-client.spec.ts +20 -0
  44. package/src/collab-ui/lead-client.ts +17 -1
  45. package/src/composer/TiptapComposer.spec.ts +40 -0
  46. package/src/composer/TiptapComposer.tsx +182 -75
  47. package/src/design-tweaks/scrub-input.tsx +3 -2
  48. package/src/design-tweaks/visual-style-controls.spec.tsx +37 -0
  49. package/src/editor/RegistryBlockContext.tsx +4 -0
  50. package/src/editor/RegistryBlockNode.spec.ts +78 -0
  51. package/src/editor/RegistryBlockNode.tsx +106 -19
  52. package/src/editor/useCollabReconcile.concurrent.spec.ts +75 -0
  53. package/src/editor/useCollabReconcile.ts +19 -2
  54. package/src/ui/index.ts +1 -0
  55. package/src/ui/pointer-lock.ts +21 -0
  56. package/src/ui/tooltip.tsx +1 -1
@@ -1024,6 +1024,14 @@ const LOCAL_RUNTIME_ENGINES = new Set([
1024
1024
  "opencode-cli",
1025
1025
  ]);
1026
1026
 
1027
+ export function hasConfiguredCloudProvider(
1028
+ groups: ReadonlyArray<{ engine: string; configured: boolean }>,
1029
+ ): boolean {
1030
+ return groups.some(
1031
+ (group) => group.configured && !LOCAL_RUNTIME_ENGINES.has(group.engine),
1032
+ );
1033
+ }
1034
+
1027
1035
  function isOpenAiModelId(model: string): boolean {
1028
1036
  const normalizedModel = model.toLowerCase();
1029
1037
  return (
@@ -1082,6 +1090,22 @@ export function shouldShowOnlyConnectPath(
1082
1090
  return showBuilderCta && groups.every((group) => !group.configured);
1083
1091
  }
1084
1092
 
1093
+ /**
1094
+ * When nothing is routable yet, the model hook resolves `selectedModel` to
1095
+ * `""` rather than pre-selecting something unusable — that reflects "nothing
1096
+ * chosen," not "nothing to show." The picker itself still has a job to do in
1097
+ * that state (its connect-provider CTAs), so gate on there being engines to
1098
+ * list and a way to change the selection, not on a model already being set.
1099
+ */
1100
+ export function shouldRenderModelSelector(
1101
+ availableModels: ReadonlyArray<unknown> | undefined,
1102
+ onModelChange: unknown,
1103
+ ): boolean {
1104
+ return Boolean(
1105
+ availableModels && availableModels.length > 0 && onModelChange,
1106
+ );
1107
+ }
1108
+
1085
1109
  function friendlyModelName(model: string, t?: ComposerTranslate): string {
1086
1110
  if (model === "auto") {
1087
1111
  return (
@@ -1320,6 +1344,7 @@ function ModelSelector({
1320
1344
  providerConnectStatusEnabled = true,
1321
1345
  onConnectProvider,
1322
1346
  onConnectLocalRuntime,
1347
+ terminalModeControl,
1323
1348
  imageModel,
1324
1349
  }: {
1325
1350
  model: string;
@@ -1344,6 +1369,7 @@ function ModelSelector({
1344
1369
  providerConnectStatusEnabled?: boolean;
1345
1370
  onConnectProvider?: () => void;
1346
1371
  onConnectLocalRuntime?: (engine: string) => void;
1372
+ terminalModeControl?: ComposerTerminalModeControl;
1347
1373
  onModelSelectorOpenChange?: (open: boolean) => void;
1348
1374
  imageModel?: ComposerImageModelMenu;
1349
1375
  open?: boolean;
@@ -1447,7 +1473,7 @@ function ModelSelector({
1447
1473
  const selectedModelLabel = friendlyModelName(model, t).replace(/^GPT-/, "");
1448
1474
 
1449
1475
  const [detailSection, setDetailSection] = useState<
1450
- "agent" | "model" | "effort" | null
1476
+ "agent" | "model" | "effort" | "mode" | null
1451
1477
  >(null);
1452
1478
  const resolvedSection = detailSection ?? (agentOnly ? "agent" : "model");
1453
1479
 
@@ -1466,9 +1492,8 @@ function ModelSelector({
1466
1492
  engines.length,
1467
1493
  );
1468
1494
 
1469
- // Keep setup actions beside model choices while any visible provider still
1470
- // needs configuration. The model rows remain visible so the user can see
1471
- // what becomes available after connecting or adding a key.
1495
+ // Keep setup actions visible, but do not show unusable model rows until one
1496
+ // provider or local agent is ready.
1472
1497
  const builderFlow = adapters.builder!.useConnectFlow!({
1473
1498
  enabled: providerConnectStatusEnabled,
1474
1499
  trackingSource: "composer_builder_cta",
@@ -1476,25 +1501,37 @@ function ModelSelector({
1476
1501
  const hasConfiguredBuilderModels = providerGroups.some(
1477
1502
  (group) => group.engine === "builder" && group.configured,
1478
1503
  );
1504
+ const hasConfiguredCloudProviderReady =
1505
+ hasConfiguredCloudProvider(providerGroups);
1479
1506
  const hasConnectedSubscription = providerGroups.some(
1480
- (group) => group.configured && group.isSubscription,
1507
+ (group) =>
1508
+ hasConfiguredCloudProviderReady &&
1509
+ group.configured &&
1510
+ group.isSubscription &&
1511
+ !LOCAL_RUNTIME_ENGINES.has(group.engine),
1481
1512
  );
1482
1513
  const hasUnconfiguredVisibleModels = modelProviderGroups.some(
1483
1514
  (group) => !group.configured,
1484
1515
  );
1516
+ const hasConfiguredProvider = providerGroups.some(
1517
+ (group) => group.configured,
1518
+ );
1485
1519
  const showBuilderAction =
1486
- hasUnconfiguredVisibleModels &&
1520
+ !hasConfiguredCloudProviderReady &&
1487
1521
  (Boolean(onConnectProvider) ||
1488
1522
  (providerConnectStatusEnabled &&
1489
1523
  !builderFlow.configured &&
1490
1524
  !builderFlow.envManaged &&
1491
1525
  !hasConfiguredBuilderModels &&
1492
1526
  !hasConnectedSubscription));
1493
- const showAddKeysAction = hasUnconfiguredVisibleModels;
1527
+ const showAddKeysAction =
1528
+ !hasConfiguredCloudProviderReady &&
1529
+ (hasUnconfiguredVisibleModels || showBuilderAction);
1494
1530
  const showProviderActions = showBuilderAction || showAddKeysAction;
1495
- const onlyConnectPathAvailable =
1496
- shouldShowOnlyConnectPath(showBuilderAction, providerGroups) &&
1497
- modelProviderGroups.length === 0;
1531
+ const onlyConnectPathAvailable = shouldShowOnlyConnectPath(
1532
+ showProviderActions,
1533
+ providerGroups,
1534
+ );
1498
1535
  const openLlmSettings = useCallback(() => {
1499
1536
  try {
1500
1537
  window.location.hash = "llm";
@@ -1601,7 +1638,7 @@ function ModelSelector({
1601
1638
  />
1602
1639
  </span>
1603
1640
  </TooltipTrigger>
1604
- <TooltipContent side="right" className="z-[400] max-w-xs">
1641
+ <TooltipContent side="right" className="max-w-xs">
1605
1642
  <span className="block">
1606
1643
  {hostedHarness
1607
1644
  ? t("agentChat.composer.hostedHarnessDescription", {
@@ -1678,6 +1715,30 @@ function ModelSelector({
1678
1715
  <IconChevronRight className="h-3 w-3 shrink-0 opacity-60 rtl:-scale-x-100" />
1679
1716
  </button>
1680
1717
  )}
1718
+ {terminalModeControl && (
1719
+ <button
1720
+ type="button"
1721
+ role="tab"
1722
+ aria-selected={resolvedSection === "mode"}
1723
+ onClick={() => setDetailSection("mode")}
1724
+ onMouseEnter={() => setDetailSection("mode")}
1725
+ className={`flex w-full min-w-0 items-center gap-1 rounded-md px-2 py-2 text-start transition-colors ${
1726
+ resolvedSection === "mode"
1727
+ ? "bg-accent text-foreground"
1728
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1729
+ }`}
1730
+ >
1731
+ <span className="shrink-0 text-[12px] font-medium">
1732
+ {t("agentPanel.mode", { defaultValue: "Mode" })}
1733
+ </span>
1734
+ <span className="ms-auto min-w-0 max-w-[6rem] truncate text-end text-[11px] text-muted-foreground/80">
1735
+ {terminalModeControl.enabled
1736
+ ? t("agentPanel.cli", { defaultValue: "CLI" })
1737
+ : t("agentPanel.uiMode", { defaultValue: "UI" })}
1738
+ </span>
1739
+ <IconChevronRight className="h-3 w-3 shrink-0 opacity-60 rtl:-scale-x-100" />
1740
+ </button>
1741
+ )}
1681
1742
  </div>
1682
1743
  </PopoverAnchor>
1683
1744
  <PopoverContent
@@ -1761,7 +1822,7 @@ function ModelSelector({
1761
1822
  </TooltipTrigger>
1762
1823
  <TooltipContent
1763
1824
  side="left"
1764
- className="z-[400] max-w-xs"
1825
+ className="max-w-xs"
1765
1826
  >
1766
1827
  {agent.description}
1767
1828
  </TooltipContent>
@@ -1865,42 +1926,47 @@ function ModelSelector({
1865
1926
  )}
1866
1927
  </>
1867
1928
  )}
1868
- {imageModel && imageModel.options.length > 0 && (
1869
- <div className="mt-2 pt-1">
1870
- <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1871
- {imageModel.label ??
1872
- t("agentChat.composer.imageModel", {
1873
- defaultValue: "Image model",
1874
- })}
1875
- </div>
1876
- {imageModel.options.map((option) => (
1877
- <button
1878
- key={option.value}
1879
- type="button"
1880
- onClick={() => {
1881
- imageModel.onChange(option.value);
1882
- setPickerOpen(false);
1883
- }}
1884
- className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-start hover:bg-accent/50"
1885
- >
1886
- <span
1887
- className={`min-w-0 flex-1 truncate text-[12px] ${
1888
- option.value === imageModel.value
1889
- ? "text-foreground"
1890
- : "text-muted-foreground"
1891
- }`}
1929
+ {hasConfiguredCloudProviderReady &&
1930
+ imageModel &&
1931
+ imageModel.options.length > 0 && (
1932
+ <div className="mt-2 pt-1">
1933
+ <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1934
+ {imageModel.label ??
1935
+ t("agentChat.composer.imageModel", {
1936
+ defaultValue: "Image model",
1937
+ })}
1938
+ </div>
1939
+ {imageModel.options.map((option) => (
1940
+ <button
1941
+ key={option.value}
1942
+ type="button"
1943
+ onClick={() => {
1944
+ imageModel.onChange(option.value);
1945
+ setPickerOpen(false);
1946
+ }}
1947
+ className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-start hover:bg-accent/50"
1892
1948
  >
1893
- {option.label}
1894
- </span>
1895
- {option.value === imageModel.value && (
1896
- <IconCheck className="size-4 shrink-0 text-primary" />
1897
- )}
1898
- </button>
1899
- ))}
1900
- </div>
1949
+ <span
1950
+ className={`min-w-0 flex-1 truncate text-[12px] ${
1951
+ option.value === imageModel.value
1952
+ ? "text-foreground"
1953
+ : "text-muted-foreground"
1954
+ }`}
1955
+ >
1956
+ {option.label}
1957
+ </span>
1958
+ {option.value === imageModel.value && (
1959
+ <IconCheck className="size-4 shrink-0 text-primary" />
1960
+ )}
1961
+ </button>
1962
+ ))}
1963
+ </div>
1964
+ )}
1965
+ {hasConfiguredProvider && showModelListSkeleton && (
1966
+ <ModelSelectorSkeleton />
1901
1967
  )}
1902
- {showModelListSkeleton && <ModelSelectorSkeleton />}
1903
- {isCodexAgent &&
1968
+ {hasConfiguredProvider &&
1969
+ isCodexAgent &&
1904
1970
  !showModelListSkeleton &&
1905
1971
  !onlyConnectPathAvailable &&
1906
1972
  modelProviderGroups.length === 0 && (
@@ -1919,32 +1985,35 @@ function ModelSelector({
1919
1985
  </span>
1920
1986
  </button>
1921
1987
  )}
1922
- {autoModelGroup && !onlyConnectPathAvailable && (
1923
- <button
1924
- type="button"
1925
- onClick={() => {
1926
- onChange("auto", autoModelGroup.engine);
1927
- setPickerOpen(false);
1928
- }}
1929
- className="mt-1 flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-start hover:bg-accent/50"
1930
- >
1931
- <span
1932
- className={`min-w-0 flex-1 truncate text-[13px] ${
1933
- model === "auto"
1934
- ? "text-foreground"
1935
- : "text-muted-foreground"
1936
- }`}
1988
+ {hasConfiguredProvider &&
1989
+ autoModelGroup &&
1990
+ !onlyConnectPathAvailable && (
1991
+ <button
1992
+ type="button"
1993
+ onClick={() => {
1994
+ onChange("auto", autoModelGroup.engine);
1995
+ setPickerOpen(false);
1996
+ }}
1997
+ className="mt-1 flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-start hover:bg-accent/50"
1937
1998
  >
1938
- {t("agentChat.composer.auto", {
1939
- defaultValue: "Auto",
1940
- })}
1941
- </span>
1942
- {model === "auto" && (
1943
- <IconCheck className="size-4 shrink-0 text-primary" />
1944
- )}
1945
- </button>
1946
- )}
1947
- {!onlyConnectPathAvailable &&
1999
+ <span
2000
+ className={`min-w-0 flex-1 truncate text-[13px] ${
2001
+ model === "auto"
2002
+ ? "text-foreground"
2003
+ : "text-muted-foreground"
2004
+ }`}
2005
+ >
2006
+ {t("agentChat.composer.auto", {
2007
+ defaultValue: "Auto",
2008
+ })}
2009
+ </span>
2010
+ {model === "auto" && (
2011
+ <IconCheck className="size-4 shrink-0 text-primary" />
2012
+ )}
2013
+ </button>
2014
+ )}
2015
+ {hasConfiguredProvider &&
2016
+ !onlyConnectPathAvailable &&
1948
2017
  visibleProviderGroups.map((group, groupIndex) => {
1949
2018
  const models = latestModelsOnly(group.models);
1950
2019
  const showProviderLabels =
@@ -2084,6 +2153,43 @@ function ModelSelector({
2084
2153
  ))}
2085
2154
  </div>
2086
2155
  )}
2156
+ {resolvedSection === "mode" && terminalModeControl && (
2157
+ <div className="flex flex-col">
2158
+ {[
2159
+ {
2160
+ enabled: false,
2161
+ label: t("agentPanel.uiMode", { defaultValue: "UI" }),
2162
+ },
2163
+ {
2164
+ enabled: true,
2165
+ label: t("agentPanel.cli", { defaultValue: "CLI" }),
2166
+ },
2167
+ ].map((option) => (
2168
+ <button
2169
+ key={option.label}
2170
+ type="button"
2171
+ onClick={() => {
2172
+ terminalModeControl.onChange(option.enabled);
2173
+ setPickerOpen(false);
2174
+ }}
2175
+ className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-start hover:bg-accent/50"
2176
+ >
2177
+ <span
2178
+ className={`min-w-0 flex-1 truncate text-[12px] ${
2179
+ option.enabled === terminalModeControl.enabled
2180
+ ? "text-foreground"
2181
+ : "text-muted-foreground"
2182
+ }`}
2183
+ >
2184
+ {option.label}
2185
+ </span>
2186
+ {option.enabled === terminalModeControl.enabled && (
2187
+ <IconCheck className="size-4 shrink-0 text-primary" />
2188
+ )}
2189
+ </button>
2190
+ ))}
2191
+ </div>
2192
+ )}
2087
2193
  </div>
2088
2194
  </div>
2089
2195
  </PopoverContent>
@@ -3679,25 +3785,26 @@ export function TiptapComposer({
3679
3785
  ))}
3680
3786
  {toolbarSlot ?? modeControl}
3681
3787
  <div data-agent-composer-slot="toolbar-spacer" className="flex-1" />
3682
- {selectedModel && availableModels && onModelChange && (
3788
+ {shouldRenderModelSelector(availableModels, onModelChange) && (
3683
3789
  <ModelSelector
3684
- model={selectedModel}
3790
+ model={selectedModel ?? ""}
3685
3791
  open={modelSelectorOpen}
3686
3792
  effort={selectedEffort}
3687
- engines={availableModels}
3793
+ engines={availableModels!}
3688
3794
  agents={availableAgents}
3689
3795
  selectedAgent={selectedAgent}
3690
3796
  agentOnly={agentOnly}
3691
3797
  hostedHarness={hostedHarness}
3692
3798
  showAutoModelOption={showAutoModelOption}
3693
3799
  modelListLoading={modelListLoading}
3694
- onChange={onModelChange}
3800
+ onChange={onModelChange!}
3695
3801
  onEffortChange={onEffortChange}
3696
3802
  onAgentChange={onAgentChange}
3697
3803
  onModelSelectorOpenChange={onModelSelectorOpenChange}
3698
3804
  providerConnectStatusEnabled={providerConnectStatusEnabled}
3699
3805
  onConnectProvider={onConnectProvider}
3700
3806
  onConnectLocalRuntime={onConnectLocalRuntime}
3807
+ terminalModeControl={terminalModeControl}
3701
3808
  imageModel={imageModelMenu}
3702
3809
  />
3703
3810
  )}
@@ -272,8 +272,9 @@ export function VisualScrubInput({
272
272
  if (event.key === "Enter") {
273
273
  event.preventDefault();
274
274
  commitDraft();
275
- skipNextBlurCommitRef.current = true;
276
- event.currentTarget.blur();
275
+ // Blurring here would hand the next keystroke to whatever global
276
+ // shortcut owns that key while the user is still typing at the field.
277
+ event.currentTarget.select();
277
278
  return;
278
279
  }
279
280
 
@@ -91,6 +91,43 @@ describe("visual style controls", () => {
91
91
  expect(input?.value).toBe("24px");
92
92
  });
93
93
 
94
+ it("commits on Enter and keeps focus so the next keystroke stays in the field", () => {
95
+ const onChange = vi.fn();
96
+ act(() => {
97
+ root.render(
98
+ <VisualScrubInput
99
+ label="Weight"
100
+ value={1}
101
+ unit="px"
102
+ onChange={onChange}
103
+ />,
104
+ );
105
+ });
106
+
107
+ const input = container.querySelector<HTMLInputElement>("input")!;
108
+ act(() => input.focus());
109
+ act(() => {
110
+ const setter = Object.getOwnPropertyDescriptor(
111
+ HTMLInputElement.prototype,
112
+ "value",
113
+ )!.set!;
114
+ setter.call(input, "2");
115
+ input.dispatchEvent(new Event("input", { bubbles: true }));
116
+ });
117
+ act(() => {
118
+ input.dispatchEvent(
119
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
120
+ );
121
+ });
122
+
123
+ expect(onChange).toHaveBeenCalledWith(
124
+ 2,
125
+ expect.objectContaining({ source: "commit" }),
126
+ );
127
+ // Blurring here would hand the next keystroke to a global shortcut.
128
+ expect(document.activeElement).toBe(input);
129
+ });
130
+
94
131
  it("supports a compact icon prefix without removing the accessible label", () => {
95
132
  const TestIcon = ({
96
133
  className,
@@ -5,6 +5,10 @@ export interface RegistryBlockSideMapBlock {
5
5
  title?: string;
6
6
  summary?: string;
7
7
  data: unknown;
8
+ loadError?: {
9
+ message: string;
10
+ rawSource?: string;
11
+ };
8
12
  }
9
13
 
10
14
  export interface RegistryBlockNestedBlock {
@@ -6,14 +6,38 @@ import { NodeSelection } from "@tiptap/pm/state";
6
6
  import StarterKit from "@tiptap/starter-kit";
7
7
  import React, { act } from "react";
8
8
  import { createRoot, type Root } from "react-dom/client";
9
+ import { renderToStaticMarkup } from "react-dom/server";
9
10
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
11
 
11
12
  import {
12
13
  createRegistryBlockNode,
13
14
  LegacyJsonEditSurface,
15
+ RegistryBlockLoadErrorView,
16
+ selectRegistryBlockNode,
14
17
  type RegistryBlockSideMapBlock,
15
18
  } from "./RegistryBlockNode.js";
16
19
 
20
+ describe("RegistryBlockLoadErrorView", () => {
21
+ it("preserves unreadable source and reports a terminal load error", () => {
22
+ const rawSource = '<Mermaid id="broken" source={nope} />';
23
+ const markup = renderToStaticMarkup(
24
+ React.createElement(RegistryBlockLoadErrorView, {
25
+ message:
26
+ "Could not load mermaid block: Persisted block source is unreadable.",
27
+ rawSource,
28
+ }),
29
+ );
30
+
31
+ expect(markup).toContain(
32
+ "&lt;Mermaid id=&quot;broken&quot; source={nope} /&gt;",
33
+ );
34
+ expect(markup).toContain(
35
+ "Could not load mermaid block: Persisted block source is unreadable.",
36
+ );
37
+ expect(markup).not.toContain("Loading mermaid block");
38
+ });
39
+ });
40
+
17
41
  const PlanBlockNode = createRegistryBlockNode({
18
42
  nodeName: "planBlock",
19
43
  dataTag: "data-plan-block",
@@ -95,6 +119,60 @@ afterEach(() => {
95
119
  });
96
120
 
97
121
  describe("RegistryBlockNode keyboard guard", () => {
122
+ it("selects and removes an errored registry atom from its interactive error surface", () => {
123
+ const editor = createEditor();
124
+ const target = document.createElement("div");
125
+ target.setAttribute("data-plan-interactive", "true");
126
+
127
+ try {
128
+ expect(
129
+ selectRegistryBlockNode({
130
+ editable: true,
131
+ allowInteractiveChild: true,
132
+ target,
133
+ getPos: () => findPlanBlockPos(editor),
134
+ view: editor.view,
135
+ preventDefault: vi.fn(),
136
+ stopPropagation: vi.fn(),
137
+ }),
138
+ ).toBe(true);
139
+ expect(editor.state.selection).toBeInstanceOf(NodeSelection);
140
+
141
+ expect(editor.commands.deleteSelection()).toBe(true);
142
+ expect(() => findPlanBlockPos(editor)).toThrow("Expected planBlock node");
143
+ } finally {
144
+ editor.destroy();
145
+ }
146
+ });
147
+
148
+ it("preserves native raw-source selection inside an errored registry atom", () => {
149
+ const editor = createEditor();
150
+ const errorSurface = document.createElement("div");
151
+ const rawSource = document.createElement("pre");
152
+ errorSurface.appendChild(rawSource);
153
+ const preventDefault = vi.fn();
154
+ const stopPropagation = vi.fn();
155
+
156
+ try {
157
+ expect(
158
+ selectRegistryBlockNode({
159
+ editable: true,
160
+ allowInteractiveChild: true,
161
+ target: rawSource,
162
+ getPos: () => findPlanBlockPos(editor),
163
+ view: editor.view,
164
+ preventDefault,
165
+ stopPropagation,
166
+ }),
167
+ ).toBe(false);
168
+ expect(preventDefault).not.toHaveBeenCalled();
169
+ expect(stopPropagation).not.toHaveBeenCalled();
170
+ expect(editor.state.selection).not.toBeInstanceOf(NodeSelection);
171
+ } finally {
172
+ editor.destroy();
173
+ }
174
+ });
175
+
98
176
  it("keeps selected registry atoms immutable for text entry and preserves undo history", () => {
99
177
  const editor = createEditor();
100
178