@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.
- package/agent-native.eject.json +1 -0
- package/dist/composer/ComposerPlusMenu.d.ts +10 -3
- package/dist/composer/ComposerPlusMenu.d.ts.map +1 -1
- package/dist/composer/ComposerPlusMenu.js +23 -4
- package/dist/composer/ComposerPlusMenu.js.map +1 -1
- package/dist/composer/PromptComposer.d.ts +8 -1
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +8 -3
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +9 -2
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +17 -15
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/index.d.ts +1 -0
- package/dist/composer/index.d.ts.map +1 -1
- package/dist/composer/index.js.map +1 -1
- package/dist/editor/extensions.d.ts.map +1 -1
- package/dist/editor/extensions.js +41 -1
- package/dist/editor/extensions.js.map +1 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js +28 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.d.ts +8 -1
- package/dist/editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/editor/useCollabReconcile.js +8 -3
- package/dist/editor/useCollabReconcile.js.map +1 -1
- package/package.json +1 -1
- package/src/composer/ComposerPlusMenu.tsx +87 -4
- package/src/composer/PromptComposer.tsx +24 -3
- package/src/composer/TiptapComposer.tsx +60 -27
- package/src/composer/index.ts +1 -0
- package/src/editor/extensions.ts +53 -1
- package/src/editor/useCollabReconcile.concurrent.spec.ts +40 -1
- 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-
|
|
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={
|
|
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 {
|
|
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
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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}
|
package/src/composer/index.ts
CHANGED
package/src/editor/extensions.ts
CHANGED
|
@@ -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(
|
|
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({
|
|
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
|
-
|
|
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 && (
|
|
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 => {
|