@agent-native/toolkit 0.209.0 → 0.209.1-nightly-20261015005848

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 (42) hide show
  1. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  2. package/dist/app/chat/AgentSidebar.js +22 -11
  3. package/dist/app/chat/AgentSidebar.js.map +1 -1
  4. package/dist/app/review/InlineMarkdown.d.ts +2 -0
  5. package/dist/app/review/InlineMarkdown.d.ts.map +1 -1
  6. package/dist/app/review/InlineMarkdown.js +25 -7
  7. package/dist/app/review/InlineMarkdown.js.map +1 -1
  8. package/dist/composer/MentionChip.d.ts +14 -0
  9. package/dist/composer/MentionChip.d.ts.map +1 -0
  10. package/dist/composer/MentionChip.js +11 -0
  11. package/dist/composer/MentionChip.js.map +1 -0
  12. package/dist/composer/MentionPopover.d.ts +5 -0
  13. package/dist/composer/MentionPopover.d.ts.map +1 -1
  14. package/dist/composer/MentionPopover.js +55 -44
  15. package/dist/composer/MentionPopover.js.map +1 -1
  16. package/dist/composer/PromptComposer.d.ts +6 -1
  17. package/dist/composer/PromptComposer.d.ts.map +1 -1
  18. package/dist/composer/PromptComposer.js +3 -3
  19. package/dist/composer/PromptComposer.js.map +1 -1
  20. package/dist/composer/TiptapComposer.d.ts +18 -2
  21. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  22. package/dist/composer/TiptapComposer.js +60 -12
  23. package/dist/composer/TiptapComposer.js.map +1 -1
  24. package/dist/composer/extensions/MentionReference.d.ts.map +1 -1
  25. package/dist/composer/extensions/MentionReference.js +16 -5
  26. package/dist/composer/extensions/MentionReference.js.map +1 -1
  27. package/dist/composer/index.d.ts +2 -1
  28. package/dist/composer/index.d.ts.map +1 -1
  29. package/dist/composer/index.js +1 -0
  30. package/dist/composer/index.js.map +1 -1
  31. package/package.json +4 -4
  32. package/src/app/chat/AgentSidebar.spec.tsx +339 -2
  33. package/src/app/chat/AgentSidebar.tsx +25 -10
  34. package/src/app/review/InlineMarkdown.spec.tsx +25 -0
  35. package/src/app/review/InlineMarkdown.tsx +29 -13
  36. package/src/composer/MentionChip.tsx +46 -0
  37. package/src/composer/MentionPopover.tsx +49 -7
  38. package/src/composer/PromptComposer.text-references.spec.tsx +198 -0
  39. package/src/composer/PromptComposer.tsx +12 -0
  40. package/src/composer/TiptapComposer.tsx +100 -10
  41. package/src/composer/extensions/MentionReference.tsx +22 -14
  42. package/src/composer/index.ts +2 -0
@@ -44,6 +44,8 @@ export interface InlineMarkdownProtectedSpan {
44
44
  label: string;
45
45
  className?: string;
46
46
  title?: string;
47
+ /** Protect only the occurrence of `source` starting at this offset. */
48
+ at?: number;
47
49
  }
48
50
 
49
51
  export function InlineMarkdown({
@@ -137,23 +139,37 @@ export function InlineMarkdown({
137
139
 
138
140
  const PROTECTED_SPAN_PREFIX = "https://inline-markdown-protected.invalid/span/";
139
141
 
142
+ // One pass over the original text: replacing span by span would match a
143
+ // shorter source again inside the link label a longer one just produced.
140
144
  function protectInlineMarkdownSpans(
141
145
  content: string,
142
146
  spans: readonly InlineMarkdownProtectedSpan[],
143
147
  ): string {
144
- return [...spans]
145
- .map((span, index) => ({ span, index }))
146
- .filter(({ span }) => span.source.length > 0)
147
- .sort((a, b) => b.span.source.length - a.span.source.length)
148
- .reduce(
149
- (markdown, { span, index }) =>
150
- markdown
151
- .split(span.source)
152
- .join(
153
- `[${escapeProtectedSpanLabel(span.label)}](${PROTECTED_SPAN_PREFIX}${index})`,
154
- ),
155
- content,
156
- );
148
+ const matches: { start: number; end: number; index: number }[] = [];
149
+ spans.forEach(({ source, at }, index) => {
150
+ if (!source) return;
151
+ if (at !== undefined) {
152
+ if (content.startsWith(source, at))
153
+ matches.push({ start: at, end: at + source.length, index });
154
+ return;
155
+ }
156
+ for (
157
+ let start = content.indexOf(source);
158
+ start !== -1;
159
+ start = content.indexOf(source, start + source.length)
160
+ ) {
161
+ matches.push({ start, end: start + source.length, index });
162
+ }
163
+ });
164
+ matches.sort((a, b) => a.start - b.start || b.end - a.end);
165
+ let markdown = "";
166
+ let cursor = 0;
167
+ for (const { start, end, index } of matches) {
168
+ if (start < cursor) continue;
169
+ markdown += `${content.slice(cursor, start)}[${escapeProtectedSpanLabel(spans[index]!.label)}](${PROTECTED_SPAN_PREFIX}${index})`;
170
+ cursor = end;
171
+ }
172
+ return markdown + content.slice(cursor);
157
173
  }
158
174
 
159
175
  function escapeProtectedSpanLabel(label: string): string {
@@ -0,0 +1,46 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from "react";
2
+
3
+ import { cn } from "../utils.js";
4
+ import { MentionItemMedia } from "./MentionItemMedia.js";
5
+ import type { MentionItemMedia as MentionItemMediaValue } from "./types.js";
6
+
7
+ export interface MentionChipProps extends Omit<
8
+ ComponentPropsWithRef<"span">,
9
+ "children"
10
+ > {
11
+ label: ReactNode;
12
+ icon?: string;
13
+ media?: MentionItemMediaValue | null;
14
+ fallbackIcon?: "clipboard" | "file" | "stack";
15
+ }
16
+
17
+ /**
18
+ * The inline chip a mention is drawn as, in the composer and wherever a sent
19
+ * message shows its mentions, so the two always look the same.
20
+ */
21
+ export function MentionChip({
22
+ label,
23
+ icon,
24
+ media,
25
+ fallbackIcon = "stack",
26
+ className,
27
+ ...props
28
+ }: MentionChipProps) {
29
+ return (
30
+ <span
31
+ {...props}
32
+ className={cn(
33
+ "mx-0.5 inline-flex max-w-[200px] items-center gap-1 rounded-md border border-input bg-muted/50 px-1.5 py-0.5 align-middle text-xs font-medium text-foreground",
34
+ className,
35
+ )}
36
+ >
37
+ <MentionItemMedia
38
+ icon={icon}
39
+ media={media}
40
+ size="sm"
41
+ fallbackIcon={fallbackIcon}
42
+ />
43
+ <span className="truncate">{label}</span>
44
+ </span>
45
+ );
46
+ }
@@ -33,6 +33,8 @@ export interface MentionPopoverRef {
33
33
  getSelectedIndex: () => number;
34
34
  getSelectedMention: () => MentionItem | null;
35
35
  getSelectedCommand: () => SlashCommand | null;
36
+ /** Runs Retry if it is the highlighted row; false when it is not. */
37
+ activateRetry: () => boolean;
36
38
  }
37
39
 
38
40
  interface MentionPopoverProps {
@@ -43,6 +45,9 @@ interface MentionPopoverProps {
43
45
  commands?: SlashCommand[];
44
46
  hint?: string;
45
47
  isLoading: boolean;
48
+ /** Why some @ items could not load; shown above the items that did. */
49
+ error?: string;
50
+ onRetry?: () => void;
46
51
  query: string;
47
52
  onSelectMention: (item: MentionItem) => void;
48
53
  onSelectSkill: (skill: SkillResult) => void;
@@ -158,6 +163,8 @@ export const MentionPopover = forwardRef<
158
163
  commands = [],
159
164
  hint,
160
165
  isLoading,
166
+ error,
167
+ onRetry,
161
168
  query,
162
169
  onSelectMention,
163
170
  onSelectSkill,
@@ -168,7 +175,7 @@ export const MentionPopover = forwardRef<
168
175
  const stacked = density === "stacked";
169
176
 
170
177
  const [selectedIndex, setSelectedIndex] = useState(0);
171
- const listRef = useRef<HTMLDivElement>(null);
178
+ const containerRef = useRef<HTMLDivElement>(null);
172
179
  const t = useComposerRuntimeAdapters().translate!;
173
180
 
174
181
  const sectionLabel = (section: string) => {
@@ -196,6 +203,10 @@ export const MentionPopover = forwardRef<
196
203
 
197
204
  const itemCount =
198
205
  type === "@" ? mentionItems.length : commands.length + skills.length;
206
+ // Focus stays in the editor, so Retry is a row above the items, at -1,
207
+ // for the arrow keys to reach.
208
+ const retryable = type === "@" && !!error && !!onRetry;
209
+ const firstIndex = retryable ? -1 : 0;
199
210
  const itemIdentitySignature =
200
211
  type === "@"
201
212
  ? mentionItems.map((item) => item.id).join("\0")
@@ -250,10 +261,10 @@ export const MentionPopover = forwardRef<
250
261
 
251
262
  useEffect(() => {
252
263
  setSelectedIndex((current) => (current === 0 ? current : 0));
253
- }, [itemIdentitySignature, query]);
264
+ }, [itemIdentitySignature, query, retryable]);
254
265
 
255
266
  useEffect(() => {
256
- const container = listRef.current;
267
+ const container = containerRef.current;
257
268
  if (!container) return;
258
269
  const selected = container.querySelector(
259
270
  `[data-mention-index="${selectedIndex}"]`,
@@ -266,11 +277,13 @@ export const MentionPopover = forwardRef<
266
277
  useImperativeHandle(ref, () => ({
267
278
  moveUp: () => {
268
279
  setSelectedIndex((prev) =>
269
- prev <= 0 ? Math.max(0, itemCount - 1) : prev - 1,
280
+ prev <= firstIndex ? Math.max(firstIndex, itemCount - 1) : prev - 1,
270
281
  );
271
282
  },
272
283
  moveDown: () => {
273
- setSelectedIndex((prev) => (prev >= itemCount - 1 ? 0 : prev + 1));
284
+ setSelectedIndex((prev) =>
285
+ prev >= itemCount - 1 ? firstIndex : prev + 1,
286
+ );
274
287
  },
275
288
  getSelectedIndex: () => selectedIndex,
276
289
  getSelectedMention: () => flatMentionItems[selectedIndex] ?? null,
@@ -278,6 +291,11 @@ export const MentionPopover = forwardRef<
278
291
  if (type !== "/" || selectedIndex >= commands.length) return null;
279
292
  return commands[selectedIndex] ?? null;
280
293
  },
294
+ activateRetry: () => {
295
+ if (!retryable || selectedIndex !== -1) return false;
296
+ onRetry!();
297
+ return true;
298
+ },
281
299
  }));
282
300
 
283
301
  if (!position) return null;
@@ -287,6 +305,7 @@ export const MentionPopover = forwardRef<
287
305
  {/* Backdrop to capture outside clicks */}
288
306
  <div className="fixed inset-0 z-[9998]" onClick={onClose} />
289
307
  <div
308
+ ref={containerRef}
290
309
  data-agent-native-composer-popover="true"
291
310
  data-agent-native-mention-popover="true"
292
311
  className="fixed z-[9999] overflow-y-auto rounded-lg border border-border/80 bg-popover p-1 shadow-2xl"
@@ -308,9 +327,32 @@ export const MentionPopover = forwardRef<
308
327
  ),
309
328
  }}
310
329
  >
330
+ {type === "@" && error && (
331
+ <div
332
+ role="alert"
333
+ className="flex items-center justify-between gap-2 px-3 py-2 text-xs text-destructive"
334
+ >
335
+ <span>{error}</span>
336
+ {onRetry && (
337
+ <button
338
+ type="button"
339
+ data-mention-index={-1}
340
+ className={`shrink-0 rounded px-1.5 py-0.5 font-medium text-foreground ${
341
+ selectedIndex === -1 ? "bg-muted" : "hover:bg-accent"
342
+ }`}
343
+ onMouseEnter={() => setSelectedIndex(-1)}
344
+ onMouseDown={(event) => event.preventDefault()}
345
+ onClick={onRetry}
346
+ >
347
+ {t("agentChat.common.retry", { defaultValue: "Retry" })}
348
+ </button>
349
+ )}
350
+ </div>
351
+ )}
311
352
  {isLoading && itemCount === 0 ? (
312
353
  <LoadingSkeleton />
313
- ) : itemCount === 0 ? (
354
+ ) : itemCount === 0 && type === "@" && error ? null : itemCount ===
355
+ 0 ? (
314
356
  <div className="px-3 py-4 text-center text-xs text-muted-foreground">
315
357
  {type === "@" ? (
316
358
  query ? (
@@ -331,7 +373,7 @@ export const MentionPopover = forwardRef<
331
373
  )}
332
374
  </div>
333
375
  ) : (
334
- <div ref={listRef}>
376
+ <div>
335
377
  {isLoading && <LoadingSkeletonRow />}
336
378
  {type === "@"
337
379
  ? (() => {
@@ -0,0 +1,198 @@
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, type PromptComposerProps } from "./PromptComposer.js";
8
+ import type {
9
+ ComposerTextReference,
10
+ TiptapComposerHandle,
11
+ } from "./TiptapComposer.js";
12
+ import type { Reference } from "./types.js";
13
+
14
+ let container: HTMLDivElement;
15
+ let root: Root;
16
+ let composerRef: React.RefObject<TiptapComposerHandle | null>;
17
+ let reported: Reference[];
18
+
19
+ beforeEach(() => {
20
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
21
+ container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ root = createRoot(container);
24
+ composerRef = React.createRef<TiptapComposerHandle>();
25
+ reported = [];
26
+ });
27
+
28
+ afterEach(() => {
29
+ act(() => root.unmount());
30
+ container.remove();
31
+ vi.unstubAllGlobals();
32
+ });
33
+
34
+ function person(start: number, end: number, email: string, label: string) {
35
+ return {
36
+ start,
37
+ end,
38
+ reference: { label, source: "test", refType: "person", refId: email },
39
+ } satisfies ComposerTextReference;
40
+ }
41
+
42
+ async function render(props: Partial<PromptComposerProps> = {}) {
43
+ await act(async () => {
44
+ root.render(
45
+ <PromptComposer
46
+ composerRef={composerRef}
47
+ onSubmit={vi.fn()}
48
+ onReferencesChange={(references) => (reported = references)}
49
+ showModelSelector={false}
50
+ modelStatusChecksEnabled={false}
51
+ includeDefaultMentionSearch={false}
52
+ includeDefaultSlashSkills={false}
53
+ voiceEnabled={false}
54
+ {...props}
55
+ />,
56
+ );
57
+ });
58
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
59
+ }
60
+
61
+ /** Each line, with every chip written as [refId]. */
62
+ function lines() {
63
+ return [...container.querySelectorAll(".ProseMirror > p")].map((line) =>
64
+ [...line.childNodes]
65
+ .map((node) => {
66
+ const chip =
67
+ node instanceof Element
68
+ ? (node.closest("[data-mention-ref-id]") ??
69
+ node.querySelector("[data-mention-ref-id]"))
70
+ : null;
71
+ return chip
72
+ ? `[${chip.getAttribute("data-mention-ref-id")}]`
73
+ : node.textContent;
74
+ })
75
+ .join(""),
76
+ );
77
+ }
78
+
79
+ describe("composer text with chips", () => {
80
+ it("seeds the initial text with each chip in place of its range", async () => {
81
+ await render({
82
+ initialText: "@Sam Lee and @Sam Lee, thanks",
83
+ initialTextKey: "seeded",
84
+ initialReferences: [
85
+ person(0, 8, "b@example.test", "Sam Lee"),
86
+ person(13, 21, "a@example.test", "Sam Lee"),
87
+ ],
88
+ });
89
+
90
+ expect(lines()).toEqual(["[b@example.test] and [a@example.test], thanks"]);
91
+ expect(reported.map((reference) => reference.refId)).toEqual([
92
+ "b@example.test",
93
+ "a@example.test",
94
+ ]);
95
+ });
96
+
97
+ it("replaces the draft with chips across lines, leaving an overlapping range as text and a space after a final chip", async () => {
98
+ await render({ initialText: "Old draft", initialTextKey: "old" });
99
+
100
+ await act(async () =>
101
+ composerRef.current!.setText("Hi @Ana\n@Bo and @Cy", [
102
+ person(3, 7, "ana@example.test", "Ana"),
103
+ person(16, 19, "cy@example.test", "Cy"),
104
+ person(8, 11, "bo@example.test", "Bo"),
105
+ person(9, 12, "overlap@example.test", "o a"),
106
+ ]),
107
+ );
108
+
109
+ expect(lines()).toEqual([
110
+ "Hi [ana@example.test]",
111
+ "[bo@example.test] and [cy@example.test] ",
112
+ ]);
113
+ expect(reported.map((reference) => reference.refId)).toEqual([
114
+ "ana@example.test",
115
+ "bo@example.test",
116
+ "cy@example.test",
117
+ ]);
118
+ });
119
+
120
+ it("reports each chip as its own @name, apart from the words beside it", async () => {
121
+ let text = "";
122
+ await render({ onTextChange: (next) => (text = next) });
123
+
124
+ await act(async () =>
125
+ composerRef.current!.setText("word@Sam@Bo and @Cyx", [
126
+ person(4, 8, "sam@example.test", "Sam"),
127
+ person(8, 11, "bo@example.test", "Bo"),
128
+ person(16, 19, "cy@example.test", "Cy"),
129
+ ]),
130
+ );
131
+
132
+ expect(lines()).toEqual([
133
+ "word[sam@example.test][bo@example.test] and [cy@example.test]x",
134
+ ]);
135
+ expect(text).toBe("word @Sam @Bo and @Cy x");
136
+ });
137
+
138
+ it("spaces a chip from a combining mark and a letter past U+FFFF", async () => {
139
+ let text = "";
140
+ await render({ onTextChange: (next) => (text = next) });
141
+
142
+ await act(async () =>
143
+ composerRef.current!.setText("José@Sam\u{10400}", [
144
+ person(5, 9, "sam@example.test", "Sam"),
145
+ ]),
146
+ );
147
+
148
+ expect(text).toBe("José @Sam \u{10400}");
149
+ });
150
+ });
151
+
152
+ describe("inserting @ at the cursor", () => {
153
+ afterEach(() => {
154
+ delete (document as { execCommand?: unknown }).execCommand;
155
+ });
156
+
157
+ it("filters and picks a mention without touching the text before the @", async () => {
158
+ await render({
159
+ initialText: "Ping",
160
+ initialTextKey: "ping",
161
+ mentionItems: [
162
+ {
163
+ id: "person:sam",
164
+ label: "Sam Lee",
165
+ source: "test",
166
+ refType: "person",
167
+ refId: "sam@example.test",
168
+ },
169
+ ],
170
+ });
171
+ const editor = () =>
172
+ (
173
+ container.querySelector(".ProseMirror") as HTMLElement & {
174
+ editor: { commands: { insertContent(text: string): boolean } };
175
+ }
176
+ ).editor;
177
+ // A browser applies execCommand's edit to the DOM, and ProseMirror only
178
+ // reads it back after the call returns.
179
+ Object.defineProperty(document, "execCommand", {
180
+ configurable: true,
181
+ value: (_command: string, _showUi: boolean, text: string) => {
182
+ queueMicrotask(() => editor().commands.insertContent(text));
183
+ return true;
184
+ },
185
+ });
186
+
187
+ await act(async () => composerRef.current!.insertTextAtCursor!(" "));
188
+ await act(async () => composerRef.current!.insertTextAtCursor!("@"));
189
+ await act(async () => void editor().commands.insertContent("Sa"));
190
+ const option = [
191
+ ...document.querySelectorAll<HTMLElement>("[data-mention-index]"),
192
+ ].find((item) => item.textContent?.includes("Sam Lee"));
193
+ expect(option).toBeDefined();
194
+ await act(async () => option!.click());
195
+
196
+ expect(lines()).toEqual(["Ping [sam@example.test] "]);
197
+ });
198
+ });
@@ -55,6 +55,7 @@ import {
55
55
  isLocalRuntimeEngine,
56
56
  TiptapComposer,
57
57
  type ComposerAgentOption,
58
+ type ComposerTextReference,
58
59
  type ComposerTextSelection,
59
60
  type ComposerImageModelMenu,
60
61
  type ComposerSubmitIntent,
@@ -188,6 +189,8 @@ export interface PromptComposerProps {
188
189
  initialText?: string;
189
190
  /** Stable key used to re-apply `initialText` when the host picks a preset. */
190
191
  initialTextKey?: string | number;
192
+ /** Chips placed into `initialText`, as the composer handle's `setText` takes them. */
193
+ initialReferences?: readonly ComposerTextReference[];
191
194
  /** Optional host-owned control rendered directly after the "+" button. */
192
195
  modeControl?: ReactNode;
193
196
  /** Current agent execution mode shown in the shared composer toolbar. */
@@ -244,6 +247,9 @@ export interface PromptComposerProps {
244
247
  /** Called whenever the plain editor text changes. */
245
248
  onTextChange?: (text: string) => void;
246
249
  mentionItems?: MentionItem[];
250
+ /** Why some of `mentionItems` could not load, shown in the @ popover. */
251
+ mentionItemsError?: string;
252
+ onRetryMentionItems?: () => void;
247
253
  mentionPopoverDensity?: "default" | "stacked";
248
254
  includeDefaultMentionSearch?: boolean;
249
255
  onReferencesChange?: (references: Reference[]) => void;
@@ -657,6 +663,7 @@ function PromptComposerInner({
657
663
  extensionTools = false,
658
664
  initialText,
659
665
  initialTextKey,
666
+ initialReferences,
660
667
  modeControl,
661
668
  requireAgentEngine = false,
662
669
  execMode,
@@ -689,6 +696,8 @@ function PromptComposerInner({
689
696
  modelStatusChecksEnabled,
690
697
  onTextChange,
691
698
  mentionItems,
699
+ mentionItemsError,
700
+ onRetryMentionItems,
692
701
  mentionPopoverDensity,
693
702
  includeDefaultMentionSearch,
694
703
  onReferencesChange,
@@ -978,6 +987,7 @@ function PromptComposerInner({
978
987
  placeholder={placeholder}
979
988
  initialText={initialText}
980
989
  initialTextKey={initialTextKey}
990
+ initialReferences={initialReferences}
981
991
  onSubmit={handleSubmit}
982
992
  onEmptySubmit={onEmptySubmit}
983
993
  onBeforeSubmit={beforeSubmit}
@@ -1014,6 +1024,8 @@ function PromptComposerInner({
1014
1024
  voiceEnabled={voiceEnabled}
1015
1025
  onTextChange={onTextChange}
1016
1026
  mentionItems={mentionItems}
1027
+ mentionItemsError={mentionItemsError}
1028
+ onRetryMentionItems={onRetryMentionItems}
1017
1029
  mentionPopoverDensity={mentionPopoverDensity}
1018
1030
  includeDefaultMentionSearch={includeDefaultMentionSearch}
1019
1031
  onReferencesChange={onReferencesChange}