@agent-native/toolkit 0.219.0 → 0.220.0-nightly-20261023212848

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.
@@ -62,6 +62,8 @@ export interface AssistantChatSendOptions {
62
62
  approvedToolCalls?: string[];
63
63
  /** Send as a protocol continuation that stays out of visible history. */
64
64
  hideUserMessage?: boolean;
65
+ /** The app surface this send came from, reported with the turn's analytics. */
66
+ surface?: string;
65
67
  }
66
68
 
67
69
  export type AssistantChatSubmitResult =
@@ -215,7 +215,7 @@ function ContextSubmenu({
215
215
  icon?: ReactNode;
216
216
  disabled?: boolean;
217
217
  open: boolean;
218
- onOpenChange: (open: boolean) => void;
218
+ onOpenChange: (open: boolean, reason?: "escape") => void;
219
219
  children: ReactNode;
220
220
  }) {
221
221
  const trigger = useRef<HTMLDivElement>(null);
@@ -244,7 +244,7 @@ function ContextSubmenu({
244
244
  onEscapeKeyDown={(event) => {
245
245
  event.preventDefault();
246
246
  event.stopPropagation();
247
- onOpenChange(false);
247
+ onOpenChange(false, "escape");
248
248
  trigger.current?.focus();
249
249
  }}
250
250
  >
@@ -312,10 +312,14 @@ export function ComposerContextMenu({
312
312
  const onDisabledFocusRef = useRef(onDisabledFocus);
313
313
  onDisabledFocusRef.current = onDisabledFocus;
314
314
  const disabledFocusFrame = useRef<number | null>(null);
315
+ const resumedPageClose = useRef<string | null>(null);
316
+ const resumedPageCloseFrame = useRef<number | null>(null);
315
317
  useEffect(
316
318
  () => () => {
317
319
  if (disabledFocusFrame.current !== null)
318
320
  window.cancelAnimationFrame(disabledFocusFrame.current);
321
+ if (resumedPageCloseFrame.current !== null)
322
+ window.cancelAnimationFrame(resumedPageCloseFrame.current);
319
323
  },
320
324
  [],
321
325
  );
@@ -545,7 +549,19 @@ export function ComposerContextMenu({
545
549
  label={entry.label}
546
550
  disabled={entry.disabled}
547
551
  open={expanded}
548
- onOpenChange={(next) => {
552
+ onOpenChange={(next, reason) => {
553
+ if (
554
+ !next &&
555
+ reason !== "escape" &&
556
+ resumedPageClose.current === entry.id
557
+ ) {
558
+ resumedPageClose.current = null;
559
+ if (resumedPageCloseFrame.current !== null) {
560
+ window.cancelAnimationFrame(resumedPageCloseFrame.current);
561
+ resumedPageCloseFrame.current = null;
562
+ }
563
+ return;
564
+ }
549
565
  const isOpen = branch.every(
550
566
  (id, index) => pathRef.current[index] === id,
551
567
  );
@@ -588,6 +604,19 @@ export function ComposerContextMenu({
588
604
  );
589
605
  return;
590
606
  }
607
+ // Radix can emit one stale close after native-picker focus returns.
608
+ resumedPageClose.current = entry.id;
609
+ if (resumedPageCloseFrame.current !== null)
610
+ window.cancelAnimationFrame(
611
+ resumedPageCloseFrame.current,
612
+ );
613
+ resumedPageCloseFrame.current =
614
+ window.requestAnimationFrame(() => {
615
+ resumedPageCloseFrame.current = null;
616
+ if (resumedPageClose.current === entry.id)
617
+ resumedPageClose.current = null;
618
+ });
619
+ triggerRef.current?.focus();
591
620
  changeOpen(true);
592
621
  activate(action, origin, false);
593
622
  },
@@ -71,7 +71,10 @@ afterEach(() => {
71
71
  async function settle() {
72
72
  await new Promise((resolve) => setTimeout(resolve, 0));
73
73
  }
74
- async function mount(props: Partial<PromptComposerProps> = {}) {
74
+ async function mount(
75
+ props: Partial<PromptComposerProps> = {},
76
+ settleAfterRender = true,
77
+ ) {
75
78
  const composerRef = React.createRef<TiptapComposerHandle>();
76
79
  const onSubmit = vi.fn();
77
80
  await act(async () => {
@@ -92,7 +95,7 @@ async function mount(props: Partial<PromptComposerProps> = {}) {
92
95
  />
93
96
  </ComposerRuntimeAdaptersProvider>,
94
97
  );
95
- await settle();
98
+ if (settleAfterRender) await settle();
96
99
  });
97
100
  return { composerRef, onSubmit };
98
101
  }
@@ -722,4 +725,95 @@ describe("shared default action preservation", () => {
722
725
  expect(document.querySelector('[role="status"]')).toBeNull();
723
726
  expect(item("Upload skill file")).toBeDefined();
724
727
  });
728
+
729
+ it("resumes skill-file review after the native picker dismisses the menu", async () => {
730
+ await mount();
731
+ await open();
732
+ await choose("Create Skill");
733
+ await choose("Upload skill file");
734
+
735
+ await act(async () => {
736
+ await new Promise((resolve) => setTimeout(resolve, 120));
737
+ });
738
+
739
+ const input = container.querySelector<HTMLInputElement>(
740
+ 'input[accept=".md,text/markdown"]',
741
+ )!;
742
+ const file = new File(["# Test skill\nDo the test."], "Test Skill.md", {
743
+ type: "text/markdown",
744
+ });
745
+ Object.defineProperty(input, "files", {
746
+ configurable: true,
747
+ value: [file],
748
+ });
749
+ await act(async () => {
750
+ input.dispatchEvent(new Event("change", { bubbles: true }));
751
+ await settle();
752
+ });
753
+
754
+ const form = document.querySelector<HTMLFormElement>("[role=menu] form");
755
+ expect(form).not.toBeNull();
756
+ expect(form?.textContent).toContain(
757
+ "Review the content from Test Skill.md before saving.",
758
+ );
759
+ expect(form?.querySelector("textarea")?.value).toBe(
760
+ "# Test skill\nDo the test.",
761
+ );
762
+ });
763
+
764
+ it("restores the skill submenu when the native picker is canceled", async () => {
765
+ await mount();
766
+ await open();
767
+ await choose("Create Skill");
768
+ await choose("Upload skill file");
769
+
770
+ await act(async () => {
771
+ await new Promise((resolve) => setTimeout(resolve, 120));
772
+ });
773
+
774
+ const input = container.querySelector<HTMLInputElement>(
775
+ 'input[accept=".md,text/markdown"]',
776
+ )!;
777
+ await act(async () => {
778
+ input.dispatchEvent(new Event("cancel"));
779
+ await settle();
780
+ });
781
+
782
+ expect(labels()).toContain("Upload skill file");
783
+ });
784
+
785
+ it("does not resume the skill page if the composer is disabled", async () => {
786
+ await mount();
787
+ await open();
788
+ await choose("Create Skill");
789
+ await choose("Upload skill file");
790
+
791
+ const input = container.querySelector<HTMLInputElement>(
792
+ 'input[accept=".md,text/markdown"]',
793
+ )!;
794
+ vi.useFakeTimers();
795
+ try {
796
+ const scheduledTimeouts = vi.spyOn(globalThis, "setTimeout");
797
+ const clearedTimeouts = vi.spyOn(globalThis, "clearTimeout");
798
+ const timersBeforeCancel = vi.getTimerCount();
799
+ const timeoutCallIndex = scheduledTimeouts.mock.calls.length;
800
+ act(() => input.dispatchEvent(new Event("cancel")));
801
+ expect(vi.getTimerCount()).toBe(timersBeforeCancel + 1);
802
+ const resumeTimer =
803
+ scheduledTimeouts.mock.results[timeoutCallIndex]?.value;
804
+ expect(resumeTimer).toBeDefined();
805
+
806
+ await mount({ disabled: true }, false);
807
+ expect(clearedTimeouts).toHaveBeenCalledWith(resumeTimer);
808
+
809
+ await act(async () => {
810
+ await vi.advanceTimersByTimeAsync(0);
811
+ });
812
+
813
+ expect(document.querySelector("[role=menu] form")).toBeNull();
814
+ expect(labels()).not.toContain("Upload skill file");
815
+ } finally {
816
+ vi.useRealTimers();
817
+ }
818
+ });
725
819
  });
@@ -92,6 +92,9 @@ export function useComposerDefaultActions({
92
92
  const [view, setView] = useState<View>("menu");
93
93
  const skillFileInputRef = useRef<HTMLInputElement>(null);
94
94
  const skillControls = useRef<ComposerContextPageControls | null>(null);
95
+ const skillFilePickerOpen = useRef(false);
96
+ const skillPageDismissedWhilePickerOpen = useRef(false);
97
+ const skillResumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
95
98
  const skillEpoch = useRef(0);
96
99
  const skillRequest = useRef<AbortController | null>(null);
97
100
  const skillCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -104,13 +107,48 @@ export function useComposerDefaultActions({
104
107
  } | null>(null);
105
108
  const [skillUploadBusy, setSkillUploadBusy] = useState(false);
106
109
  const formId = useId();
107
- const cancelSkillWork = useCallback(() => {
110
+ const invalidateSkillWork = useCallback(() => {
108
111
  skillEpoch.current++;
109
112
  skillRequest.current?.abort();
110
113
  skillRequest.current = null;
111
114
  if (skillCloseTimer.current !== null) clearTimeout(skillCloseTimer.current);
112
115
  skillCloseTimer.current = null;
113
116
  }, []);
117
+ const cancelSkillWork = useCallback(() => {
118
+ skillFilePickerOpen.current = false;
119
+ skillPageDismissedWhilePickerOpen.current = false;
120
+ if (skillResumeTimer.current !== null)
121
+ clearTimeout(skillResumeTimer.current);
122
+ skillResumeTimer.current = null;
123
+ invalidateSkillWork();
124
+ }, [invalidateSkillWork]);
125
+ const finishSkillFilePicker = useCallback(() => {
126
+ skillFilePickerOpen.current = false;
127
+ if (!skillPageDismissedWhilePickerOpen.current) return;
128
+ skillPageDismissedWhilePickerOpen.current = false;
129
+ if (skillResumeTimer.current !== null)
130
+ clearTimeout(skillResumeTimer.current);
131
+ skillResumeTimer.current = setTimeout(() => {
132
+ skillResumeTimer.current = null;
133
+ skillControls.current?.onResume();
134
+ }, 0);
135
+ }, []);
136
+ const dismissSkillPage = useCallback(() => {
137
+ if (skillFilePickerOpen.current) {
138
+ skillPageDismissedWhilePickerOpen.current = true;
139
+ return;
140
+ }
141
+ cancelSkillWork();
142
+ }, [cancelSkillWork]);
143
+ useEffect(() => {
144
+ const input = skillFileInputRef.current;
145
+ input?.addEventListener("cancel", finishSkillFilePicker);
146
+ return () => {
147
+ input?.removeEventListener("cancel", finishSkillFilePicker);
148
+ if (skillResumeTimer.current !== null)
149
+ clearTimeout(skillResumeTimer.current);
150
+ };
151
+ }, [finishSkillFilePicker]);
114
152
  useEffect(() => cancelSkillWork, [cancelSkillWork, adapters.resolvePath]);
115
153
  useEffect(() => {
116
154
  if (!disabled) return;
@@ -128,7 +166,7 @@ export function useComposerDefaultActions({
128
166
  setSkillUploadBusy(false);
129
167
  };
130
168
  const handleSkillFileSelected = async (file: File) => {
131
- cancelSkillWork();
169
+ invalidateSkillWork();
132
170
  const epoch = skillEpoch.current;
133
171
  setSkillUploadStatus(null);
134
172
  try {
@@ -298,7 +336,7 @@ export function useComposerDefaultActions({
298
336
  }),
299
337
  ],
300
338
  onSelect: resetSkillUpload,
301
- onDismiss: cancelSkillWork,
339
+ onDismiss: dismissSkillPage,
302
340
  render: (controls) => {
303
341
  skillControls.current = controls;
304
342
  return view === "menu" ? (
@@ -317,7 +355,12 @@ export function useComposerDefaultActions({
317
355
  <DropdownMenuItem
318
356
  onSelect={(event) => {
319
357
  event.preventDefault();
320
- skillFileInputRef.current?.click();
358
+ const input = skillFileInputRef.current;
359
+ if (!input) return;
360
+ skillPageDismissedWhilePickerOpen.current = false;
361
+ skillFilePickerOpen.current = true;
362
+ controls.onClose({ restoreFocus: false });
363
+ input.click();
321
364
  }}
322
365
  >
323
366
  {t("agentChat.composer.skill.uploadFile", {
@@ -451,6 +494,7 @@ export function useComposerDefaultActions({
451
494
  onChange={(event) => {
452
495
  const file = event.target.files?.[0];
453
496
  event.target.value = "";
497
+ finishSkillFilePicker();
454
498
  if (file) void handleSkillFileSelected(file);
455
499
  }}
456
500
  />