@agent-native/toolkit 0.16.2 → 0.16.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 (33) hide show
  1. package/agent-native.eject.json +1 -0
  2. package/dist/composer/ComposerPlusMenu.d.ts +10 -3
  3. package/dist/composer/ComposerPlusMenu.d.ts.map +1 -1
  4. package/dist/composer/ComposerPlusMenu.js +23 -4
  5. package/dist/composer/ComposerPlusMenu.js.map +1 -1
  6. package/dist/composer/PromptComposer.d.ts +8 -1
  7. package/dist/composer/PromptComposer.d.ts.map +1 -1
  8. package/dist/composer/PromptComposer.js +8 -3
  9. package/dist/composer/PromptComposer.js.map +1 -1
  10. package/dist/composer/TiptapComposer.d.ts +9 -2
  11. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  12. package/dist/composer/TiptapComposer.js +17 -15
  13. package/dist/composer/TiptapComposer.js.map +1 -1
  14. package/dist/composer/index.d.ts +1 -0
  15. package/dist/composer/index.d.ts.map +1 -1
  16. package/dist/composer/index.js.map +1 -1
  17. package/dist/editor/extensions.d.ts.map +1 -1
  18. package/dist/editor/extensions.js +41 -1
  19. package/dist/editor/extensions.js.map +1 -1
  20. package/dist/editor/useCollabReconcile.concurrent.spec.js +28 -1
  21. package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
  22. package/dist/editor/useCollabReconcile.d.ts +8 -1
  23. package/dist/editor/useCollabReconcile.d.ts.map +1 -1
  24. package/dist/editor/useCollabReconcile.js +8 -3
  25. package/dist/editor/useCollabReconcile.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/composer/ComposerPlusMenu.tsx +87 -4
  28. package/src/composer/PromptComposer.tsx +24 -3
  29. package/src/composer/TiptapComposer.tsx +60 -27
  30. package/src/composer/index.ts +1 -0
  31. package/src/editor/extensions.ts +53 -1
  32. package/src/editor/useCollabReconcile.concurrent.spec.ts +40 -1
  33. package/src/editor/useCollabReconcile.ts +16 -2
@@ -36,6 +36,7 @@ import {
36
36
  PROMPT_DOCUMENT_ATTACHMENT_ACCEPT,
37
37
  TextAttachmentAdapter,
38
38
  } from "./attachment-accept.js";
39
+ import type { ComposerTerminalModeControl } from "./ComposerPlusMenu.js";
39
40
  import { isPastedTextAttachmentName } from "./pasted-text.js";
40
41
  import { PastedTextChip } from "./PastedTextChip.js";
41
42
  import { escapePromptAttachmentAttribute } from "./prompt-attachments.js";
@@ -85,6 +86,8 @@ export interface PromptComposerProps {
85
86
  ) => void | Promise<void>;
86
87
  placeholder?: string;
87
88
  disabled?: boolean;
89
+ /** Called when a host-gated composer is clicked while it is disabled. */
90
+ onDisabledClick?: () => void;
88
91
  /** Override the generic document attachment cap for a multipart host. */
89
92
  maxDocumentAttachmentBytes?: number;
90
93
  /** Label used in the visible document attachment limit error. */
@@ -112,7 +115,9 @@ export interface PromptComposerProps {
112
115
  * Controls the shared "+" affordance. Defaults to upload-only for standalone
113
116
  * prompt forms; chat surfaces can opt into the full sidebar menu.
114
117
  */
115
- plusMenuMode?: "full" | "upload-only" | "hidden";
118
+ plusMenuMode?: "full" | "upload-only" | "terminal" | "hidden";
119
+ /** Controls the terminal-specific plus menu when `plusMenuMode` is terminal. */
120
+ terminalModeControl?: ComposerTerminalModeControl;
116
121
  /**
117
122
  * Include extension creation in the full "+" menu. Defaults to false.
118
123
  */
@@ -156,6 +161,8 @@ export interface PromptComposerProps {
156
161
  availableAgents?: ComposerAgentOption[];
157
162
  /** Selected agent runtime identifier. */
158
163
  selectedAgent?: string;
164
+ /** Show only the selected agent in the model control. */
165
+ agentOnly?: boolean;
159
166
  /** Callback when the user picks an agent runtime. */
160
167
  onAgentChange?: (agent: string) => void;
161
168
  /** Called when the shared model picker opens or closes. */
@@ -491,6 +498,7 @@ function PromptComposerInner({
491
498
  onSubmit,
492
499
  placeholder,
493
500
  disabled,
501
+ onDisabledClick,
494
502
  maxDocumentAttachmentBytes,
495
503
  documentAttachmentLimitLabel,
496
504
  autoFocus,
@@ -506,6 +514,7 @@ function PromptComposerInner({
506
514
  voiceEnabled = DEFAULT_VOICE_DICTATION_ENABLED,
507
515
  attachmentsEnabled = true,
508
516
  plusMenuMode,
517
+ terminalModeControl,
509
518
  extensionTools = false,
510
519
  initialText,
511
520
  initialTextKey,
@@ -529,6 +538,7 @@ function PromptComposerInner({
529
538
  onEffortChange,
530
539
  availableAgents,
531
540
  selectedAgent,
541
+ agentOnly = false,
532
542
  onAgentChange,
533
543
  onModelSelectorOpenChange,
534
544
  modelStatusChecksEnabled,
@@ -643,12 +653,13 @@ function PromptComposerInner({
643
653
  <BuilderSetupCard
644
654
  onConnected={handleBuilderConnected}
645
655
  bouncePulse={missingKeyBouncePulse}
656
+ attached
646
657
  fullWidth
647
658
  layout="sidebar"
648
659
  />
649
660
  ) : null}
650
661
  {missingApiKey && useInlineMissingKeySetup && BuilderSetupContent ? (
651
- <div className="mb-2 rounded-md border border-border/80 bg-background/80 p-2.5 text-start shadow-sm">
662
+ <div className="agent-builder-setup-inline--attached mb-0 rounded-md border border-border/80 bg-background/80 p-2.5 text-start shadow-sm">
652
663
  <BuilderSetupContent
653
664
  onConnected={handleBuilderConnected}
654
665
  layout="sidebar"
@@ -659,13 +670,21 @@ function PromptComposerInner({
659
670
  className={cn(
660
671
  "text-start",
661
672
  gateComposer && "cursor-pointer",
673
+ (gateComposer || onDisabledClick) &&
674
+ "agent-composer-area--attached-above",
662
675
  className,
663
676
  )}
664
677
  rootClassName={rootClassName}
665
678
  style={style}
666
679
  rootStyle={rootStyle}
667
680
  layoutVariant={layoutVariant}
668
- onClick={gateComposer ? bounceMissingKeySetup : undefined}
681
+ onClick={
682
+ gateComposer
683
+ ? bounceMissingKeySetup
684
+ : onDisabledClick
685
+ ? () => onDisabledClick()
686
+ : undefined
687
+ }
669
688
  >
670
689
  <PromptAttachmentStrip />
671
690
  <TiptapComposer
@@ -687,6 +706,7 @@ function PromptComposerInner({
687
706
  plusMenuMode={
688
707
  plusMenuMode ?? (attachmentsEnabled ? "upload-only" : "hidden")
689
708
  }
709
+ terminalModeControl={terminalModeControl}
690
710
  extensionTools={extensionTools}
691
711
  attachButton={attachButton}
692
712
  modeControl={modeControl}
@@ -708,6 +728,7 @@ function PromptComposerInner({
708
728
  availableModels={composerModelGroups}
709
729
  availableAgents={availableAgents}
710
730
  selectedAgent={selectedAgent}
731
+ agentOnly={agentOnly}
711
732
  showAutoModelOption={showAutoModelOption}
712
733
  modelListLoading={composerModelListLoading}
713
734
  onModelChange={handleModelChange}
@@ -36,7 +36,10 @@ import {
36
36
  PopoverTrigger,
37
37
  } from "../ui/popover.js";
38
38
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
39
- import { ComposerPlusMenu } from "./ComposerPlusMenu.js";
39
+ import {
40
+ ComposerPlusMenu,
41
+ type ComposerTerminalModeControl,
42
+ } from "./ComposerPlusMenu.js";
40
43
  import { getComposerDraftKey } from "./draft-key.js";
41
44
  import { FileReference } from "./extensions/FileReference.js";
42
45
  import { MentionReference } from "./extensions/MentionReference.js";
@@ -668,6 +671,8 @@ export interface ComposerAgentOption {
668
671
  id: string;
669
672
  /** Human-readable runtime name shown in the picker. */
670
673
  label: string;
674
+ /** Optional icon shown beside the runtime name. */
675
+ icon?: React.ReactNode;
671
676
  /** Optional short detail shown below the runtime name. */
672
677
  description?: string;
673
678
  /** Whether this runtime can be selected right now. */
@@ -774,6 +779,8 @@ export interface TiptapComposerProps {
774
779
  availableAgents?: ComposerAgentOption[];
775
780
  /** Selected agent runtime identifier. Defaults to the built-in agent. */
776
781
  selectedAgent?: string;
782
+ /** Show only the selected agent in the model control. */
783
+ agentOnly?: boolean;
777
784
  /** Mark the selected runtime as the hosted tools-only harness mode. */
778
785
  hostedHarness?: boolean;
779
786
  /** Callback when the user picks an agent runtime. */
@@ -811,7 +818,9 @@ export interface TiptapComposerProps {
811
818
  * that opens the file picker directly. `"hidden"` hides attachment controls
812
819
  * for text-only prompt surfaces.
813
820
  */
814
- plusMenuMode?: "full" | "upload-only" | "hidden";
821
+ plusMenuMode?: "full" | "upload-only" | "terminal" | "hidden";
822
+ /** Controls the terminal-specific plus menu when `plusMenuMode` is terminal. */
823
+ terminalModeControl?: ComposerTerminalModeControl;
815
824
  /**
816
825
  * Include extension creation in the full "+" menu. Defaults to false so
817
826
  * apps opt into the extension capability deliberately.
@@ -1297,6 +1306,7 @@ function ModelSelector({
1297
1306
  engines,
1298
1307
  agents,
1299
1308
  selectedAgent,
1309
+ agentOnly = false,
1300
1310
  hostedHarness = false,
1301
1311
  showAutoModelOption = true,
1302
1312
  modelListLoading = false,
@@ -1323,6 +1333,7 @@ function ModelSelector({
1323
1333
  statusLabel?: string;
1324
1334
  isSubscription?: boolean;
1325
1335
  }>;
1336
+ agentOnly?: boolean;
1326
1337
  showAutoModelOption?: boolean;
1327
1338
  modelListLoading?: boolean;
1328
1339
  onChange: (model: string, engine: string) => void;
@@ -1411,9 +1422,10 @@ function ModelSelector({
1411
1422
  }
1412
1423
  onChange(preferredAgentModel.model, preferredAgentModel.engine);
1413
1424
  }, [engines, isClaudeCodeAgent, model, onChange, preferredAgentModel]);
1414
- const effortOptions =
1415
- reasoning?.getOptionsForModel?.(model) ??
1416
- getComposerReasoningEffortOptions(model);
1425
+ const effortOptions = agentOnly
1426
+ ? []
1427
+ : (reasoning?.getOptionsForModel?.(model) ??
1428
+ getComposerReasoningEffortOptions(model));
1417
1429
  const selectedEffort =
1418
1430
  reasoning?.resolve?.(model, effort) ??
1419
1431
  resolveReasoningEffortSelection(model, effort ?? defaultEffort);
@@ -1435,15 +1447,15 @@ function ModelSelector({
1435
1447
  const [detailSection, setDetailSection] = useState<
1436
1448
  "agent" | "model" | "effort" | null
1437
1449
  >(null);
1438
- const resolvedSection = detailSection ?? "model";
1450
+ const resolvedSection = detailSection ?? (agentOnly ? "agent" : "model");
1439
1451
 
1440
1452
  const setPickerOpen = useCallback(
1441
1453
  (nextOpen: boolean) => {
1442
1454
  if (controlledOpen === undefined) setInternalOpen(nextOpen);
1443
- if (nextOpen) setDetailSection(null);
1455
+ if (nextOpen) setDetailSection(agentOnly ? "agent" : null);
1444
1456
  onModelSelectorOpenChange?.(nextOpen);
1445
1457
  },
1446
- [controlledOpen, onModelSelectorOpenChange],
1458
+ [agentOnly, controlledOpen, onModelSelectorOpenChange],
1447
1459
  );
1448
1460
 
1449
1461
  const visibleProviderGroups = modelProviderGroups;
@@ -1518,6 +1530,11 @@ function ModelSelector({
1518
1530
  className="agent-composer-model-button flex min-w-0 max-w-[10.5rem] shrink items-center gap-1 rounded-md px-2 py-1 text-[12px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1519
1531
  >
1520
1532
  <span className="min-w-0 truncate">
1533
+ {selectedAgentOption?.icon ? (
1534
+ <span className="me-1 inline-flex shrink-0 align-[-2px] text-muted-foreground">
1535
+ {selectedAgentOption.icon}
1536
+ </span>
1537
+ ) : null}
1521
1538
  {selectedAgentOption && selectedAgentOption.id !== "default"
1522
1539
  ? selectedAgentLabel
1523
1540
  : compactComposerModelName(model, t)}
@@ -1613,25 +1630,29 @@ function ModelSelector({
1613
1630
  <IconChevronRight className="h-3 w-3 shrink-0 opacity-60 rtl:-scale-x-100" />
1614
1631
  </button>
1615
1632
  )}
1616
- <button
1617
- type="button"
1618
- role="tab"
1619
- aria-selected={resolvedSection === "model"}
1620
- onClick={() => setDetailSection("model")}
1621
- onMouseEnter={() => setDetailSection("model")}
1622
- className={`flex w-full min-w-0 items-center gap-1 rounded-md px-2 py-2 text-start transition-colors ${
1623
- resolvedSection === "model"
1624
- ? "bg-accent text-foreground"
1625
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1626
- }`}
1627
- >
1628
- <span className="shrink-0 text-[12px] font-medium">Model</span>
1629
- <span className="ms-auto min-w-0 max-w-[6rem] truncate text-end text-[11px] text-muted-foreground/80">
1630
- {selectedModelLabel}
1631
- </span>
1632
- <IconChevronRight className="h-3 w-3 shrink-0 opacity-60 rtl:-scale-x-100" />
1633
- </button>
1634
- {effortOptions.length > 0 && (
1633
+ {!agentOnly && (
1634
+ <button
1635
+ type="button"
1636
+ role="tab"
1637
+ aria-selected={resolvedSection === "model"}
1638
+ onClick={() => setDetailSection("model")}
1639
+ onMouseEnter={() => setDetailSection("model")}
1640
+ className={`flex w-full min-w-0 items-center gap-1 rounded-md px-2 py-2 text-start transition-colors ${
1641
+ resolvedSection === "model"
1642
+ ? "bg-accent text-foreground"
1643
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1644
+ }`}
1645
+ >
1646
+ <span className="shrink-0 text-[12px] font-medium">
1647
+ Model
1648
+ </span>
1649
+ <span className="ms-auto min-w-0 max-w-[6rem] truncate text-end text-[11px] text-muted-foreground/80">
1650
+ {selectedModelLabel}
1651
+ </span>
1652
+ <IconChevronRight className="h-3 w-3 shrink-0 opacity-60 rtl:-scale-x-100" />
1653
+ </button>
1654
+ )}
1655
+ {!agentOnly && effortOptions.length > 0 && (
1635
1656
  <button
1636
1657
  type="button"
1637
1658
  role="tab"
@@ -1703,6 +1724,14 @@ function ModelSelector({
1703
1724
  : "cursor-default opacity-50"
1704
1725
  }`}
1705
1726
  >
1727
+ {agent.icon ? (
1728
+ <span
1729
+ className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
1730
+ aria-hidden="true"
1731
+ >
1732
+ {agent.icon}
1733
+ </span>
1734
+ ) : null}
1706
1735
  <span
1707
1736
  className={`min-w-0 truncate text-[12px] ${
1708
1737
  isSelected
@@ -2134,6 +2163,7 @@ export function TiptapComposer({
2134
2163
  onEffortChange,
2135
2164
  availableAgents,
2136
2165
  selectedAgent,
2166
+ agentOnly = false,
2137
2167
  hostedHarness,
2138
2168
  onAgentChange,
2139
2169
  onModelSelectorOpenChange,
@@ -2145,6 +2175,7 @@ export function TiptapComposer({
2145
2175
  contextItems = [],
2146
2176
  onRemoveContextItem,
2147
2177
  plusMenuMode = "full",
2178
+ terminalModeControl,
2148
2179
  extensionTools = false,
2149
2180
  interceptBuildRequestsForBuilder = false,
2150
2181
  onAttachmentError,
@@ -3631,6 +3662,7 @@ export function TiptapComposer({
3631
3662
  <ComposerPlusMenu
3632
3663
  onSelectMode={handleSelectMode}
3633
3664
  mode={plusMenuMode}
3665
+ terminalModeControl={terminalModeControl}
3634
3666
  extensionTools={extensionTools}
3635
3667
  onAttachmentError={onAttachmentError}
3636
3668
  />
@@ -3645,6 +3677,7 @@ export function TiptapComposer({
3645
3677
  engines={availableModels}
3646
3678
  agents={availableAgents}
3647
3679
  selectedAgent={selectedAgent}
3680
+ agentOnly={agentOnly}
3648
3681
  hostedHarness={hostedHarness}
3649
3682
  showAutoModelOption={showAutoModelOption}
3650
3683
  modelListLoading={modelListLoading}
@@ -29,6 +29,7 @@ export {
29
29
  type PromptComposerFile,
30
30
  type PromptComposerSubmitOptions,
31
31
  } from "./PromptComposer.js";
32
+ export type { ComposerTerminalModeControl } from "./ComposerPlusMenu.js";
32
33
  export {
33
34
  RealtimeVoiceModeDock,
34
35
  RealtimeVoiceModeEntry,
@@ -50,6 +50,56 @@ import type { Doc as YDoc } from "yjs";
50
50
  import { createCodeBlockNode } from "./CodeBlockNode.js";
51
51
  import { createImageExtension, type ImageUploadFn } from "./ImageExtension.js";
52
52
 
53
+ interface YSyncBindingWithInitialRender {
54
+ beforeTransactionSelection: unknown | null;
55
+ _forceRerender: () => void;
56
+ }
57
+
58
+ const CollaborationWithSafeInitialSelection = Collaboration.extend({
59
+ addProseMirrorPlugins() {
60
+ const plugins = this.parent?.() ?? [];
61
+
62
+ for (const plugin of plugins) {
63
+ const originalView = plugin.spec.view;
64
+ if (!originalView) continue;
65
+
66
+ plugin.spec.view = (view) => {
67
+ const pluginState = plugin.getState(view.state) as
68
+ | { binding?: YSyncBindingWithInitialRender }
69
+ | undefined;
70
+ const binding = pluginState?.binding;
71
+ if (!binding || typeof binding._forceRerender !== "function") {
72
+ return originalView(view);
73
+ }
74
+
75
+ const originalForceRerender = binding._forceRerender.bind(binding);
76
+ binding._forceRerender = () => {
77
+ const previousSelection = binding.beforeTransactionSelection;
78
+ // y-prosemirror recreates its initial TextSelection at the same
79
+ // absolute position after replacing the whole document. That
80
+ // position can land inside a zero-child block. Let the replacement
81
+ // transaction map its existing selection instead; ProseMirror then
82
+ // chooses a valid nearby selection without warning.
83
+ binding.beforeTransactionSelection ??= view.state.selection;
84
+ try {
85
+ originalForceRerender();
86
+ } finally {
87
+ binding.beforeTransactionSelection = previousSelection;
88
+ }
89
+ };
90
+
91
+ try {
92
+ return originalView(view);
93
+ } finally {
94
+ binding._forceRerender = originalForceRerender;
95
+ }
96
+ };
97
+ }
98
+
99
+ return plugins;
100
+ },
101
+ });
102
+
53
103
  /**
54
104
  * Markdown dialect the editor parses/serializes.
55
105
  *
@@ -385,7 +435,9 @@ export function createSharedEditorExtensions({
385
435
  // saved representation (onChange serializes it); the Y.Doc is transient live
386
436
  // state only. Appended last so it binds over the configured schema above.
387
437
  if (ydoc) {
388
- exts.push(Collaboration.configure({ document: ydoc }));
438
+ exts.push(
439
+ CollaborationWithSafeInitialSelection.configure({ document: ydoc }),
440
+ );
389
441
  // Live multi-user cursors. Only mounted alongside a Y.Doc so the standalone
390
442
  // controlled editor (today's plan/content behavior) is untouched.
391
443
  if (awareness) {
@@ -48,6 +48,7 @@ afterEach(() => {
48
48
  interface HarnessProps {
49
49
  value: string;
50
50
  contentUpdatedAt: string;
51
+ editorOwnedFocus?: boolean;
51
52
  }
52
53
 
53
54
  interface CollabSeedHarnessProps {
@@ -65,7 +66,11 @@ interface Captured {
65
66
  function makeHarness() {
66
67
  const captured: Captured = { editor: null, emitted: [], setContentCalls: 0 };
67
68
 
68
- function Harness({ value, contentUpdatedAt }: HarnessProps) {
69
+ function Harness({
70
+ value,
71
+ contentUpdatedAt,
72
+ editorOwnedFocus = false,
73
+ }: HarnessProps) {
69
74
  const guardsRef = React.useRef<ReturnType<
70
75
  typeof useCollabReconcile
71
76
  > | null>(null);
@@ -88,6 +93,7 @@ function makeHarness() {
88
93
  value,
89
94
  contentUpdatedAt,
90
95
  editable: true,
96
+ isEditorFocused: () => editorOwnedFocus,
91
97
  getMarkdown: getEditorMarkdown,
92
98
  setContent: (ed, v, options) => {
93
99
  captured.setContentCalls += 1;
@@ -739,4 +745,37 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
739
745
 
740
746
  expect(getEditorMarkdown(captured.editor!)).toBe("# Doc updated by agent");
741
747
  });
748
+
749
+ it("does not reapply a partial save echo while an external editor control owns focus", async () => {
750
+ const { captured, Harness } = makeHarness();
751
+
752
+ render(root, Harness, {
753
+ value: "# abcdef",
754
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
755
+ editorOwnedFocus: true,
756
+ });
757
+ await flush();
758
+
759
+ act(() => captured.editor!.commands.setContent("# abcde"));
760
+ act(() => captured.editor!.commands.setContent("# ab"));
761
+ expect(getEditorMarkdown(captured.editor!)).toBe("# ab");
762
+
763
+ render(root, Harness, {
764
+ value: "# abcde",
765
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
766
+ editorOwnedFocus: true,
767
+ });
768
+ await flush();
769
+
770
+ expect(getEditorMarkdown(captured.editor!)).toBe("# ab");
771
+
772
+ render(root, Harness, {
773
+ value: "# updated externally",
774
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
775
+ editorOwnedFocus: false,
776
+ });
777
+ await flush();
778
+
779
+ expect(getEditorMarkdown(captured.editor!)).toBe("# updated externally");
780
+ });
742
781
  });
@@ -62,6 +62,13 @@ export interface UseCollabReconcileOptions {
62
62
  contentUpdatedAt?: string | null;
63
63
  /** Whether the editor accepts edits. Reconcile/seed only run for the live editor. */
64
64
  editable: boolean;
65
+ /**
66
+ * Reports whether focus is inside any editing surface owned by this editor.
67
+ * Defaults to TipTap's contenteditable focus. Apps with editable NodeView
68
+ * controls outside the contenteditable surface can include those controls so
69
+ * a partial save echo cannot interrupt an active edit.
70
+ */
71
+ isEditorFocused?: (editor: Editor) => boolean;
65
72
  /**
66
73
  * Reads the current markdown from the editor. Injected so a dialect could
67
74
  * swap serializers; defaults to the tiptap-markdown storage reader. For an app
@@ -216,6 +223,10 @@ function defaultSetContent(
216
223
  editor.commands.setContent(value);
217
224
  }
218
225
 
226
+ function defaultIsEditorFocused(editor: Editor): boolean {
227
+ return editor.isFocused;
228
+ }
229
+
219
230
  export function useCollabReconcile({
220
231
  editor,
221
232
  ydoc = null,
@@ -224,6 +235,7 @@ export function useCollabReconcile({
224
235
  value,
225
236
  contentUpdatedAt,
226
237
  editable,
238
+ isEditorFocused = defaultIsEditorFocused,
227
239
  getMarkdown = getEditorMarkdown,
228
240
  setContent = defaultSetContent,
229
241
  parseValue,
@@ -452,8 +464,9 @@ export function useCollabReconcile({
452
464
  const editorUnchangedSinceApply =
453
465
  lastAppliedSerializedRef.current !== null &&
454
466
  currentMarkdown === lastAppliedSerializedRef.current;
467
+ const editorFocused = isEditorFocused(editor);
455
468
  const typingRecently =
456
- editor.isFocused && Date.now() - lastTypedAtRef.current < 1500;
469
+ editorFocused && Date.now() - lastTypedAtRef.current < 1500;
457
470
 
458
471
  // Doc-equivalence skip. Never re-apply content the editor already
459
472
  // represents — comparing by DOC EQUIVALENCE, not raw strings/timestamps:
@@ -531,7 +544,7 @@ export function useCollabReconcile({
531
544
  // `lastAppliedSerializedRef` so the very first seed (nothing applied yet,
532
545
  // also not-newer) still lands.
533
546
  const seeded = lastAppliedSerializedRef.current !== null;
534
- if (!externalNewer && (editor.isFocused || (!collab && seeded))) return;
547
+ if (!externalNewer && (editorFocused || (!collab && seeded))) return;
535
548
 
536
549
  // Race guard: with peers present, let Yjs deliver a peer's edit first.
537
550
  // Defer once and re-check — a peer edit makes the equality check above
@@ -616,6 +629,7 @@ export function useCollabReconcile({
616
629
  setContent,
617
630
  parseValue,
618
631
  normalizeValue,
632
+ isEditorFocused,
619
633
  ]);
620
634
 
621
635
  const shouldIgnoreUpdate = (transaction: Transaction): boolean => {