@agent-native/toolkit 0.210.2 → 0.211.0-nightly-20261020183616
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/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +46 -7
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
- package/dist/app/chat/AgentSidebar.js +5 -3
- package/dist/app/chat/AgentSidebar.js.map +1 -1
- package/dist/app/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/app/resources/McpIntegrationDialog.js +3 -2
- package/dist/app/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/app/review/ReviewCommentComposer.d.ts.map +1 -1
- package/dist/app/review/ReviewCommentComposer.js +21 -2
- package/dist/app/review/ReviewCommentComposer.js.map +1 -1
- package/dist/app/review/ReviewThreadPanel.d.ts.map +1 -1
- package/dist/app/review/ReviewThreadPanel.js +27 -5
- package/dist/app/review/ReviewThreadPanel.js.map +1 -1
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +23 -12
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/editor/DragHandle.d.ts +1 -0
- package/dist/editor/DragHandle.d.ts.map +1 -1
- package/dist/editor/DragHandle.js +13 -6
- package/dist/editor/DragHandle.js.map +1 -1
- package/package.json +4 -4
- package/src/app/agentkit/react/components.interaction.spec.tsx +85 -0
- package/src/app/agentkit/react/components.tsx +45 -8
- package/src/app/chat/AgentSidebar.spec.tsx +56 -6
- package/src/app/chat/AgentSidebar.tsx +7 -5
- package/src/app/resources/McpIntegrationDialog.spec.tsx +51 -1
- package/src/app/resources/McpIntegrationDialog.tsx +3 -2
- package/src/app/review/ReviewCommentComposer.tsx +18 -1
- package/src/app/review/ReviewThreadPanel.spec.tsx +170 -7
- package/src/app/review/ReviewThreadPanel.tsx +34 -4
- package/src/composer/PromptComposer.autofocus.spec.tsx +73 -0
- package/src/composer/PromptComposer.tsx +24 -14
- package/src/composer/TiptapComposer.spec.ts +6 -0
- package/src/editor/DragHandle.spec.ts +45 -0
- package/src/editor/DragHandle.ts +15 -6
|
@@ -25,7 +25,14 @@ const mocks = vi.hoisted(() => ({
|
|
|
25
25
|
org: [],
|
|
26
26
|
orgId: "org-builder",
|
|
27
27
|
role: "owner",
|
|
28
|
-
}
|
|
28
|
+
} as
|
|
29
|
+
| {
|
|
30
|
+
user: unknown[];
|
|
31
|
+
org: unknown[];
|
|
32
|
+
orgId: string | null;
|
|
33
|
+
role: string | null;
|
|
34
|
+
}
|
|
35
|
+
| undefined,
|
|
29
36
|
isSuccess: true,
|
|
30
37
|
isError: false,
|
|
31
38
|
error: null as Error | null,
|
|
@@ -72,6 +79,12 @@ describe("McpIntegrationDialog", () => {
|
|
|
72
79
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
73
80
|
mocks.navigateToMcpOAuthStart.mockReset().mockReturnValue(true);
|
|
74
81
|
mocks.customIntegrationEnabled.mockReset().mockReturnValue(false);
|
|
82
|
+
mocks.mcpServersQuery.data = {
|
|
83
|
+
user: [],
|
|
84
|
+
org: [],
|
|
85
|
+
orgId: "org-builder",
|
|
86
|
+
role: "owner",
|
|
87
|
+
};
|
|
75
88
|
mocks.settings.mockReset();
|
|
76
89
|
mocks.hubspotOAuthReadinessQuery.data = { hubspot: "configured" };
|
|
77
90
|
mocks.hubspotOAuthReadinessQuery.isSuccess = true;
|
|
@@ -459,6 +472,7 @@ describe("McpIntegrationDialog", () => {
|
|
|
459
472
|
const gong = DEFAULT_MCP_INTEGRATIONS.find(
|
|
460
473
|
(integration) => integration.id === "gong",
|
|
461
474
|
)!;
|
|
475
|
+
mocks.mcpServersQuery.data = undefined;
|
|
462
476
|
mocks.mcpServersQuery.isSuccess = false;
|
|
463
477
|
|
|
464
478
|
act(() => {
|
|
@@ -481,6 +495,12 @@ describe("McpIntegrationDialog", () => {
|
|
|
481
495
|
expect(document.body.textContent).toContain("Loading connection scope…");
|
|
482
496
|
expect(document.body.textContent).not.toContain("Provider setup required");
|
|
483
497
|
|
|
498
|
+
mocks.mcpServersQuery.data = {
|
|
499
|
+
user: [],
|
|
500
|
+
org: [],
|
|
501
|
+
orgId: "org-builder",
|
|
502
|
+
role: "owner",
|
|
503
|
+
};
|
|
484
504
|
mocks.mcpServersQuery.isSuccess = true;
|
|
485
505
|
act(() => {
|
|
486
506
|
root.render(
|
|
@@ -502,6 +522,36 @@ describe("McpIntegrationDialog", () => {
|
|
|
502
522
|
expect(document.body.textContent).toContain("Who should use this?");
|
|
503
523
|
});
|
|
504
524
|
|
|
525
|
+
it("opens the requested setup from cached scope data after a refetch fails", () => {
|
|
526
|
+
const gong = DEFAULT_MCP_INTEGRATIONS.find(
|
|
527
|
+
(integration) => integration.id === "gong",
|
|
528
|
+
)!;
|
|
529
|
+
mocks.mcpServersQuery.isSuccess = false;
|
|
530
|
+
mocks.mcpServersQuery.isError = true;
|
|
531
|
+
|
|
532
|
+
act(() => {
|
|
533
|
+
root.render(
|
|
534
|
+
<TooltipProvider>
|
|
535
|
+
<McpIntegrationDialog
|
|
536
|
+
open
|
|
537
|
+
onOpenChange={() => {}}
|
|
538
|
+
initialIntegrationId="gong"
|
|
539
|
+
defaultScope="org"
|
|
540
|
+
canCreateOrgMcp
|
|
541
|
+
hasOrg
|
|
542
|
+
onCreateMcpServer={vi.fn()}
|
|
543
|
+
integrations={[gong]}
|
|
544
|
+
/>
|
|
545
|
+
</TooltipProvider>,
|
|
546
|
+
);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(document.body.textContent).toContain("Who should use this?");
|
|
550
|
+
expect(document.body.textContent).not.toContain(
|
|
551
|
+
"Loading connection scope…",
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
505
555
|
it("surfaces a retry when scope metadata fails before direct connecting", () => {
|
|
506
556
|
const context7 = DEFAULT_MCP_INTEGRATIONS.find(
|
|
507
557
|
(integration) => integration.id === "context7",
|
|
@@ -209,6 +209,7 @@ export function McpIntegrationDialog({
|
|
|
209
209
|
>(null);
|
|
210
210
|
const mcpApi = useMcpServersApi();
|
|
211
211
|
const mcpServersQuery = useMcpServers({ defer: true });
|
|
212
|
+
const hasMcpServerData = mcpServersQuery.data !== undefined;
|
|
212
213
|
const defaultIntegrations = useMemo(
|
|
213
214
|
() => integrations ?? getDefaultMcpIntegrations(),
|
|
214
215
|
[integrations],
|
|
@@ -281,7 +282,7 @@ export function McpIntegrationDialog({
|
|
|
281
282
|
|
|
282
283
|
useEffect(() => {
|
|
283
284
|
if (!open) return;
|
|
284
|
-
if (initialIntegrationId && !
|
|
285
|
+
if (initialIntegrationId && !hasMcpServerData) return;
|
|
285
286
|
const initialIntegration = initialIntegrationId
|
|
286
287
|
? defaultIntegrations.find(
|
|
287
288
|
(integration) => integration.id === initialIntegrationId,
|
|
@@ -332,7 +333,7 @@ export function McpIntegrationDialog({
|
|
|
332
333
|
open,
|
|
333
334
|
safeDefaultScope,
|
|
334
335
|
showCatalog,
|
|
335
|
-
|
|
336
|
+
hasMcpServerData,
|
|
336
337
|
]);
|
|
337
338
|
|
|
338
339
|
useEffect(() => {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
IconSend,
|
|
22
22
|
} from "@tabler/icons-react";
|
|
23
23
|
import {
|
|
24
|
+
useEffect,
|
|
24
25
|
useRef,
|
|
25
26
|
useState,
|
|
26
27
|
type ReactNode,
|
|
@@ -91,6 +92,23 @@ export function ReviewCommentComposer({
|
|
|
91
92
|
}: ReviewCommentComposerProps) {
|
|
92
93
|
const canSubmit = Boolean(value.trim()) && !disabled;
|
|
93
94
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!autoFocus) return;
|
|
97
|
+
// A menu item that opens this composer (Edit, say) mounts it while the
|
|
98
|
+
// menu still traps focus, so keep trying for a few frames until it lands.
|
|
99
|
+
let attempts = 0;
|
|
100
|
+
let frame = 0;
|
|
101
|
+
const focusTextarea = () => {
|
|
102
|
+
const textarea = textareaRef.current;
|
|
103
|
+
if (!textarea) return;
|
|
104
|
+
textarea.focus();
|
|
105
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
106
|
+
if (document.activeElement !== textarea && ++attempts < 10)
|
|
107
|
+
frame = requestAnimationFrame(focusTextarea);
|
|
108
|
+
};
|
|
109
|
+
focusTextarea();
|
|
110
|
+
return () => cancelAnimationFrame(frame);
|
|
111
|
+
}, [autoFocus]);
|
|
94
112
|
const [mentionSearch, setMentionSearch] = useState("");
|
|
95
113
|
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
|
|
96
114
|
const [mentionTriggerIndex, setMentionTriggerIndex] = useState<number | null>(
|
|
@@ -309,7 +327,6 @@ export function ReviewCommentComposer({
|
|
|
309
327
|
<Textarea
|
|
310
328
|
{...textareaProps}
|
|
311
329
|
ref={textareaRef}
|
|
312
|
-
autoFocus={autoFocus}
|
|
313
330
|
value={value}
|
|
314
331
|
disabled={disabled}
|
|
315
332
|
onChange={(event) => {
|
|
@@ -627,6 +627,66 @@ describe("ReviewThreadPanel sidebar layout", () => {
|
|
|
627
627
|
).toBe("A quick reply");
|
|
628
628
|
});
|
|
629
629
|
|
|
630
|
+
async function failReplyAfter(beforeFailure: () => void) {
|
|
631
|
+
act(() => {
|
|
632
|
+
root.render(
|
|
633
|
+
<ReviewThreadPanel
|
|
634
|
+
resourceType="design"
|
|
635
|
+
resourceId="design-1"
|
|
636
|
+
showHeader={false}
|
|
637
|
+
showComposer={false}
|
|
638
|
+
canReply
|
|
639
|
+
replyPlaceholder="Reply to this thread"
|
|
640
|
+
/>,
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
act(() =>
|
|
644
|
+
container
|
|
645
|
+
.querySelector<HTMLButtonElement>('button[aria-label="Reply"]')
|
|
646
|
+
?.click(),
|
|
647
|
+
);
|
|
648
|
+
const replyComposer = container.querySelector<HTMLTextAreaElement>(
|
|
649
|
+
'textarea[placeholder="Reply to this thread"]',
|
|
650
|
+
);
|
|
651
|
+
setTextareaValue(replyComposer!, "A quick reply");
|
|
652
|
+
const failedReply = deferredMutation();
|
|
653
|
+
act(() =>
|
|
654
|
+
replyComposer
|
|
655
|
+
?.closest("form")
|
|
656
|
+
?.querySelector<HTMLButtonElement>('button[type="submit"]')
|
|
657
|
+
?.click(),
|
|
658
|
+
);
|
|
659
|
+
beforeFailure();
|
|
660
|
+
await act(async () => {
|
|
661
|
+
failedReply.reject(new Error("offline"));
|
|
662
|
+
await failedReply.promise.catch(() => undefined);
|
|
663
|
+
});
|
|
664
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
|
665
|
+
return container.querySelector<HTMLTextAreaElement>(
|
|
666
|
+
'textarea[placeholder="Reply to this thread"]',
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
it("puts the cursor back in a failed reply when nothing else took focus", async () => {
|
|
671
|
+
const restored = await failReplyAfter(() => {});
|
|
672
|
+
|
|
673
|
+
expect(restored?.value).toBe("A quick reply");
|
|
674
|
+
expect(document.activeElement).toBe(restored);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("leaves focus where the person moved when a failed reply comes back", async () => {
|
|
678
|
+
const elsewhere = document.createElement("input");
|
|
679
|
+
document.body.appendChild(elsewhere);
|
|
680
|
+
try {
|
|
681
|
+
const restored = await failReplyAfter(() => elsewhere.focus());
|
|
682
|
+
|
|
683
|
+
expect(restored?.value).toBe("A quick reply");
|
|
684
|
+
expect(document.activeElement).toBe(elsewhere);
|
|
685
|
+
} finally {
|
|
686
|
+
elsewhere.remove();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
630
690
|
it("restores the delete dialog when dismissal races with a failed optimistic delete", async () => {
|
|
631
691
|
act(() => {
|
|
632
692
|
root.render(
|
|
@@ -803,15 +863,118 @@ describe("ReviewThreadPanel sidebar layout", () => {
|
|
|
803
863
|
expect(
|
|
804
864
|
document.querySelector('textarea[aria-label="Edit comment"]'),
|
|
805
865
|
).toBeNull();
|
|
866
|
+
// Someone who moved on while the save ran keeps their focus.
|
|
867
|
+
const elsewhere = document.createElement("input");
|
|
868
|
+
document.body.appendChild(elsewhere);
|
|
869
|
+
elsewhere.focus();
|
|
870
|
+
try {
|
|
871
|
+
await act(async () => {
|
|
872
|
+
failedEdit.reject(new Error("offline"));
|
|
873
|
+
await failedEdit.promise.catch(() => undefined);
|
|
874
|
+
});
|
|
875
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
|
876
|
+
expect(
|
|
877
|
+
document.querySelector<HTMLTextAreaElement>(
|
|
878
|
+
'textarea[aria-label="Edit comment"]',
|
|
879
|
+
)?.value,
|
|
880
|
+
).toBe("Ping @Alice updated");
|
|
881
|
+
expect(document.activeElement).toBe(elsewhere);
|
|
882
|
+
} finally {
|
|
883
|
+
elsewhere.remove();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("puts the cursor in the reply box that Reply opens", async () => {
|
|
888
|
+
act(() => {
|
|
889
|
+
root.render(
|
|
890
|
+
<ReviewThreadPanel
|
|
891
|
+
resourceType="design"
|
|
892
|
+
resourceId="design-1"
|
|
893
|
+
showHeader={false}
|
|
894
|
+
showComposer={false}
|
|
895
|
+
canReply
|
|
896
|
+
/>,
|
|
897
|
+
);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const reply = container.querySelector<HTMLButtonElement>(
|
|
901
|
+
'button[aria-label="Reply"]',
|
|
902
|
+
);
|
|
903
|
+
reply?.focus();
|
|
904
|
+
await act(async () => reply?.click());
|
|
905
|
+
|
|
906
|
+
expect(document.activeElement).toBe(
|
|
907
|
+
container.querySelector("textarea[data-review-reply-input]"),
|
|
908
|
+
);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("puts the cursor at the end of the comment that Edit opens", async () => {
|
|
912
|
+
act(() => {
|
|
913
|
+
root.render(
|
|
914
|
+
<ReviewThreadPanel
|
|
915
|
+
resourceType="design"
|
|
916
|
+
resourceId="design-1"
|
|
917
|
+
showHeader={false}
|
|
918
|
+
showComposer={false}
|
|
919
|
+
canEditComment
|
|
920
|
+
/>,
|
|
921
|
+
);
|
|
922
|
+
});
|
|
923
|
+
|
|
806
924
|
await act(async () => {
|
|
807
|
-
|
|
808
|
-
|
|
925
|
+
container
|
|
926
|
+
.querySelector<HTMLButtonElement>('button[aria-label="More actions"]')
|
|
927
|
+
?.dispatchEvent(
|
|
928
|
+
new PointerEvent("pointerdown", { bubbles: true, button: 0 }),
|
|
929
|
+
);
|
|
809
930
|
});
|
|
810
|
-
|
|
811
|
-
document.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
931
|
+
const editItem = Array.from(
|
|
932
|
+
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
933
|
+
).find((item) => item.textContent?.trim() === "Edit comment");
|
|
934
|
+
editItem?.focus();
|
|
935
|
+
await act(async () => editItem?.click());
|
|
936
|
+
// The menu hands focus back to its trigger once it has closed.
|
|
937
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
|
938
|
+
|
|
939
|
+
const editComposer = document.querySelector<HTMLTextAreaElement>(
|
|
940
|
+
'textarea[aria-label="Edit comment"]',
|
|
941
|
+
);
|
|
942
|
+
expect(document.activeElement).toBe(editComposer);
|
|
943
|
+
expect(editComposer?.selectionStart).toBe(rootComment.body.length);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("returns focus to the menu button after an action other than Edit", async () => {
|
|
947
|
+
const onMarkThreadUnread = vi.fn();
|
|
948
|
+
act(() => {
|
|
949
|
+
root.render(
|
|
950
|
+
<ReviewThreadPanel
|
|
951
|
+
resourceType="design"
|
|
952
|
+
resourceId="design-1"
|
|
953
|
+
showHeader={false}
|
|
954
|
+
showComposer={false}
|
|
955
|
+
canEditComment
|
|
956
|
+
onMarkThreadUnread={onMarkThreadUnread}
|
|
957
|
+
/>,
|
|
958
|
+
);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const trigger = container.querySelector<HTMLButtonElement>(
|
|
962
|
+
'button[aria-label="More actions"]',
|
|
963
|
+
);
|
|
964
|
+
await act(async () => {
|
|
965
|
+
trigger?.dispatchEvent(
|
|
966
|
+
new PointerEvent("pointerdown", { bubbles: true, button: 0 }),
|
|
967
|
+
);
|
|
968
|
+
});
|
|
969
|
+
const markUnread = Array.from(
|
|
970
|
+
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
971
|
+
).find((item) => item.textContent?.trim() === "Mark as unread");
|
|
972
|
+
markUnread?.focus();
|
|
973
|
+
await act(async () => markUnread?.click());
|
|
974
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
|
975
|
+
|
|
976
|
+
expect(onMarkThreadUnread).toHaveBeenCalledTimes(1);
|
|
977
|
+
expect(document.activeElement).toBe(trigger);
|
|
815
978
|
});
|
|
816
979
|
|
|
817
980
|
it("shows only the controls authorized for the current viewer", () => {
|
|
@@ -257,6 +257,11 @@ export function ReviewThreadPanel({
|
|
|
257
257
|
}>
|
|
258
258
|
>([]);
|
|
259
259
|
const createPendingRef = useRef(false);
|
|
260
|
+
const editChosenFromMenuRef = useRef(false);
|
|
261
|
+
// Reply and Edit put the cursor in their box. A box reopened after a
|
|
262
|
+
// failed save takes focus only if nobody else holds it by then.
|
|
263
|
+
const [focusReplyBox, setFocusReplyBox] = useState(true);
|
|
264
|
+
const [focusEditBox, setFocusEditBox] = useState(true);
|
|
260
265
|
const [replyingThreadId, setReplyingThreadId] = useState<string | null>(null);
|
|
261
266
|
const replyingThreadIdRef = useRef<string | null>(null);
|
|
262
267
|
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
|
@@ -538,6 +543,7 @@ export function ReviewThreadPanel({
|
|
|
538
543
|
editCandidateRef.current = comment;
|
|
539
544
|
editDraftRef.current = submittedDraft;
|
|
540
545
|
editMentionsRef.current = submittedMentions;
|
|
546
|
+
setFocusEditBox(focusIsFree());
|
|
541
547
|
setEditCandidate(comment);
|
|
542
548
|
setEditDraft(submittedDraft);
|
|
543
549
|
setEditMentions(submittedMentions);
|
|
@@ -546,6 +552,7 @@ export function ReviewThreadPanel({
|
|
|
546
552
|
|
|
547
553
|
const startEditing = (comment: ReviewComment) => {
|
|
548
554
|
editGenerationRef.current += 1;
|
|
555
|
+
setFocusEditBox(true);
|
|
549
556
|
editCandidateRef.current = comment;
|
|
550
557
|
editDraftRef.current = comment.body;
|
|
551
558
|
editMentionsRef.current = [...comment.mentions];
|
|
@@ -564,8 +571,9 @@ export function ReviewThreadPanel({
|
|
|
564
571
|
setEditMentions([]);
|
|
565
572
|
};
|
|
566
573
|
|
|
567
|
-
const startReplying = (threadId: string) => {
|
|
574
|
+
const startReplying = (threadId: string, focus = true) => {
|
|
568
575
|
replyingThreadIdRef.current = threadId;
|
|
576
|
+
setFocusReplyBox(focus);
|
|
569
577
|
setReplyingThreadId(threadId);
|
|
570
578
|
};
|
|
571
579
|
|
|
@@ -648,7 +656,8 @@ export function ReviewThreadPanel({
|
|
|
648
656
|
...current,
|
|
649
657
|
[comment.id]: submittedMentions,
|
|
650
658
|
}));
|
|
651
|
-
if (replyingThreadIdRef.current === null)
|
|
659
|
+
if (replyingThreadIdRef.current === null)
|
|
660
|
+
startReplying(threadId, focusIsFree());
|
|
652
661
|
} finally {
|
|
653
662
|
replyPendingRef.current.delete(comment.id);
|
|
654
663
|
}
|
|
@@ -983,6 +992,7 @@ export function ReviewThreadPanel({
|
|
|
983
992
|
commentLabel={saveEditLabel}
|
|
984
993
|
placeholder={editLabel}
|
|
985
994
|
textareaProps={{ "aria-label": editLabel }}
|
|
995
|
+
autoFocus={focusEditBox}
|
|
986
996
|
submitOnEnter
|
|
987
997
|
onEscape={cancelEditing}
|
|
988
998
|
/>
|
|
@@ -1129,6 +1139,7 @@ export function ReviewThreadPanel({
|
|
|
1129
1139
|
onSubmit={() => submitReply(thread.root)}
|
|
1130
1140
|
placeholder={replyPlaceholder}
|
|
1131
1141
|
commentLabel={replyLabel}
|
|
1142
|
+
autoFocus={focusReplyBox}
|
|
1132
1143
|
submitOnEnter
|
|
1133
1144
|
onEscape={cancelReplying}
|
|
1134
1145
|
textareaProps={{ "data-review-reply-input": true }}
|
|
@@ -1227,10 +1238,23 @@ export function ReviewThreadPanel({
|
|
|
1227
1238
|
<IconDots className="size-4" />
|
|
1228
1239
|
</Button>
|
|
1229
1240
|
</DropdownMenuTrigger>
|
|
1230
|
-
<DropdownMenuContent
|
|
1241
|
+
<DropdownMenuContent
|
|
1242
|
+
align="end"
|
|
1243
|
+
className="w-44"
|
|
1244
|
+
onCloseAutoFocus={(event) => {
|
|
1245
|
+
// Edit puts focus in its composer; leave it
|
|
1246
|
+
// there. Other actions return to the button.
|
|
1247
|
+
if (editChosenFromMenuRef.current)
|
|
1248
|
+
event.preventDefault();
|
|
1249
|
+
editChosenFromMenuRef.current = false;
|
|
1250
|
+
}}
|
|
1251
|
+
>
|
|
1231
1252
|
{editAllowed ? (
|
|
1232
1253
|
<DropdownMenuItem
|
|
1233
|
-
onSelect={() =>
|
|
1254
|
+
onSelect={() => {
|
|
1255
|
+
editChosenFromMenuRef.current = true;
|
|
1256
|
+
startEditing(thread.root);
|
|
1257
|
+
}}
|
|
1234
1258
|
>
|
|
1235
1259
|
{editLabel}
|
|
1236
1260
|
</DropdownMenuItem>
|
|
@@ -1599,6 +1623,12 @@ export function isTrustedReviewAttachmentUrl(value: string): boolean {
|
|
|
1599
1623
|
return parsed.protocol === "https:" && parsed.hostname === "cdn.builder.io";
|
|
1600
1624
|
}
|
|
1601
1625
|
|
|
1626
|
+
/** Nothing on the page holds focus, so taking it moves nobody's caret. */
|
|
1627
|
+
function focusIsFree() {
|
|
1628
|
+
const active = document.activeElement;
|
|
1629
|
+
return !active || active === document.body;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1602
1632
|
function displayReviewCommentBody(body: string): string {
|
|
1603
1633
|
return body.replace(/@\[([^\]]+)\]\(mailto:[^)]+\)/g, "@$1");
|
|
1604
1634
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { PromptComposer } from "./PromptComposer.js";
|
|
8
|
+
import type { TiptapComposerHandle } from "./TiptapComposer.js";
|
|
9
|
+
|
|
10
|
+
let container: HTMLDivElement;
|
|
11
|
+
let root: Root;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
15
|
+
container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
root = createRoot(container);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
act(() => root.unmount());
|
|
22
|
+
container.remove();
|
|
23
|
+
vi.unstubAllGlobals();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function render(composerRef: React.Ref<TiptapComposerHandle>) {
|
|
27
|
+
await act(async () => {
|
|
28
|
+
root.render(
|
|
29
|
+
<PromptComposer
|
|
30
|
+
composerRef={composerRef}
|
|
31
|
+
autoFocus
|
|
32
|
+
onSubmit={vi.fn()}
|
|
33
|
+
showModelSelector={false}
|
|
34
|
+
modelStatusChecksEnabled={false}
|
|
35
|
+
includeDefaultMentionSearch={false}
|
|
36
|
+
includeDefaultSlashSkills={false}
|
|
37
|
+
voiceEnabled={false}
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 100)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const editor = () => container.querySelector<HTMLElement>(".ProseMirror");
|
|
45
|
+
|
|
46
|
+
describe("autoFocus", () => {
|
|
47
|
+
it("focuses the editor when the caller passes a ref object", async () => {
|
|
48
|
+
const composerRef = React.createRef<TiptapComposerHandle>();
|
|
49
|
+
await render(composerRef);
|
|
50
|
+
expect(composerRef.current).not.toBeNull();
|
|
51
|
+
expect(document.activeElement).toBe(editor());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("focuses the editor when the caller passes a callback ref", async () => {
|
|
55
|
+
let handle: TiptapComposerHandle | null = null;
|
|
56
|
+
await render((next) => {
|
|
57
|
+
handle = next;
|
|
58
|
+
});
|
|
59
|
+
expect(handle).not.toBeNull();
|
|
60
|
+
expect(document.activeElement).toBe(editor());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("runs the cleanup a callback ref returns", async () => {
|
|
64
|
+
const cleanup = vi.fn();
|
|
65
|
+
const ref = vi.fn(() => cleanup);
|
|
66
|
+
await render(ref);
|
|
67
|
+
act(() => root.unmount());
|
|
68
|
+
// React detaches with the cleanup, never by calling the ref with null.
|
|
69
|
+
expect(cleanup).toHaveBeenCalledTimes(ref.mock.calls.length);
|
|
70
|
+
expect(ref).not.toHaveBeenCalledWith(null);
|
|
71
|
+
root = createRoot(container);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -722,7 +722,26 @@ function PromptComposerInner({
|
|
|
722
722
|
const modelsAdapter = adapters.models!;
|
|
723
723
|
const BuilderSetupCard = modelsAdapter.BuilderSetupCard;
|
|
724
724
|
const localRef = useRef<TiptapComposerHandle>(null);
|
|
725
|
-
|
|
725
|
+
// Keep our own copy of the handle so autofocus and held submits work when
|
|
726
|
+
// the caller passes a callback ref instead of a ref object.
|
|
727
|
+
const handleRef = useCallback(
|
|
728
|
+
(handle: TiptapComposerHandle | null) => {
|
|
729
|
+
localRef.current = handle;
|
|
730
|
+
if (typeof composerRef !== "function") {
|
|
731
|
+
if (composerRef) composerRef.current = handle;
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const cleanup = composerRef(handle);
|
|
735
|
+
// React runs a returned cleanup instead of calling the ref with null,
|
|
736
|
+
// so pass the caller's cleanup through.
|
|
737
|
+
if (typeof cleanup !== "function") return;
|
|
738
|
+
return () => {
|
|
739
|
+
localRef.current = null;
|
|
740
|
+
cleanup();
|
|
741
|
+
};
|
|
742
|
+
},
|
|
743
|
+
[composerRef],
|
|
744
|
+
);
|
|
726
745
|
const attachments = useComposer((state) => state.attachments);
|
|
727
746
|
const attachmentFiles = useMemo(
|
|
728
747
|
() =>
|
|
@@ -854,15 +873,12 @@ function PromptComposerInner({
|
|
|
854
873
|
const held = heldProviderDraftRef.current;
|
|
855
874
|
if (engineState !== "configured" || !held) return;
|
|
856
875
|
heldProviderDraftRef.current = null;
|
|
857
|
-
const target =
|
|
858
|
-
typeof handleRef === "object" && handleRef && "current" in handleRef
|
|
859
|
-
? handleRef.current
|
|
860
|
-
: null;
|
|
876
|
+
const target = localRef.current;
|
|
861
877
|
const current = target?.getDraftSnapshot?.();
|
|
862
878
|
if (current && sameComposerDraft(held, current)) {
|
|
863
879
|
void target?.submit?.();
|
|
864
880
|
}
|
|
865
|
-
}, [engineState,
|
|
881
|
+
}, [engineState, heldProviderDraftRevision]);
|
|
866
882
|
const useInlineMissingKeySetup = layoutVariant === "compact";
|
|
867
883
|
const retryEngineStatus = useCallback(() => {
|
|
868
884
|
if (typeof window !== "undefined") {
|
|
@@ -871,15 +887,9 @@ function PromptComposerInner({
|
|
|
871
887
|
}, []);
|
|
872
888
|
useEffect(() => {
|
|
873
889
|
if (!autoFocus || composerDisabled) return;
|
|
874
|
-
const id = window.setTimeout(() =>
|
|
875
|
-
const target =
|
|
876
|
-
typeof handleRef === "object" && handleRef && "current" in handleRef
|
|
877
|
-
? handleRef.current
|
|
878
|
-
: null;
|
|
879
|
-
target?.focus();
|
|
880
|
-
}, 50);
|
|
890
|
+
const id = window.setTimeout(() => localRef.current?.focus(), 50);
|
|
881
891
|
return () => window.clearTimeout(id);
|
|
882
|
-
}, [autoFocus, composerDisabled
|
|
892
|
+
}, [autoFocus, composerDisabled]);
|
|
883
893
|
|
|
884
894
|
const handleSubmit = useCallback(
|
|
885
895
|
async (
|
|
@@ -2759,6 +2759,7 @@ describe("createTiptapComposerExtensions", () => {
|
|
|
2759
2759
|
selectedModel: "gpt-5.6-luna",
|
|
2760
2760
|
selectedEngine: "openai",
|
|
2761
2761
|
onModelChange: vi.fn(),
|
|
2762
|
+
agentOnly: false,
|
|
2762
2763
|
includeDefaultSlashSkills: false,
|
|
2763
2764
|
plusMenuMode: "hidden",
|
|
2764
2765
|
providerConnectStatusEnabled: false,
|
|
@@ -2808,6 +2809,11 @@ describe("createTiptapComposerExtensions", () => {
|
|
|
2808
2809
|
'[data-agent-native-composer-popover="true"]',
|
|
2809
2810
|
);
|
|
2810
2811
|
expect(picker?.textContent).toContain("GPT-5.6 Luna");
|
|
2812
|
+
expect(
|
|
2813
|
+
Array.from(picker?.querySelectorAll('[role="tab"]') ?? []).some((tab) =>
|
|
2814
|
+
tab.textContent?.includes("Model"),
|
|
2815
|
+
),
|
|
2816
|
+
).toBe(true);
|
|
2811
2817
|
});
|
|
2812
2818
|
|
|
2813
2819
|
it("shows current Builder model versions in the picker", () => {
|
|
@@ -5,6 +5,7 @@ import StarterKit from "@tiptap/starter-kit";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
DRAG_HANDLE_UI_EVENT,
|
|
8
9
|
DragHandle,
|
|
9
10
|
dragPreviewTransform,
|
|
10
11
|
type DragHandleOptions,
|
|
@@ -345,6 +346,50 @@ describe("DragHandle menu", () => {
|
|
|
345
346
|
}
|
|
346
347
|
});
|
|
347
348
|
|
|
349
|
+
it("marks menu and drag edits as user actions", () => {
|
|
350
|
+
const { editor, handle } = mountEditor("<p>First</p><p>Second</p>");
|
|
351
|
+
const uiEvents: unknown[] = [];
|
|
352
|
+
editor.on("transaction", ({ transaction }) => {
|
|
353
|
+
if (transaction.docChanged) uiEvents.push(transaction.getMeta("uiEvent"));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
for (const label of ["Insert block below", "Duplicate", "Delete"]) {
|
|
358
|
+
hoverAt(32, 12);
|
|
359
|
+
clickHandle(handle);
|
|
360
|
+
clickMenuItem(label);
|
|
361
|
+
}
|
|
362
|
+
hoverAt(32, 12);
|
|
363
|
+
handle.dispatchEvent(
|
|
364
|
+
new MouseEvent("mousedown", {
|
|
365
|
+
bubbles: true,
|
|
366
|
+
button: 0,
|
|
367
|
+
clientX: 12,
|
|
368
|
+
clientY: 12,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
document.dispatchEvent(
|
|
372
|
+
new MouseEvent("mousemove", {
|
|
373
|
+
bubbles: true,
|
|
374
|
+
clientX: 12,
|
|
375
|
+
clientY: 56,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
document.dispatchEvent(
|
|
379
|
+
new MouseEvent("mouseup", {
|
|
380
|
+
bubbles: true,
|
|
381
|
+
button: 0,
|
|
382
|
+
clientX: 12,
|
|
383
|
+
clientY: 56,
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(uiEvents).toEqual(Array(4).fill(DRAG_HANDLE_UI_EVENT));
|
|
388
|
+
} finally {
|
|
389
|
+
editor.destroy();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
348
393
|
it("keeps drag-to-reorder behavior when the handle is moved", () => {
|
|
349
394
|
const { editor, handle } = mountEditor("<p>First</p><p>Second</p>");
|
|
350
395
|
|