@agent-native/toolkit 0.20.9 → 0.21.0-nightly-20260925012751

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 (233) hide show
  1. package/agent-native.eject.json +30 -0
  2. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  3. package/dist/composer/TiptapComposer.js +101 -59
  4. package/dist/composer/TiptapComposer.js.map +1 -1
  5. package/dist/composer/TiptapComposer.spec.js +102 -10
  6. package/dist/composer/TiptapComposer.spec.js.map +1 -1
  7. package/dist/icons/ResourceIcon.d.ts +14 -0
  8. package/dist/icons/ResourceIcon.d.ts.map +1 -0
  9. package/dist/icons/ResourceIcon.js +43 -0
  10. package/dist/icons/ResourceIcon.js.map +1 -0
  11. package/dist/icons/ResourceIcon.spec.d.ts +2 -0
  12. package/dist/icons/ResourceIcon.spec.d.ts.map +1 -0
  13. package/dist/icons/ResourceIcon.spec.js +23 -0
  14. package/dist/icons/ResourceIcon.spec.js.map +1 -0
  15. package/dist/icons/ResourceIconPicker.d.ts +52 -0
  16. package/dist/icons/ResourceIconPicker.d.ts.map +1 -0
  17. package/dist/icons/ResourceIconPicker.js +344 -0
  18. package/dist/icons/ResourceIconPicker.js.map +1 -0
  19. package/dist/icons/catalog.d.ts +17 -0
  20. package/dist/icons/catalog.d.ts.map +1 -0
  21. package/dist/icons/catalog.js +114 -0
  22. package/dist/icons/catalog.js.map +1 -0
  23. package/dist/icons/catalog.spec.d.ts +2 -0
  24. package/dist/icons/catalog.spec.d.ts.map +1 -0
  25. package/dist/icons/catalog.spec.js +29 -0
  26. package/dist/icons/catalog.spec.js.map +1 -0
  27. package/dist/icons/emoji-catalog.d.ts +12 -0
  28. package/dist/icons/emoji-catalog.d.ts.map +1 -0
  29. package/dist/icons/emoji-catalog.js +59 -0
  30. package/dist/icons/emoji-catalog.js.map +1 -0
  31. package/dist/icons/emoji-catalog.spec.d.ts +2 -0
  32. package/dist/icons/emoji-catalog.spec.d.ts.map +1 -0
  33. package/dist/icons/emoji-catalog.spec.js +29 -0
  34. package/dist/icons/emoji-catalog.spec.js.map +1 -0
  35. package/dist/icons/icon-colors.d.ts +3 -0
  36. package/dist/icons/icon-colors.d.ts.map +1 -0
  37. package/dist/icons/icon-colors.js +12 -0
  38. package/dist/icons/icon-colors.js.map +1 -0
  39. package/dist/icons/icon-persistence.spec.d.ts +2 -0
  40. package/dist/icons/icon-persistence.spec.d.ts.map +1 -0
  41. package/dist/icons/icon-persistence.spec.js +38 -0
  42. package/dist/icons/icon-persistence.spec.js.map +1 -0
  43. package/dist/icons/index.d.ts +5 -0
  44. package/dist/icons/index.d.ts.map +1 -0
  45. package/dist/icons/index.js +5 -0
  46. package/dist/icons/index.js.map +1 -0
  47. package/dist/icons/recents.d.ts +6 -0
  48. package/dist/icons/recents.d.ts.map +1 -0
  49. package/dist/icons/recents.js +23 -0
  50. package/dist/icons/recents.js.map +1 -0
  51. package/dist/icons/recents.spec.d.ts +2 -0
  52. package/dist/icons/recents.spec.d.ts.map +1 -0
  53. package/dist/icons/recents.spec.js +29 -0
  54. package/dist/icons/recents.spec.js.map +1 -0
  55. package/dist/icons/server.d.ts +7 -0
  56. package/dist/icons/server.d.ts.map +1 -0
  57. package/dist/icons/server.js +25 -0
  58. package/dist/icons/server.js.map +1 -0
  59. package/dist/icons/tabler-catalog-data.d.ts +31 -0
  60. package/dist/icons/tabler-catalog-data.d.ts.map +1 -0
  61. package/dist/icons/tabler-catalog-data.js +30121 -0
  62. package/dist/icons/tabler-catalog-data.js.map +1 -0
  63. package/dist/icons/tabler-chunk-loaders.d.ts +30 -0
  64. package/dist/icons/tabler-chunk-loaders.d.ts.map +1 -0
  65. package/dist/icons/tabler-chunk-loaders.js +30 -0
  66. package/dist/icons/tabler-chunk-loaders.js.map +1 -0
  67. package/dist/icons/tabler-chunks/a.d.ts +450 -0
  68. package/dist/icons/tabler-chunks/a.d.ts.map +1 -0
  69. package/dist/icons/tabler-chunks/a.js +451 -0
  70. package/dist/icons/tabler-chunks/a.js.map +1 -0
  71. package/dist/icons/tabler-chunks/b.d.ts +849 -0
  72. package/dist/icons/tabler-chunks/b.d.ts.map +1 -0
  73. package/dist/icons/tabler-chunks/b.js +850 -0
  74. package/dist/icons/tabler-chunks/b.js.map +1 -0
  75. package/dist/icons/tabler-chunks/c.d.ts +922 -0
  76. package/dist/icons/tabler-chunks/c.d.ts.map +1 -0
  77. package/dist/icons/tabler-chunks/c.js +923 -0
  78. package/dist/icons/tabler-chunks/c.js.map +1 -0
  79. package/dist/icons/tabler-chunks/d.d.ts +380 -0
  80. package/dist/icons/tabler-chunks/d.d.ts.map +1 -0
  81. package/dist/icons/tabler-chunks/d.js +381 -0
  82. package/dist/icons/tabler-chunks/d.js.map +1 -0
  83. package/dist/icons/tabler-chunks/e.d.ts +112 -0
  84. package/dist/icons/tabler-chunks/e.d.ts.map +1 -0
  85. package/dist/icons/tabler-chunks/e.js +113 -0
  86. package/dist/icons/tabler-chunks/e.js.map +1 -0
  87. package/dist/icons/tabler-chunks/f.d.ts +341 -0
  88. package/dist/icons/tabler-chunks/f.d.ts.map +1 -0
  89. package/dist/icons/tabler-chunks/f.js +342 -0
  90. package/dist/icons/tabler-chunks/f.js.map +1 -0
  91. package/dist/icons/tabler-chunks/g.d.ts +101 -0
  92. package/dist/icons/tabler-chunks/g.d.ts.map +1 -0
  93. package/dist/icons/tabler-chunks/g.js +102 -0
  94. package/dist/icons/tabler-chunks/g.js.map +1 -0
  95. package/dist/icons/tabler-chunks/h.d.ts +276 -0
  96. package/dist/icons/tabler-chunks/h.d.ts.map +1 -0
  97. package/dist/icons/tabler-chunks/h.js +277 -0
  98. package/dist/icons/tabler-chunks/h.js.map +1 -0
  99. package/dist/icons/tabler-chunks/i.d.ts +79 -0
  100. package/dist/icons/tabler-chunks/i.d.ts.map +1 -0
  101. package/dist/icons/tabler-chunks/i.js +80 -0
  102. package/dist/icons/tabler-chunks/i.js.map +1 -0
  103. package/dist/icons/tabler-chunks/j.d.ts +17 -0
  104. package/dist/icons/tabler-chunks/j.d.ts.map +1 -0
  105. package/dist/icons/tabler-chunks/j.js +18 -0
  106. package/dist/icons/tabler-chunks/j.js.map +1 -0
  107. package/dist/icons/tabler-chunks/k.d.ts +25 -0
  108. package/dist/icons/tabler-chunks/k.d.ts.map +1 -0
  109. package/dist/icons/tabler-chunks/k.js +26 -0
  110. package/dist/icons/tabler-chunks/k.js.map +1 -0
  111. package/dist/icons/tabler-chunks/l.d.ts +298 -0
  112. package/dist/icons/tabler-chunks/l.d.ts.map +1 -0
  113. package/dist/icons/tabler-chunks/l.js +299 -0
  114. package/dist/icons/tabler-chunks/l.js.map +1 -0
  115. package/dist/icons/tabler-chunks/m.d.ts +413 -0
  116. package/dist/icons/tabler-chunks/m.d.ts.map +1 -0
  117. package/dist/icons/tabler-chunks/m.js +414 -0
  118. package/dist/icons/tabler-chunks/m.js.map +1 -0
  119. package/dist/icons/tabler-chunks/n.d.ts +174 -0
  120. package/dist/icons/tabler-chunks/n.d.ts.map +1 -0
  121. package/dist/icons/tabler-chunks/n.js +175 -0
  122. package/dist/icons/tabler-chunks/n.js.map +1 -0
  123. package/dist/icons/tabler-chunks/o.d.ts +31 -0
  124. package/dist/icons/tabler-chunks/o.d.ts.map +1 -0
  125. package/dist/icons/tabler-chunks/o.js +32 -0
  126. package/dist/icons/tabler-chunks/o.js.map +1 -0
  127. package/dist/icons/tabler-chunks/p.d.ts +374 -0
  128. package/dist/icons/tabler-chunks/p.d.ts.map +1 -0
  129. package/dist/icons/tabler-chunks/p.js +375 -0
  130. package/dist/icons/tabler-chunks/p.js.map +1 -0
  131. package/dist/icons/tabler-chunks/q.d.ts +14 -0
  132. package/dist/icons/tabler-chunks/q.d.ts.map +1 -0
  133. package/dist/icons/tabler-chunks/q.js +15 -0
  134. package/dist/icons/tabler-chunks/q.js.map +1 -0
  135. package/dist/icons/tabler-chunks/r.d.ts +183 -0
  136. package/dist/icons/tabler-chunks/r.d.ts.map +1 -0
  137. package/dist/icons/tabler-chunks/r.js +184 -0
  138. package/dist/icons/tabler-chunks/r.js.map +1 -0
  139. package/dist/icons/tabler-chunks/s.d.ts +656 -0
  140. package/dist/icons/tabler-chunks/s.d.ts.map +1 -0
  141. package/dist/icons/tabler-chunks/s.js +657 -0
  142. package/dist/icons/tabler-chunks/s.js.map +1 -0
  143. package/dist/icons/tabler-chunks/t.d.ts +242 -0
  144. package/dist/icons/tabler-chunks/t.d.ts.map +1 -0
  145. package/dist/icons/tabler-chunks/t.js +243 -0
  146. package/dist/icons/tabler-chunks/t.js.map +1 -0
  147. package/dist/icons/tabler-chunks/u.d.ts +62 -0
  148. package/dist/icons/tabler-chunks/u.d.ts.map +1 -0
  149. package/dist/icons/tabler-chunks/u.js +63 -0
  150. package/dist/icons/tabler-chunks/u.js.map +1 -0
  151. package/dist/icons/tabler-chunks/v.d.ts +60 -0
  152. package/dist/icons/tabler-chunks/v.d.ts.map +1 -0
  153. package/dist/icons/tabler-chunks/v.js +61 -0
  154. package/dist/icons/tabler-chunks/v.js.map +1 -0
  155. package/dist/icons/tabler-chunks/w.d.ts +115 -0
  156. package/dist/icons/tabler-chunks/w.d.ts.map +1 -0
  157. package/dist/icons/tabler-chunks/w.js +116 -0
  158. package/dist/icons/tabler-chunks/w.js.map +1 -0
  159. package/dist/icons/tabler-chunks/x.d.ts +18 -0
  160. package/dist/icons/tabler-chunks/x.d.ts.map +1 -0
  161. package/dist/icons/tabler-chunks/x.js +19 -0
  162. package/dist/icons/tabler-chunks/x.js.map +1 -0
  163. package/dist/icons/tabler-chunks/y.d.ts +7 -0
  164. package/dist/icons/tabler-chunks/y.d.ts.map +1 -0
  165. package/dist/icons/tabler-chunks/y.js +4 -0
  166. package/dist/icons/tabler-chunks/y.js.map +1 -0
  167. package/dist/icons/tabler-chunks/z.d.ts +51 -0
  168. package/dist/icons/tabler-chunks/z.d.ts.map +1 -0
  169. package/dist/icons/tabler-chunks/z.js +52 -0
  170. package/dist/icons/tabler-chunks/z.js.map +1 -0
  171. package/dist/icons/tabler-groups.d.ts +30 -0
  172. package/dist/icons/tabler-groups.d.ts.map +1 -0
  173. package/dist/icons/tabler-groups.js +65 -0
  174. package/dist/icons/tabler-groups.js.map +1 -0
  175. package/dist/icons/types.d.ts +25 -0
  176. package/dist/icons/types.d.ts.map +1 -0
  177. package/dist/icons/types.js +2 -0
  178. package/dist/icons/types.js.map +1 -0
  179. package/dist/index.d.ts +1 -0
  180. package/dist/index.d.ts.map +1 -1
  181. package/dist/index.js +1 -0
  182. package/dist/index.js.map +1 -1
  183. package/dist/styles.css +2 -0
  184. package/dist/theme/resource-icons.css +24 -0
  185. package/package.json +11 -2
  186. package/src/composer/TiptapComposer.spec.ts +142 -15
  187. package/src/composer/TiptapComposer.tsx +109 -58
  188. package/src/icons/ResourceIcon.spec.tsx +38 -0
  189. package/src/icons/ResourceIcon.tsx +91 -0
  190. package/src/icons/ResourceIconPicker.tsx +812 -0
  191. package/src/icons/catalog.spec.ts +50 -0
  192. package/src/icons/catalog.ts +153 -0
  193. package/src/icons/emoji-catalog.spec.ts +46 -0
  194. package/src/icons/emoji-catalog.ts +86 -0
  195. package/src/icons/icon-colors.ts +13 -0
  196. package/src/icons/icon-persistence.spec.ts +45 -0
  197. package/src/icons/index.ts +4 -0
  198. package/src/icons/recents.spec.ts +37 -0
  199. package/src/icons/recents.ts +37 -0
  200. package/src/icons/server.tsx +53 -0
  201. package/src/icons/tabler-catalog-data.ts +30122 -0
  202. package/src/icons/tabler-chunk-loaders.ts +29 -0
  203. package/src/icons/tabler-chunks/a.ts +897 -0
  204. package/src/icons/tabler-chunks/b.ts +1695 -0
  205. package/src/icons/tabler-chunks/c.ts +1841 -0
  206. package/src/icons/tabler-chunks/d.ts +757 -0
  207. package/src/icons/tabler-chunks/e.ts +221 -0
  208. package/src/icons/tabler-chunks/f.ts +679 -0
  209. package/src/icons/tabler-chunks/g.ts +199 -0
  210. package/src/icons/tabler-chunks/h.ts +549 -0
  211. package/src/icons/tabler-chunks/i.ts +155 -0
  212. package/src/icons/tabler-chunks/j.ts +31 -0
  213. package/src/icons/tabler-chunks/k.ts +47 -0
  214. package/src/icons/tabler-chunks/l.ts +593 -0
  215. package/src/icons/tabler-chunks/m.ts +823 -0
  216. package/src/icons/tabler-chunks/n.ts +345 -0
  217. package/src/icons/tabler-chunks/o.ts +59 -0
  218. package/src/icons/tabler-chunks/p.ts +745 -0
  219. package/src/icons/tabler-chunks/q.ts +25 -0
  220. package/src/icons/tabler-chunks/r.ts +363 -0
  221. package/src/icons/tabler-chunks/s.ts +1309 -0
  222. package/src/icons/tabler-chunks/t.ts +481 -0
  223. package/src/icons/tabler-chunks/u.ts +121 -0
  224. package/src/icons/tabler-chunks/v.ts +117 -0
  225. package/src/icons/tabler-chunks/w.ts +227 -0
  226. package/src/icons/tabler-chunks/x.ts +33 -0
  227. package/src/icons/tabler-chunks/y.ts +3 -0
  228. package/src/icons/tabler-chunks/z.ts +99 -0
  229. package/src/icons/tabler-groups.ts +70 -0
  230. package/src/icons/types.ts +35 -0
  231. package/src/index.ts +1 -0
  232. package/src/styles.css +2 -0
  233. package/src/theme/resource-icons.css +24 -0
@@ -0,0 +1,812 @@
1
+ import {
2
+ IconCheck,
3
+ IconChevronRight,
4
+ IconPhotoPlus,
5
+ IconSearch,
6
+ } from "@tabler/icons-react";
7
+ import * as React from "react";
8
+
9
+ import { Button } from "../ui/button.js";
10
+ import { Input } from "../ui/input.js";
11
+ import {
12
+ Popover,
13
+ PopoverAnchor,
14
+ PopoverContent,
15
+ PopoverTrigger,
16
+ } from "../ui/popover.js";
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectGroup,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from "../ui/select.js";
25
+ import { Tabs, TabsList, TabsTrigger } from "../ui/tabs.js";
26
+ import { cn } from "../utils.js";
27
+ import { loadTablerCatalog } from "./catalog.js";
28
+ import { loadEmojiCatalog, EMOJI_CATEGORIES } from "./emoji-catalog.js";
29
+ import { resourceIconColorClasses } from "./icon-colors.js";
30
+ import { useResourceIconRecents } from "./recents.js";
31
+ import { ResourceIcon } from "./ResourceIcon.js";
32
+ import { TABLER_ICON_GROUPS } from "./tabler-groups.js";
33
+ import type {
34
+ ResourceIconColor,
35
+ ResourceIconImage,
36
+ ResourceIconValue,
37
+ } from "./types.js";
38
+
39
+ const colors: Array<ResourceIconColor | undefined> = [
40
+ undefined,
41
+ "gray",
42
+ "brown",
43
+ "orange",
44
+ "yellow",
45
+ "green",
46
+ "blue",
47
+ "purple",
48
+ "pink",
49
+ "red",
50
+ ];
51
+ const columns = 7;
52
+ const rowHeight = 36;
53
+ const MAX_ICON_UPLOAD_BYTES = 5 * 1024 * 1024;
54
+
55
+ export interface ResourceIconPickerLabels {
56
+ trigger: string;
57
+ iconsTab: string;
58
+ emojiTab: string;
59
+ uploadTab: string;
60
+ search: string;
61
+ noResults: string;
62
+ recents: string;
63
+ colors: string;
64
+ defaultColor: string;
65
+ remove: string;
66
+ upload: string;
67
+ uploading: string;
68
+ loadError?: string;
69
+ saveError?: string;
70
+ retry?: string;
71
+ uploadHint?: string;
72
+ uploadTooLarge?: string;
73
+ allCategories?: string;
74
+ colorNames?: Partial<Record<ResourceIconColor, string>>;
75
+ categoryNames?: Record<string, string>;
76
+ groupNames?: Record<string, string>;
77
+ }
78
+
79
+ export interface ResourceIconPickerProps {
80
+ value: ResourceIconValue | null;
81
+ onValueChange: (value: ResourceIconValue | null) => void | Promise<void>;
82
+ labels: ResourceIconPickerLabels;
83
+ recentValues?: readonly ResourceIconValue[];
84
+ onRecentsChange?: (recents: ResourceIconValue[]) => void;
85
+ uploadedImages?: readonly ResourceIconImage[];
86
+ onUpload?: (file: File) => Promise<ResourceIconImage>;
87
+ onUploadError?: (error: unknown) => void;
88
+ resolveImageUrl?: (
89
+ image: Extract<ResourceIconValue, { kind: "image" }>,
90
+ ) => string | undefined;
91
+ disabled?: boolean;
92
+ className?: string;
93
+ contentClassName?: string;
94
+ portalled?: boolean;
95
+ container?: React.ComponentProps<typeof PopoverContent>["container"];
96
+ open?: boolean;
97
+ onOpenChange?: (open: boolean) => void;
98
+ anchored?: boolean;
99
+ anchorElement?: HTMLElement | null;
100
+ children?: React.ReactNode;
101
+ }
102
+
103
+ export function persistedIconValue(
104
+ value: ResourceIconValue,
105
+ ): ResourceIconValue {
106
+ if (value.kind !== "image") return value;
107
+ return {
108
+ version: 1,
109
+ kind: "image",
110
+ assetId: value.assetId,
111
+ authority: value.authority,
112
+ ...(value.alt === undefined ? {} : { alt: value.alt }),
113
+ };
114
+ }
115
+
116
+ type ChoiceItem = {
117
+ value: ResourceIconValue;
118
+ label: string;
119
+ category: string;
120
+ search: string;
121
+ };
122
+ type CatalogRow =
123
+ | { id: string; label: string; collapsed: boolean; depth: number }
124
+ | { items: ChoiceItem[] };
125
+ type CatalogSection = {
126
+ id: string;
127
+ label: string;
128
+ categories: readonly string[];
129
+ initiallyCollapsed?: boolean;
130
+ children?: CatalogSection[];
131
+ };
132
+ function choiceLabel(value: ResourceIconValue) {
133
+ return value.kind === "emoji"
134
+ ? value.emoji
135
+ : value.kind === "library"
136
+ ? value.name.replaceAll("-", " ")
137
+ : (value.alt ?? value.assetId);
138
+ }
139
+
140
+ function IconGrid({
141
+ items,
142
+ sections,
143
+ labels,
144
+ recents,
145
+ query,
146
+ selectedKey,
147
+ resolveImageUrl,
148
+ onSelect,
149
+ }: {
150
+ items: ChoiceItem[];
151
+ sections: CatalogSection[];
152
+ labels: ResourceIconPickerLabels;
153
+ recents: readonly ResourceIconValue[];
154
+ query: string;
155
+ selectedKey: string;
156
+ resolveImageUrl?: ResourceIconPickerProps["resolveImageUrl"];
157
+ onSelect: (value: ResourceIconValue) => void;
158
+ }) {
159
+ const viewport = React.useRef<HTMLDivElement>(null);
160
+ const [scrollTop, setScrollTop] = React.useState(0);
161
+ const [collapsed, setCollapsed] = React.useState<Set<string>>(
162
+ () =>
163
+ new Set(
164
+ sections.flatMap((section) => [
165
+ ...(section.initiallyCollapsed ? [section.id] : []),
166
+ ...(section.children ?? [])
167
+ .filter((child) => child.initiallyCollapsed)
168
+ .map((child) => child.id),
169
+ ]),
170
+ ),
171
+ );
172
+ const normalized = query.trim().toLowerCase();
173
+ const rows = React.useMemo(() => {
174
+ const result: CatalogRow[] = [];
175
+ const addRows = (entries: ChoiceItem[]) => {
176
+ if (!entries.length) return;
177
+ for (let i = 0; i < entries.length; i += columns)
178
+ result.push({ items: entries.slice(i, i + columns) });
179
+ };
180
+ const addSection = (section: CatalogSection, depth = 0) => {
181
+ const entries = items.filter((item) =>
182
+ section.categories.includes(item.category),
183
+ );
184
+ if (!entries.length) return;
185
+ result.push({
186
+ id: section.id,
187
+ label: section.label,
188
+ collapsed: collapsed.has(section.id),
189
+ depth,
190
+ });
191
+ if (collapsed.has(section.id)) return;
192
+ if (section.children) {
193
+ for (const child of section.children) addSection(child, depth + 1);
194
+ } else addRows(entries);
195
+ };
196
+ if (normalized)
197
+ addRows(
198
+ items.filter(
199
+ (item) =>
200
+ (item.value.kind === "emoji" &&
201
+ item.value.emoji === query.trim()) ||
202
+ normalized
203
+ .split(/\s+/u)
204
+ .every((part) => item.search.includes(part)),
205
+ ),
206
+ );
207
+ else {
208
+ if (recents.length) {
209
+ result.push({
210
+ id: "recent",
211
+ label: labels.recents,
212
+ collapsed: collapsed.has("recent"),
213
+ depth: 0,
214
+ });
215
+ if (!collapsed.has("recent"))
216
+ addRows(
217
+ recents.slice(0, columns).map((value) => ({
218
+ value,
219
+ label: choiceLabel(value),
220
+ category: "recent",
221
+ search: "",
222
+ })),
223
+ );
224
+ }
225
+ for (const section of sections) addSection(section);
226
+ }
227
+ return result;
228
+ }, [items, sections, labels, recents, query, normalized, collapsed]);
229
+ React.useEffect(() => {
230
+ viewport.current?.scrollTo({ top: 0 });
231
+ setScrollTop(0);
232
+ }, [normalized]);
233
+ const maxScrollTop = Math.max(0, rows.length * rowHeight - 256);
234
+ const first = Math.max(
235
+ 0,
236
+ Math.floor(Math.min(scrollTop, maxScrollTop) / rowHeight) - 2,
237
+ );
238
+ const last = Math.min(rows.length, first + 13);
239
+ return (
240
+ <div
241
+ ref={viewport}
242
+ className="h-64 overflow-y-auto overscroll-contain px-2"
243
+ onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
244
+ >
245
+ {rows.length === 0 ? (
246
+ <p
247
+ className="py-10 text-center text-sm text-muted-foreground"
248
+ role="status"
249
+ >
250
+ {labels.noResults}
251
+ </p>
252
+ ) : (
253
+ <div className="relative" style={{ height: rows.length * rowHeight }}>
254
+ {rows.slice(first, last).map((row, index) => (
255
+ <div
256
+ key={first + index}
257
+ className="absolute inset-x-0"
258
+ style={{ top: (first + index) * rowHeight, height: rowHeight }}
259
+ >
260
+ {"label" in row ? (
261
+ <button
262
+ type="button"
263
+ aria-expanded={!row.collapsed}
264
+ onClick={() =>
265
+ setCollapsed((current) => {
266
+ const next = new Set(current);
267
+ if (next.has(row.id)) next.delete(row.id);
268
+ else next.add(row.id);
269
+ return next;
270
+ })
271
+ }
272
+ className={cn(
273
+ "flex h-full w-full items-end gap-1 rounded px-1 pb-1 text-start text-xs font-medium text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
274
+ row.depth > 0 && "ps-4",
275
+ )}
276
+ >
277
+ <IconChevronRight
278
+ aria-hidden
279
+ className={cn(
280
+ "size-3.5 shrink-0 transition-transform",
281
+ !row.collapsed && "rotate-90",
282
+ )}
283
+ />
284
+ {row.label}
285
+ </button>
286
+ ) : (
287
+ <div className="grid h-full grid-cols-7 gap-1">
288
+ {row.items.map((item) => (
289
+ <button
290
+ key={JSON.stringify(item.value)}
291
+ type="button"
292
+ title={item.label}
293
+ aria-label={item.label}
294
+ aria-pressed={selectedKey === JSON.stringify(item.value)}
295
+ onClick={() => onSelect(item.value)}
296
+ className={cn(
297
+ "flex h-9 min-w-0 items-center justify-center rounded-md hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
298
+ selectedKey === JSON.stringify(item.value) &&
299
+ "bg-accent ring-1 ring-inset ring-ring",
300
+ )}
301
+ >
302
+ <ResourceIcon
303
+ value={item.value}
304
+ size={20}
305
+ resolveImageUrl={resolveImageUrl}
306
+ />
307
+ </button>
308
+ ))}
309
+ </div>
310
+ )}
311
+ </div>
312
+ ))}
313
+ </div>
314
+ )}
315
+ </div>
316
+ );
317
+ }
318
+
319
+ export function ResourceIconPicker({
320
+ value,
321
+ onValueChange,
322
+ labels,
323
+ recentValues,
324
+ onRecentsChange,
325
+ uploadedImages = [],
326
+ onUpload,
327
+ onUploadError,
328
+ resolveImageUrl,
329
+ disabled,
330
+ className,
331
+ contentClassName,
332
+ portalled,
333
+ container,
334
+ open: controlledOpen,
335
+ onOpenChange,
336
+ anchored,
337
+ anchorElement,
338
+ children,
339
+ }: ResourceIconPickerProps) {
340
+ const [localRecents, setLocalRecents] = React.useState<ResourceIconValue[]>(
341
+ [],
342
+ );
343
+ const [internalOpen, setInternalOpen] = React.useState(false);
344
+ const open = controlledOpen ?? internalOpen;
345
+ const changeOpen = (next: boolean) => {
346
+ if (controlledOpen === undefined) setInternalOpen(next);
347
+ onOpenChange?.(next);
348
+ };
349
+ const [tab, setTab] = React.useState("icons");
350
+ const [query, setQuery] = React.useState("");
351
+ const [category, setCategory] = React.useState("all");
352
+ const [catalog, setCatalog] = React.useState<ChoiceItem[]>([]);
353
+ const [emoji, setEmoji] = React.useState<ChoiceItem[]>([]);
354
+ const [loading, setLoading] = React.useState(false);
355
+ const [loadFailed, setLoadFailed] = React.useState(false);
356
+ const [retry, setRetry] = React.useState(0);
357
+ const [uploading, setUploading] = React.useState(false);
358
+ const [saveFailed, setSaveFailed] = React.useState(false);
359
+ const [uploadError, setUploadError] = React.useState<string>();
360
+ const [color, setColor] = React.useState<ResourceIconColor | undefined>(
361
+ value?.kind === "library" ? value.color : undefined,
362
+ );
363
+ const fileInput = React.useRef<HTMLInputElement>(null);
364
+ const saveQueue = React.useRef<Promise<void>>(Promise.resolve());
365
+ const effectiveRecents = recentValues ?? localRecents;
366
+ React.useEffect(() => {
367
+ if (!open) return;
368
+ setColor(value?.kind === "library" ? value.color : undefined);
369
+ try {
370
+ const parsed: unknown = JSON.parse(
371
+ window.localStorage.getItem("agent-native.resource-icon-recents") ??
372
+ "[]",
373
+ );
374
+ if (Array.isArray(parsed))
375
+ setLocalRecents(
376
+ parsed
377
+ .filter(
378
+ (entry) =>
379
+ entry &&
380
+ entry.version === 1 &&
381
+ ["emoji", "library", "image"].includes(entry.kind),
382
+ )
383
+ .slice(0, 24)
384
+ .map(persistedIconValue),
385
+ );
386
+ // coercion-ok: recents are optional browser convenience; selection does not depend on storage.
387
+ } catch {}
388
+ }, [open]);
389
+ const remember = useResourceIconRecents(effectiveRecents, (next) => {
390
+ if (recentValues === undefined) {
391
+ setLocalRecents(next);
392
+ try {
393
+ window.localStorage.setItem(
394
+ "agent-native.resource-icon-recents",
395
+ JSON.stringify(next),
396
+ );
397
+ } catch {
398
+ // coercion-ok: recents are optional browser convenience; selection does not depend on storage.
399
+ }
400
+ }
401
+ onRecentsChange?.(next);
402
+ });
403
+ React.useEffect(() => {
404
+ if (!open || tab === "upload") return;
405
+ let active = true;
406
+ setLoading(true);
407
+ setLoadFailed(false);
408
+ const request =
409
+ tab === "icons"
410
+ ? loadTablerCatalog().then((entries) =>
411
+ entries.map(
412
+ (entry): ChoiceItem => ({
413
+ category: entry.category,
414
+ search: entry.search,
415
+ label: entry.name.replaceAll("-", " "),
416
+ value: {
417
+ version: 1,
418
+ kind: "library",
419
+ library: "tabler",
420
+ name: entry.name.endsWith("-filled")
421
+ ? entry.name.slice(0, -7)
422
+ : entry.name,
423
+ ...(entry.name.endsWith("-filled")
424
+ ? { variant: "filled" }
425
+ : {}),
426
+ },
427
+ }),
428
+ ),
429
+ )
430
+ : loadEmojiCatalog().then((entries) =>
431
+ entries.map(
432
+ (entry): ChoiceItem => ({
433
+ category: entry.category,
434
+ search: entry.search,
435
+ label: entry.label,
436
+ value: { version: 1, kind: "emoji", emoji: entry.emoji },
437
+ }),
438
+ ),
439
+ );
440
+ void request
441
+ .then((entries) => {
442
+ if (active) tab === "icons" ? setCatalog(entries) : setEmoji(entries);
443
+ })
444
+ .catch((error) => {
445
+ console.error("Icon catalog could not load", error);
446
+ if (active) setLoadFailed(true);
447
+ })
448
+ .finally(() => {
449
+ if (active) setLoading(false);
450
+ });
451
+ return () => {
452
+ active = false;
453
+ };
454
+ }, [open, tab, retry]);
455
+ const select = async (next: ResourceIconValue | null, close = true) => {
456
+ setSaveFailed(false);
457
+ setUploadError(undefined);
458
+ const persisted = next ? persistedIconValue(next) : null;
459
+ try {
460
+ const save = saveQueue.current.then(() => onValueChange(persisted));
461
+ saveQueue.current = save.catch(() => {});
462
+ await save;
463
+ if (persisted) remember(persisted);
464
+ if (close) changeOpen(false);
465
+ } catch (error) {
466
+ console.error("Icon could not be saved", error);
467
+ setSaveFailed(true);
468
+ }
469
+ };
470
+ const upload = async (file: File) => {
471
+ if (!onUpload) return;
472
+ if (file.size > MAX_ICON_UPLOAD_BYTES) {
473
+ setSaveFailed(false);
474
+ setUploadError(labels.uploadTooLarge ?? labels.saveError);
475
+ if (fileInput.current) fileInput.current.value = "";
476
+ return;
477
+ }
478
+ setUploading(true);
479
+ setSaveFailed(false);
480
+ setUploadError(undefined);
481
+ try {
482
+ await select(await onUpload(file));
483
+ } catch (error) {
484
+ setSaveFailed(true);
485
+ onUploadError?.(error);
486
+ } finally {
487
+ setUploading(false);
488
+ if (fileInput.current) fileInput.current.value = "";
489
+ }
490
+ };
491
+ const coloredCatalog = React.useMemo(
492
+ () =>
493
+ catalog.map((item) => ({
494
+ ...item,
495
+ value:
496
+ item.value.kind === "library" ? { ...item.value, color } : item.value,
497
+ })),
498
+ [catalog, color],
499
+ );
500
+ const sections: CatalogSection[] =
501
+ tab === "icons"
502
+ ? TABLER_ICON_GROUPS.map((group) => ({
503
+ id: group.id,
504
+ label: labels.groupNames?.[group.id] ?? group.label,
505
+ categories: group.categories,
506
+ initiallyCollapsed: category === "all",
507
+ ...(group.id === "more"
508
+ ? {
509
+ children: group.categories.map((sourceCategory) => ({
510
+ id: sourceCategory,
511
+ label:
512
+ labels.categoryNames?.[sourceCategory] ?? sourceCategory,
513
+ categories: [sourceCategory],
514
+ initiallyCollapsed: true,
515
+ })),
516
+ }
517
+ : {}),
518
+ }))
519
+ : EMOJI_CATEGORIES.map((sourceCategory) => ({
520
+ id: sourceCategory,
521
+ label: labels.categoryNames?.[sourceCategory] ?? sourceCategory,
522
+ categories: [sourceCategory],
523
+ }));
524
+ const uploadChoices = [
525
+ ...uploadedImages,
526
+ ...effectiveRecents.filter(
527
+ (entry): entry is ResourceIconImage => entry.kind === "image",
528
+ ),
529
+ ].filter(
530
+ (entry, index, all) =>
531
+ all.findIndex(
532
+ (other) =>
533
+ other.assetId === entry.assetId &&
534
+ other.authority === entry.authority,
535
+ ) === index,
536
+ );
537
+ return (
538
+ <Popover
539
+ open={open}
540
+ onOpenChange={(next) => {
541
+ changeOpen(next);
542
+ if (next) {
543
+ setQuery("");
544
+ setSaveFailed(false);
545
+ setUploadError(undefined);
546
+ }
547
+ }}
548
+ >
549
+ {anchored ? (
550
+ anchorElement ? (
551
+ <PopoverAnchor virtualRef={{ current: anchorElement }} />
552
+ ) : null
553
+ ) : (
554
+ <PopoverTrigger asChild disabled={disabled}>
555
+ {children ?? (
556
+ <Button
557
+ type="button"
558
+ variant="ghost"
559
+ size="icon"
560
+ aria-label={labels.trigger}
561
+ >
562
+ <ResourceIcon
563
+ value={value}
564
+ fallback={<IconPhotoPlus />}
565
+ resolveImageUrl={resolveImageUrl}
566
+ />
567
+ </Button>
568
+ )}
569
+ </PopoverTrigger>
570
+ )}
571
+ <PopoverContent
572
+ align="start"
573
+ collisionPadding={8}
574
+ portalled={portalled}
575
+ container={container}
576
+ className={cn(
577
+ "max-h-[var(--radix-popover-content-available-height)] w-80 max-w-[calc(100vw-1rem)] overflow-y-auto p-0",
578
+ className,
579
+ contentClassName,
580
+ )}
581
+ >
582
+ <Tabs
583
+ value={tab}
584
+ onValueChange={(next) => {
585
+ setTab(next);
586
+ setQuery("");
587
+ setCategory("all");
588
+ setSaveFailed(false);
589
+ setUploadError(undefined);
590
+ }}
591
+ >
592
+ <div className="p-2">
593
+ <TabsList className="grid w-full grid-cols-3">
594
+ <TabsTrigger value="icons">{labels.iconsTab}</TabsTrigger>
595
+ <TabsTrigger value="emoji">{labels.emojiTab}</TabsTrigger>
596
+ <TabsTrigger value="upload">{labels.uploadTab}</TabsTrigger>
597
+ </TabsList>
598
+ </div>
599
+ {tab !== "upload" && (
600
+ <div className="relative mx-3 mb-2">
601
+ <IconSearch
602
+ aria-hidden
603
+ className="pointer-events-none absolute start-2 top-2 size-4 text-muted-foreground"
604
+ />
605
+ <Input
606
+ value={query}
607
+ onChange={(event) => setQuery(event.target.value)}
608
+ aria-label={labels.search}
609
+ placeholder={labels.search}
610
+ className="h-8 ps-8 focus-visible:ring-1"
611
+ />
612
+ </div>
613
+ )}
614
+ {tab === "icons" && (
615
+ <div
616
+ className="flex items-center justify-between px-3 pb-2"
617
+ aria-label={labels.colors}
618
+ >
619
+ {colors.map((nextColor) => (
620
+ <button
621
+ key={nextColor ?? "default"}
622
+ type="button"
623
+ title={
624
+ nextColor
625
+ ? (labels.colorNames?.[nextColor] ?? nextColor)
626
+ : labels.defaultColor
627
+ }
628
+ aria-label={
629
+ nextColor
630
+ ? (labels.colorNames?.[nextColor] ?? nextColor)
631
+ : labels.defaultColor
632
+ }
633
+ aria-pressed={color === nextColor}
634
+ onClick={() => {
635
+ setColor(nextColor);
636
+ if (value?.kind === "library")
637
+ void select({ ...value, color: nextColor }, false);
638
+ }}
639
+ className={cn(
640
+ "flex size-7 shrink-0 items-center justify-center rounded-md border border-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
641
+ color === nextColor && "border-border bg-accent",
642
+ )}
643
+ >
644
+ <span
645
+ className={cn(
646
+ "flex size-4 items-center justify-center rounded-full bg-current",
647
+ nextColor
648
+ ? resourceIconColorClasses[nextColor]
649
+ : "text-foreground",
650
+ )}
651
+ >
652
+ {color === nextColor && (
653
+ <IconCheck
654
+ className="size-3 text-background"
655
+ stroke={3}
656
+ />
657
+ )}
658
+ </span>
659
+ </button>
660
+ ))}
661
+ </div>
662
+ )}
663
+ {tab !== "upload" && !query && (
664
+ <div className="px-3 pb-2">
665
+ <Select value={category} onValueChange={setCategory}>
666
+ <SelectTrigger
667
+ className="h-7 border-0 px-1 text-xs text-muted-foreground shadow-none"
668
+ aria-label={labels.allCategories}
669
+ >
670
+ <SelectValue />
671
+ </SelectTrigger>
672
+ <SelectContent className="z-[320]" container={container}>
673
+ <SelectGroup>
674
+ <SelectItem value="all">{labels.allCategories}</SelectItem>
675
+ {sections.map((section) => (
676
+ <SelectItem key={section.id} value={section.id}>
677
+ {section.label}
678
+ </SelectItem>
679
+ ))}
680
+ </SelectGroup>
681
+ </SelectContent>
682
+ </Select>
683
+ </div>
684
+ )}
685
+ {tab === "upload" ? (
686
+ <div className="grid gap-3 p-3 pt-1">
687
+ {onUpload && (
688
+ <button
689
+ type="button"
690
+ disabled={uploading}
691
+ className="flex min-h-28 flex-col items-center justify-center gap-2 rounded-md border border-dashed px-4 py-5 text-sm hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
692
+ onClick={() => fileInput.current?.click()}
693
+ onDragOver={(event) => event.preventDefault()}
694
+ onDrop={(event) => {
695
+ event.preventDefault();
696
+ const file = event.dataTransfer.files[0];
697
+ if (file && !uploading) void upload(file);
698
+ }}
699
+ >
700
+ <IconPhotoPlus className="size-6 text-muted-foreground" />
701
+ <span>{uploading ? labels.uploading : labels.upload}</span>
702
+ {labels.uploadHint && (
703
+ <span className="text-xs text-muted-foreground">
704
+ {labels.uploadHint}
705
+ </span>
706
+ )}
707
+ </button>
708
+ )}
709
+ <input
710
+ ref={fileInput}
711
+ type="file"
712
+ accept="image/png,image/jpeg,image/webp,image/svg+xml"
713
+ className="hidden"
714
+ disabled={uploading}
715
+ onChange={(event) => {
716
+ const file = event.target.files?.[0];
717
+ if (file) void upload(file);
718
+ }}
719
+ />
720
+ {uploadChoices.length > 0 && (
721
+ <div className="grid grid-cols-7 gap-1">
722
+ {uploadChoices.map((entry) => (
723
+ <button
724
+ key={`${entry.authority}:${entry.assetId}`}
725
+ type="button"
726
+ title={entry.alt}
727
+ aria-label={entry.alt ?? labels.upload}
728
+ className="flex size-9 items-center justify-center rounded-md hover:bg-accent"
729
+ onClick={() => void select(entry)}
730
+ >
731
+ <ResourceIcon
732
+ value={entry}
733
+ size={24}
734
+ resolveImageUrl={resolveImageUrl}
735
+ />
736
+ </button>
737
+ ))}
738
+ </div>
739
+ )}
740
+ </div>
741
+ ) : loadFailed ? (
742
+ <div
743
+ role="alert"
744
+ className="grid h-64 content-center justify-items-center gap-3 px-4 text-sm"
745
+ >
746
+ <p>{labels.loadError}</p>
747
+ <Button
748
+ variant="outline"
749
+ size="sm"
750
+ onClick={() => setRetry((current) => current + 1)}
751
+ >
752
+ {labels.retry}
753
+ </Button>
754
+ </div>
755
+ ) : loading ? (
756
+ <div
757
+ aria-busy="true"
758
+ className="grid h-64 grid-cols-7 content-start gap-2 p-3"
759
+ >
760
+ {Array.from({ length: 28 }, (_, index) => (
761
+ <div
762
+ key={index}
763
+ className="size-7 animate-pulse rounded-md bg-muted"
764
+ />
765
+ ))}
766
+ </div>
767
+ ) : (
768
+ <IconGrid
769
+ key={`${tab}:${category}`}
770
+ items={tab === "icons" ? coloredCatalog : emoji}
771
+ sections={
772
+ category === "all" || query
773
+ ? sections
774
+ : sections.filter((section) => section.id === category)
775
+ }
776
+ labels={labels}
777
+ recents={
778
+ category === "all"
779
+ ? effectiveRecents.filter(
780
+ (entry) =>
781
+ entry.kind === (tab === "icons" ? "library" : "emoji"),
782
+ )
783
+ : []
784
+ }
785
+ query={query}
786
+ selectedKey={value ? JSON.stringify(value) : ""}
787
+ resolveImageUrl={resolveImageUrl}
788
+ onSelect={(next) => void select(next)}
789
+ />
790
+ )}
791
+ </Tabs>
792
+ {(saveFailed || uploadError) && (
793
+ <p role="alert" className="px-3 py-2 text-sm text-destructive">
794
+ {uploadError ?? labels.saveError}
795
+ </p>
796
+ )}
797
+ {value && (
798
+ <div className="border-t p-1">
799
+ <Button
800
+ type="button"
801
+ variant="ghost"
802
+ className="h-8 w-full text-xs text-muted-foreground"
803
+ onClick={() => void select(null)}
804
+ >
805
+ {labels.remove}
806
+ </Button>
807
+ </div>
808
+ )}
809
+ </PopoverContent>
810
+ </Popover>
811
+ );
812
+ }