@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.
Files changed (37) hide show
  1. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  2. package/dist/app/agentkit/react/components.js +46 -7
  3. package/dist/app/agentkit/react/components.js.map +1 -1
  4. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  5. package/dist/app/chat/AgentSidebar.js +5 -3
  6. package/dist/app/chat/AgentSidebar.js.map +1 -1
  7. package/dist/app/resources/McpIntegrationDialog.d.ts.map +1 -1
  8. package/dist/app/resources/McpIntegrationDialog.js +3 -2
  9. package/dist/app/resources/McpIntegrationDialog.js.map +1 -1
  10. package/dist/app/review/ReviewCommentComposer.d.ts.map +1 -1
  11. package/dist/app/review/ReviewCommentComposer.js +21 -2
  12. package/dist/app/review/ReviewCommentComposer.js.map +1 -1
  13. package/dist/app/review/ReviewThreadPanel.d.ts.map +1 -1
  14. package/dist/app/review/ReviewThreadPanel.js +27 -5
  15. package/dist/app/review/ReviewThreadPanel.js.map +1 -1
  16. package/dist/composer/PromptComposer.d.ts.map +1 -1
  17. package/dist/composer/PromptComposer.js +23 -12
  18. package/dist/composer/PromptComposer.js.map +1 -1
  19. package/dist/editor/DragHandle.d.ts +1 -0
  20. package/dist/editor/DragHandle.d.ts.map +1 -1
  21. package/dist/editor/DragHandle.js +13 -6
  22. package/dist/editor/DragHandle.js.map +1 -1
  23. package/package.json +4 -4
  24. package/src/app/agentkit/react/components.interaction.spec.tsx +85 -0
  25. package/src/app/agentkit/react/components.tsx +45 -8
  26. package/src/app/chat/AgentSidebar.spec.tsx +56 -6
  27. package/src/app/chat/AgentSidebar.tsx +7 -5
  28. package/src/app/resources/McpIntegrationDialog.spec.tsx +51 -1
  29. package/src/app/resources/McpIntegrationDialog.tsx +3 -2
  30. package/src/app/review/ReviewCommentComposer.tsx +18 -1
  31. package/src/app/review/ReviewThreadPanel.spec.tsx +170 -7
  32. package/src/app/review/ReviewThreadPanel.tsx +34 -4
  33. package/src/composer/PromptComposer.autofocus.spec.tsx +73 -0
  34. package/src/composer/PromptComposer.tsx +24 -14
  35. package/src/composer/TiptapComposer.spec.ts +6 -0
  36. package/src/editor/DragHandle.spec.ts +45 -0
  37. 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 && !mcpServersQuery.isSuccess) return;
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
- mcpServersQuery.isSuccess,
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
- failedEdit.reject(new Error("offline"));
808
- await failedEdit.promise.catch(() => undefined);
925
+ container
926
+ .querySelector<HTMLButtonElement>('button[aria-label="More actions"]')
927
+ ?.dispatchEvent(
928
+ new PointerEvent("pointerdown", { bubbles: true, button: 0 }),
929
+ );
809
930
  });
810
- expect(
811
- document.querySelector<HTMLTextAreaElement>(
812
- 'textarea[aria-label="Edit comment"]',
813
- )?.value,
814
- ).toBe("Ping @Alice updated");
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) startReplying(threadId);
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 align="end" className="w-44">
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={() => startEditing(thread.root)}
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
- const handleRef = composerRef ?? localRef;
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, handleRef, heldProviderDraftRevision]);
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, handleRef]);
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