@agent-native/toolkit 0.6.0 → 0.7.0

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 (173) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/package.json +17 -1
  16. package/src/app-shell/header-actions.tsx +84 -0
  17. package/src/app-shell/index.ts +1 -0
  18. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  19. package/src/chat-history/ChatHistoryList.tsx +469 -0
  20. package/src/chat-history/index.ts +7 -0
  21. package/src/chat-history.css +314 -0
  22. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  23. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  24. package/src/collab-ui/PresenceBar.tsx +346 -0
  25. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  26. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  27. package/src/collab-ui/agent-identity.ts +16 -0
  28. package/src/collab-ui/index.ts +39 -0
  29. package/src/collab-ui/lead-client.ts +32 -0
  30. package/src/collab-ui/types.spec.ts +18 -0
  31. package/src/collab-ui/types.ts +96 -0
  32. package/src/composer/AgentComposerFrame.tsx +59 -0
  33. package/src/composer/ComposerPlusMenu.tsx +983 -0
  34. package/src/composer/MentionPopover.tsx +412 -0
  35. package/src/composer/PastedTextChip.tsx +150 -0
  36. package/src/composer/PromptComposer.spec.ts +73 -0
  37. package/src/composer/PromptComposer.tsx +718 -0
  38. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  39. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  40. package/src/composer/TiptapComposer.spec.ts +390 -0
  41. package/src/composer/TiptapComposer.tsx +2936 -0
  42. package/src/composer/VoiceButton.spec.ts +33 -0
  43. package/src/composer/VoiceButton.tsx +255 -0
  44. package/src/composer/asset-picker-url.spec.ts +56 -0
  45. package/src/composer/asset-picker-url.ts +80 -0
  46. package/src/composer/attachment-accept.spec.ts +22 -0
  47. package/src/composer/attachment-accept.ts +71 -0
  48. package/src/composer/draft-key.spec.ts +19 -0
  49. package/src/composer/draft-key.ts +7 -0
  50. package/src/composer/extensions/FileReference.tsx +55 -0
  51. package/src/composer/extensions/MentionReference.tsx +93 -0
  52. package/src/composer/extensions/SkillReference.tsx +56 -0
  53. package/src/composer/index.ts +79 -0
  54. package/src/composer/pasted-text.spec.ts +162 -0
  55. package/src/composer/pasted-text.ts +160 -0
  56. package/src/composer/prompt-attachments.spec.ts +80 -0
  57. package/src/composer/prompt-attachments.ts +93 -0
  58. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  59. package/src/composer/realtime-voice-audio-level.ts +64 -0
  60. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  61. package/src/composer/realtime-voice-transcript.ts +134 -0
  62. package/src/composer/runtime-adapters.tsx +343 -0
  63. package/src/composer/types.ts +66 -0
  64. package/src/composer/use-file-search.ts +64 -0
  65. package/src/composer/use-mention-search.ts +90 -0
  66. package/src/composer/use-skills.ts +45 -0
  67. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  68. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  69. package/src/composer/useVoiceDictation.spec.ts +45 -0
  70. package/src/composer/useVoiceDictation.ts +1191 -0
  71. package/src/context-ui/ContextMeter.tsx +101 -0
  72. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  73. package/src/context-ui/ContextTreemap.tsx +125 -0
  74. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  75. package/src/context-ui/format.ts +42 -0
  76. package/src/context-ui/index.ts +15 -0
  77. package/src/context-ui/types.ts +43 -0
  78. package/src/design-tweaks/index.ts +1 -0
  79. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  80. package/src/editor/BubbleToolbar.tsx +253 -0
  81. package/src/editor/CodeBlockNode.tsx +254 -0
  82. package/src/editor/DragHandle.spec.ts +588 -0
  83. package/src/editor/DragHandle.ts +1437 -0
  84. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  85. package/src/editor/ImageExtension.ts +306 -0
  86. package/src/editor/RegistryBlockContext.tsx +107 -0
  87. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  88. package/src/editor/RegistryBlockNode.tsx +652 -0
  89. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  90. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  91. package/src/editor/RichMarkdownEditor.tsx +81 -0
  92. package/src/editor/RunId.ts +61 -0
  93. package/src/editor/SharedRichEditor.tsx +321 -0
  94. package/src/editor/SlashCommandMenu.tsx +310 -0
  95. package/src/editor/disableHistory.spec.ts +85 -0
  96. package/src/editor/extensions.ts +402 -0
  97. package/src/editor/gfmDoc.spec.ts +230 -0
  98. package/src/editor/gfmDoc.ts +92 -0
  99. package/src/editor/index.ts +93 -0
  100. package/src/editor/registrySlashCommands.ts +131 -0
  101. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  102. package/src/editor/surgical-apply.spec.ts +279 -0
  103. package/src/editor/surgical-apply.ts +192 -0
  104. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  105. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  106. package/src/editor/useCollabReconcile.ts +589 -0
  107. package/src/editor.css +510 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/use-mobile.tsx +21 -0
  110. package/src/hooks/use-toast.ts +188 -0
  111. package/src/index.ts +8 -0
  112. package/src/onboarding/index.ts +1 -0
  113. package/src/provider.tsx +47 -0
  114. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  115. package/src/sharing/VisibilityBadge.tsx +37 -0
  116. package/src/sharing/index.ts +4 -0
  117. package/src/styles.css +21 -0
  118. package/src/ui/accordion.tsx +56 -0
  119. package/src/ui/alert-dialog.tsx +141 -0
  120. package/src/ui/alert.tsx +59 -0
  121. package/src/ui/aspect-ratio.tsx +5 -0
  122. package/src/ui/avatar.tsx +48 -0
  123. package/src/ui/badge.tsx +37 -0
  124. package/src/ui/breadcrumb.tsx +115 -0
  125. package/src/ui/button.tsx +78 -0
  126. package/src/ui/calendar.tsx +214 -0
  127. package/src/ui/card.tsx +86 -0
  128. package/src/ui/carousel.tsx +260 -0
  129. package/src/ui/chart.tsx +375 -0
  130. package/src/ui/checkbox.tsx +28 -0
  131. package/src/ui/collapsible.tsx +9 -0
  132. package/src/ui/command.spec.tsx +51 -0
  133. package/src/ui/command.tsx +174 -0
  134. package/src/ui/context-menu.tsx +202 -0
  135. package/src/ui/date-picker.tsx +64 -0
  136. package/src/ui/dialog.spec.tsx +43 -0
  137. package/src/ui/dialog.tsx +146 -0
  138. package/src/ui/drawer.tsx +126 -0
  139. package/src/ui/dropdown-menu.tsx +218 -0
  140. package/src/ui/form.tsx +177 -0
  141. package/src/ui/hover-card.tsx +27 -0
  142. package/src/ui/index.ts +52 -0
  143. package/src/ui/input-otp.tsx +69 -0
  144. package/src/ui/input.tsx +22 -0
  145. package/src/ui/label.tsx +24 -0
  146. package/src/ui/logo.tsx +36 -0
  147. package/src/ui/menubar.tsx +235 -0
  148. package/src/ui/navigation-menu.tsx +128 -0
  149. package/src/ui/pagination.tsx +121 -0
  150. package/src/ui/popover.tsx +44 -0
  151. package/src/ui/progress.tsx +26 -0
  152. package/src/ui/radio-group.tsx +42 -0
  153. package/src/ui/resizable.tsx +43 -0
  154. package/src/ui/scroll-area.tsx +46 -0
  155. package/src/ui/select.tsx +158 -0
  156. package/src/ui/separator.tsx +29 -0
  157. package/src/ui/sheet.tsx +160 -0
  158. package/src/ui/sidebar.tsx +777 -0
  159. package/src/ui/skeleton.tsx +18 -0
  160. package/src/ui/slider.tsx +26 -0
  161. package/src/ui/sonner.tsx +58 -0
  162. package/src/ui/spinner.tsx +17 -0
  163. package/src/ui/switch.tsx +27 -0
  164. package/src/ui/table.tsx +117 -0
  165. package/src/ui/tabs.tsx +53 -0
  166. package/src/ui/textarea.tsx +23 -0
  167. package/src/ui/toast.tsx +127 -0
  168. package/src/ui/toaster.tsx +33 -0
  169. package/src/ui/toggle-group.tsx +59 -0
  170. package/src/ui/toggle.tsx +43 -0
  171. package/src/ui/tooltip.tsx +53 -0
  172. package/src/ui/use-toast.ts +3 -0
  173. package/src/utils.ts +8 -0
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { isRealtimeVoiceSetupRequired } from "./VoiceButton.js";
4
+
5
+ describe("isRealtimeVoiceSetupRequired", () => {
6
+ it("waits for the voice-specific provider status before prompting setup", () => {
7
+ expect(isRealtimeVoiceSetupRequired(null, false)).toBe(false);
8
+ });
9
+
10
+ it("does not wait on the redundant Builder status after voice status resolves", () => {
11
+ expect(
12
+ isRealtimeVoiceSetupRequired({ builder: false, openai: false }, null),
13
+ ).toBe(true);
14
+ });
15
+
16
+ it("accepts either managed Builder voice or an OpenAI key", () => {
17
+ expect(
18
+ isRealtimeVoiceSetupRequired({ builder: true, openai: false }, false),
19
+ ).toBe(false);
20
+ expect(
21
+ isRealtimeVoiceSetupRequired({ builder: false, openai: true }, false),
22
+ ).toBe(false);
23
+ expect(
24
+ isRealtimeVoiceSetupRequired({ builder: false, openai: false }, true),
25
+ ).toBe(false);
26
+ });
27
+
28
+ it("prompts setup only when neither realtime provider is configured", () => {
29
+ expect(
30
+ isRealtimeVoiceSetupRequired({ builder: false, openai: false }, false),
31
+ ).toBe(true);
32
+ });
33
+ });
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Voice dictation button + recording overlay for the agent composer.
3
+ *
4
+ * UX mirrors Lovable: click-to-toggle record, a live amplitude bar and
5
+ * MM:SS timer replace the editor area while recording, and a cancel X
6
+ * discards without transcribing. The mic is always visible alongside the
7
+ * send button (Cursor replaces send with mic; their users complain — we
8
+ * don't copy that).
9
+ */
10
+
11
+ import {
12
+ IconMicrophone,
13
+ IconPlayerStopFilled,
14
+ IconLoader2,
15
+ IconX,
16
+ } from "@tabler/icons-react";
17
+
18
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
19
+ import { RealtimeVoiceModeEntry } from "./RealtimeVoiceMode.js";
20
+ import { useComposerRuntimeAdapters } from "./runtime-adapters.js";
21
+ import {
22
+ useRealtimeVoiceModeCopy,
23
+ useRealtimeVoiceModeOptional,
24
+ } from "./useRealtimeVoiceMode.js";
25
+ import type { VoiceDictationApi } from "./useVoiceDictation.js";
26
+
27
+ function openOpenAiKeySettings(): void {
28
+ window.location.hash = "#secrets:OPENAI_API_KEY";
29
+ window.dispatchEvent(new Event("agent-panel:open"));
30
+ window.dispatchEvent(
31
+ new CustomEvent("agent-panel:open-settings", {
32
+ detail: { section: "secrets" },
33
+ }),
34
+ );
35
+ }
36
+
37
+ export interface VoiceButtonProps {
38
+ voice: VoiceDictationApi;
39
+ isMac: boolean;
40
+ disabled?: boolean;
41
+ }
42
+
43
+ export function isRealtimeVoiceSetupRequired(
44
+ status: { builder?: boolean; openai?: boolean } | null,
45
+ builderConfigured: boolean | null,
46
+ ): boolean {
47
+ return (
48
+ status !== null &&
49
+ !status.builder &&
50
+ !status.openai &&
51
+ builderConfigured !== true
52
+ );
53
+ }
54
+
55
+ export function VoiceButton({ voice, isMac, disabled }: VoiceButtonProps) {
56
+ const adapters = useComposerRuntimeAdapters();
57
+ const { state, start, stop, supported } = voice;
58
+ const realtimeVoice = useRealtimeVoiceModeOptional();
59
+ const realtimeCopy = useRealtimeVoiceModeCopy();
60
+ const voiceProviders = adapters.voice!.useProviderStatus!();
61
+ const builderConnect = adapters.builder!.useConnectFlow!({
62
+ trackingSource: "realtime_voice",
63
+ trackingFlow: "voice_transcription",
64
+ onConnected: () => voiceProviders.refresh(),
65
+ });
66
+
67
+ if (!supported) return null;
68
+
69
+ const recording = state === "recording" || state === "starting";
70
+ const transcribing = state === "transcribing";
71
+
72
+ if (realtimeVoice?.active && !recording && !transcribing) return null;
73
+
74
+ if (realtimeVoice && !recording && !transcribing) {
75
+ return (
76
+ <RealtimeVoiceModeEntry
77
+ copy={realtimeCopy}
78
+ disabled={disabled}
79
+ providerStatusPending={voiceProviders.status === null}
80
+ setupRequired={isRealtimeVoiceSetupRequired(
81
+ voiceProviders.status,
82
+ builderConnect.statusResolved ? builderConnect.configured : null,
83
+ )}
84
+ openAiConfigured={voiceProviders.status?.openai === true}
85
+ connectingBuilder={builderConnect.connecting}
86
+ onConnectBuilder={builderConnect.start}
87
+ onUseOpenAiKey={() => {
88
+ if (voiceProviders.status?.openai) void realtimeVoice.start();
89
+ else openOpenAiKeySettings();
90
+ }}
91
+ onStartVoiceMode={() => void realtimeVoice.start()}
92
+ onKeepDictating={() => void start()}
93
+ />
94
+ );
95
+ }
96
+
97
+ const label = recording
98
+ ? "Stop recording"
99
+ : transcribing
100
+ ? "Transcribing…"
101
+ : `Dictate (${isMac ? "⌘⇧M" : "Ctrl+Shift+M"})`;
102
+
103
+ const onClick = () => {
104
+ if (recording) stop();
105
+ else if (!transcribing) void start();
106
+ };
107
+
108
+ return (
109
+ <Tooltip>
110
+ <TooltipTrigger asChild>
111
+ <button
112
+ type="button"
113
+ onClick={onClick}
114
+ disabled={disabled || transcribing}
115
+ aria-label={label}
116
+ aria-pressed={recording}
117
+ className={`shrink-0 flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150 disabled:opacity-30 disabled:cursor-not-allowed ${
118
+ recording
119
+ ? "text-[#00B5FF] bg-[#00B5FF]/10 hover:bg-[#00B5FF]/20"
120
+ : "text-muted-foreground hover:text-foreground hover:bg-accent/50"
121
+ }`}
122
+ >
123
+ {transcribing ? (
124
+ <IconLoader2 className="h-4 w-4 animate-spin" />
125
+ ) : recording ? (
126
+ <IconPlayerStopFilled className="h-3.5 w-3.5" />
127
+ ) : (
128
+ <IconMicrophone className="h-4 w-4" />
129
+ )}
130
+ </button>
131
+ </TooltipTrigger>
132
+ <TooltipContent>{label}</TooltipContent>
133
+ </Tooltip>
134
+ );
135
+ }
136
+
137
+ export interface VoiceRecordingOverlayProps {
138
+ voice: VoiceDictationApi;
139
+ }
140
+
141
+ export function VoiceRecordingOverlay({ voice }: VoiceRecordingOverlayProps) {
142
+ const { state, amplitude, durationMs, errorMessage, cancel } = voice;
143
+ const { dismissError, start } = voice;
144
+
145
+ if (state === "error" && errorMessage) {
146
+ return (
147
+ <div
148
+ role="alert"
149
+ className="mx-2 mt-1 flex items-start gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-[11px] text-red-500"
150
+ >
151
+ <span className="flex-1 min-w-0">{errorMessage}</span>
152
+ <Tooltip>
153
+ <TooltipTrigger asChild>
154
+ <button
155
+ type="button"
156
+ onClick={() => {
157
+ dismissError();
158
+ void start();
159
+ }}
160
+ className="shrink-0 cursor-pointer rounded px-1.5 py-0.5 text-[11px] font-medium text-red-500 hover:bg-red-500/20"
161
+ aria-label="Try again"
162
+ >
163
+ Try again
164
+ </button>
165
+ </TooltipTrigger>
166
+ <TooltipContent>Try again</TooltipContent>
167
+ </Tooltip>
168
+ <Tooltip>
169
+ <TooltipTrigger asChild>
170
+ <button
171
+ type="button"
172
+ onClick={dismissError}
173
+ className="shrink-0 flex h-4 w-4 cursor-pointer items-center justify-center rounded text-red-500 hover:bg-red-500/20"
174
+ aria-label="Dismiss"
175
+ >
176
+ <IconX className="h-3 w-3" />
177
+ </button>
178
+ </TooltipTrigger>
179
+ <TooltipContent>Dismiss</TooltipContent>
180
+ </Tooltip>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ if (state !== "recording" && state !== "starting" && state !== "transcribing")
186
+ return null;
187
+
188
+ return (
189
+ <div
190
+ className="flex items-center gap-2 mx-2 mt-2 mb-1 h-[2rem] rounded-md border border-[#00B5FF]/40 bg-[#00B5FF]/10 px-2"
191
+ aria-live="polite"
192
+ >
193
+ <Tooltip>
194
+ <TooltipTrigger asChild>
195
+ <button
196
+ type="button"
197
+ onClick={cancel}
198
+ className="shrink-0 flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent/40"
199
+ aria-label="Cancel recording"
200
+ >
201
+ <IconX className="h-3 w-3" />
202
+ </button>
203
+ </TooltipTrigger>
204
+ <TooltipContent>Cancel (Esc)</TooltipContent>
205
+ </Tooltip>
206
+
207
+ <div className="flex-1 flex items-center gap-[2px] min-w-0 h-4">
208
+ {state === "transcribing" ? (
209
+ <span className="text-[11px] text-muted-foreground">
210
+ Transcribing…
211
+ </span>
212
+ ) : (
213
+ <AmplitudeBars amplitude={amplitude} />
214
+ )}
215
+ </div>
216
+
217
+ <span className="shrink-0 text-[11px] font-medium tabular-nums text-muted-foreground">
218
+ {state === "transcribing" ? (
219
+ <IconLoader2 className="h-3 w-3 animate-spin" />
220
+ ) : (
221
+ formatDuration(durationMs)
222
+ )}
223
+ </span>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ const BAR_COUNT = 24;
229
+
230
+ function AmplitudeBars({ amplitude }: { amplitude: number }) {
231
+ // Render a symmetric meter — the middle bars peak first so the visual
232
+ // matches what voice input looks like in Lovable / iOS dictation.
233
+ const bars = [];
234
+ for (let i = 0; i < BAR_COUNT; i++) {
235
+ const centerDistance =
236
+ Math.abs(i - (BAR_COUNT - 1) / 2) / ((BAR_COUNT - 1) / 2);
237
+ const heightPct =
238
+ Math.max(0.1, amplitude * (1 - centerDistance * 0.6)) * 100;
239
+ bars.push(
240
+ <span
241
+ key={i}
242
+ className="flex-1 rounded-full bg-[#00B5FF]"
243
+ style={{ height: `${heightPct}%`, minHeight: 2 }}
244
+ />,
245
+ );
246
+ }
247
+ return <>{bars}</>;
248
+ }
249
+
250
+ function formatDuration(ms: number): string {
251
+ const total = Math.floor(ms / 1000);
252
+ const m = Math.floor(total / 60);
253
+ const s = total % 60;
254
+ return `${m}:${s.toString().padStart(2, "0")}`;
255
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ isExternalAssetPickerUrl,
5
+ standaloneAssetPickerUrl,
6
+ } from "./asset-picker-url.js";
7
+
8
+ describe("asset picker auth handoff", () => {
9
+ it("treats the hosted Assets picker as external to the host app", () => {
10
+ expect(
11
+ isExternalAssetPickerUrl(
12
+ "https://assets.agent-native.com/picker",
13
+ "https://clips.agent-native.com",
14
+ ),
15
+ ).toBe(true);
16
+ });
17
+
18
+ it("keeps same-origin pickers eligible for inline rendering", () => {
19
+ expect(
20
+ isExternalAssetPickerUrl("/picker", "https://clips.agent-native.com"),
21
+ ).toBe(false);
22
+ });
23
+
24
+ it("removes iframe flags from the top-level fallback URL", () => {
25
+ expect(
26
+ standaloneAssetPickerUrl(
27
+ "https://assets.agent-native.com/picker?embedded=1&__an_embed_token=fake-token",
28
+ ),
29
+ ).toBe("https://assets.agent-native.com/picker?mediaType=image");
30
+ });
31
+
32
+ it("adds a nonce-bound exact-origin callback to the top-level URL", () => {
33
+ expect(
34
+ standaloneAssetPickerUrl(
35
+ "https://assets.agent-native.com/picker?embedded=1",
36
+ "https://clips.agent-native.com",
37
+ {
38
+ handoffId: "handoff-123",
39
+ returnOrigin: "https://clips.agent-native.com/chat",
40
+ },
41
+ ),
42
+ ).toBe(
43
+ "https://assets.agent-native.com/picker?mediaType=image&__an_asset_picker_handoff=handoff-123&__an_asset_picker_return_origin=https%3A%2F%2Fclips.agent-native.com",
44
+ );
45
+ });
46
+
47
+ it("omits an invalid callback target", () => {
48
+ expect(
49
+ standaloneAssetPickerUrl(
50
+ "https://assets.agent-native.com/picker",
51
+ "https://clips.agent-native.com",
52
+ { handoffId: "handoff-123", returnOrigin: "javascript:alert(1)" },
53
+ ),
54
+ ).toBe("https://assets.agent-native.com/picker?mediaType=image");
55
+ });
56
+ });
@@ -0,0 +1,80 @@
1
+ const FALLBACK_BASE_URL = "http://agent-native.invalid";
2
+
3
+ export const ASSET_PICKER_HANDOFF_PARAM = "__an_asset_picker_handoff";
4
+ export const ASSET_PICKER_RETURN_ORIGIN_PARAM =
5
+ "__an_asset_picker_return_origin";
6
+
7
+ export interface StandaloneAssetPickerOptions {
8
+ handoffId?: string;
9
+ returnOrigin?: string;
10
+ }
11
+
12
+ export function createAssetPickerHandoffId(): string {
13
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
14
+ return crypto.randomUUID();
15
+ }
16
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
17
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
18
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
19
+ "",
20
+ );
21
+ }
22
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
23
+ }
24
+
25
+ /**
26
+ * Cross-origin Assets pages cannot inherit the host app's authenticated
27
+ * session inside an iframe. Treat those URLs as link-out targets so provider
28
+ * sign-in runs in a normal top-level browser context.
29
+ */
30
+ export function isExternalAssetPickerUrl(
31
+ value: string,
32
+ currentOrigin: string,
33
+ ): boolean {
34
+ try {
35
+ return new URL(value, currentOrigin).origin !== currentOrigin;
36
+ } catch {
37
+ // A malformed configured URL should fail closed instead of being loaded in
38
+ // an iframe with an unknown auth boundary.
39
+ return true;
40
+ }
41
+ }
42
+
43
+ /** Build a top-level picker URL without iframe-only auth flags. */
44
+ export function standaloneAssetPickerUrl(
45
+ value: string,
46
+ baseUrl = FALLBACK_BASE_URL,
47
+ options: StandaloneAssetPickerOptions = {},
48
+ ): string {
49
+ try {
50
+ const parsed = new URL(value, baseUrl);
51
+ parsed.searchParams.delete("embedded");
52
+ parsed.searchParams.delete("__an_embed_token");
53
+ parsed.searchParams.set("mediaType", "image");
54
+ let returnOrigin: string | null = null;
55
+ if (options.returnOrigin) {
56
+ try {
57
+ const parsedOrigin = new URL(options.returnOrigin);
58
+ if (
59
+ parsedOrigin.protocol === "http:" ||
60
+ parsedOrigin.protocol === "https:"
61
+ ) {
62
+ returnOrigin = parsedOrigin.origin;
63
+ }
64
+ } catch {
65
+ // Omit an invalid return target rather than emitting an unverified
66
+ // cross-origin callback URL.
67
+ }
68
+ }
69
+ if (options.handoffId && returnOrigin) {
70
+ parsed.searchParams.set(ASSET_PICKER_HANDOFF_PARAM, options.handoffId);
71
+ parsed.searchParams.set(ASSET_PICKER_RETURN_ORIGIN_PARAM, returnOrigin);
72
+ } else {
73
+ parsed.searchParams.delete(ASSET_PICKER_HANDOFF_PARAM);
74
+ parsed.searchParams.delete(ASSET_PICKER_RETURN_ORIGIN_PARAM);
75
+ }
76
+ return parsed.toString();
77
+ } catch {
78
+ return value;
79
+ }
80
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ CHAT_DOCUMENT_ATTACHMENT_ACCEPT,
5
+ PROMPT_DOCUMENT_ATTACHMENT_ACCEPT,
6
+ } from "./attachment-accept.js";
7
+
8
+ describe("attachment accept lists", () => {
9
+ it("keeps SVGs out of standalone prompt composer document uploads", () => {
10
+ const accept = PROMPT_DOCUMENT_ATTACHMENT_ACCEPT.split(",");
11
+
12
+ expect(accept).not.toContain("image/svg+xml");
13
+ expect(accept).not.toContain(".svg");
14
+ });
15
+
16
+ it("allows SVGs in the main chat document upload path", () => {
17
+ const accept = CHAT_DOCUMENT_ATTACHMENT_ACCEPT.split(",");
18
+
19
+ expect(accept).toContain("image/svg+xml");
20
+ expect(accept).toContain(".svg");
21
+ });
22
+ });
@@ -0,0 +1,71 @@
1
+ import { SimpleTextAttachmentAdapter } from "@assistant-ui/react";
2
+
3
+ const BASE_DOCUMENT_ATTACHMENT_ACCEPT = [
4
+ "application/pdf",
5
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
6
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
7
+ ".pdf",
8
+ ".pptx",
9
+ ".docx",
10
+ ];
11
+
12
+ export const PROMPT_DOCUMENT_ATTACHMENT_ACCEPT =
13
+ BASE_DOCUMENT_ATTACHMENT_ACCEPT.join(",");
14
+
15
+ export const CHAT_DOCUMENT_ATTACHMENT_ACCEPT = [
16
+ ...BASE_DOCUMENT_ATTACHMENT_ACCEPT,
17
+ "image/svg+xml",
18
+ ".svg",
19
+ ].join(",");
20
+
21
+ export const IMAGE_ATTACHMENT_ACCEPT = [
22
+ "image/jpeg",
23
+ "image/jpg",
24
+ "image/png",
25
+ "image/gif",
26
+ "image/webp",
27
+ "image/heic",
28
+ "image/heif",
29
+ "image/avif",
30
+ "image/bmp",
31
+ "image/tiff",
32
+ ".jpg",
33
+ ".jpeg",
34
+ ".png",
35
+ ".gif",
36
+ ".webp",
37
+ ".heic",
38
+ ".heif",
39
+ ".avif",
40
+ ".bmp",
41
+ ".tif",
42
+ ".tiff",
43
+ ].join(",");
44
+
45
+ export const TEXT_ATTACHMENT_ACCEPT = [
46
+ "text/plain",
47
+ "text/html",
48
+ "text/markdown",
49
+ "text/csv",
50
+ "text/xml",
51
+ "text/json",
52
+ "text/css",
53
+ "text/yaml",
54
+ "application/json",
55
+ "application/x-yaml",
56
+ ".txt",
57
+ ".md",
58
+ ".markdown",
59
+ ".csv",
60
+ ".json",
61
+ ".html",
62
+ ".htm",
63
+ ".css",
64
+ ".xml",
65
+ ".yaml",
66
+ ".yml",
67
+ ].join(",");
68
+
69
+ export class TextAttachmentAdapter extends SimpleTextAttachmentAdapter {
70
+ public accept = TEXT_ATTACHMENT_ACCEPT;
71
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { getComposerDraftKey } from "./draft-key.js";
4
+
5
+ describe("getComposerDraftKey", () => {
6
+ it("uses the legacy key when no scope is available", () => {
7
+ expect(getComposerDraftKey()).toBe("an-composer-draft");
8
+ expect(getComposerDraftKey(" ")).toBe("an-composer-draft");
9
+ });
10
+
11
+ it("scopes drafts by thread or tab id", () => {
12
+ expect(getComposerDraftKey("thread-qa")).toBe(
13
+ "an-composer-draft:thread-qa",
14
+ );
15
+ expect(getComposerDraftKey("tab with spaces")).toBe(
16
+ "an-composer-draft:tab%20with%20spaces",
17
+ );
18
+ });
19
+ });
@@ -0,0 +1,7 @@
1
+ const BASE_COMPOSER_DRAFT_KEY = "an-composer-draft";
2
+
3
+ export function getComposerDraftKey(scope?: string | null): string {
4
+ const trimmed = scope?.trim();
5
+ if (!trimmed) return BASE_COMPOSER_DRAFT_KEY;
6
+ return `${BASE_COMPOSER_DRAFT_KEY}:${encodeURIComponent(trimmed)}`;
7
+ }
@@ -0,0 +1,55 @@
1
+ import { IconFile, IconFolder } from "@tabler/icons-react";
2
+ import { mergeAttributes, Node } from "@tiptap/core";
3
+ import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
4
+
5
+ const FileReferenceComponent = ({ node }: { node: any }) => {
6
+ const isFolder = node.attrs.path?.endsWith("/");
7
+ const cleanPath = isFolder ? node.attrs.path.slice(0, -1) : node.attrs.path;
8
+ const displayName = cleanPath.split("/").pop() || cleanPath;
9
+
10
+ return (
11
+ <NodeViewWrapper as="span" className="inline">
12
+ <span
13
+ className="inline-flex items-center gap-1 rounded-md border border-input bg-muted/50 px-1.5 py-0.5 text-xs font-medium text-foreground align-middle mx-0.5 max-w-[200px] select-none"
14
+ title={node.attrs.path}
15
+ >
16
+ {isFolder ? (
17
+ <IconFolder size={14} className="shrink-0 text-muted-foreground" />
18
+ ) : (
19
+ <IconFile size={14} className="shrink-0 text-muted-foreground" />
20
+ )}
21
+ <span className="truncate">{displayName}</span>
22
+ </span>
23
+ </NodeViewWrapper>
24
+ );
25
+ };
26
+
27
+ export const FileReference = Node.create({
28
+ name: "fileReference",
29
+ group: "inline",
30
+ inline: true,
31
+ selectable: true,
32
+ atom: true,
33
+
34
+ addAttributes() {
35
+ return {
36
+ path: { default: null },
37
+ source: { default: "codebase" },
38
+ };
39
+ },
40
+
41
+ parseHTML() {
42
+ return [{ tag: 'span[data-type="file-reference"]' }];
43
+ },
44
+
45
+ renderHTML({ HTMLAttributes }) {
46
+ return [
47
+ "span",
48
+ mergeAttributes({ "data-type": "file-reference" }, HTMLAttributes),
49
+ ];
50
+ },
51
+
52
+ addNodeView() {
53
+ return ReactNodeViewRenderer(FileReferenceComponent);
54
+ },
55
+ });
@@ -0,0 +1,93 @@
1
+ import {
2
+ IconFile,
3
+ IconFolder,
4
+ IconFileText,
5
+ IconCheckbox,
6
+ IconMail,
7
+ IconUser,
8
+ IconPresentation,
9
+ IconStack2,
10
+ IconMessageChatbot,
11
+ } from "@tabler/icons-react";
12
+ import { mergeAttributes, Node } from "@tiptap/core";
13
+ import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
14
+
15
+ const iconProps = { size: 14, className: "shrink-0 text-muted-foreground" };
16
+
17
+ function MentionIcon({ icon }: { icon?: string }) {
18
+ switch (icon) {
19
+ case "folder":
20
+ return <IconFolder {...iconProps} />;
21
+ case "document":
22
+ return <IconFileText {...iconProps} />;
23
+ case "form":
24
+ return <IconCheckbox {...iconProps} />;
25
+ case "email":
26
+ return <IconMail {...iconProps} />;
27
+ case "user":
28
+ return <IconUser {...iconProps} />;
29
+ case "deck":
30
+ return <IconPresentation {...iconProps} />;
31
+ case "agent":
32
+ return <IconMessageChatbot {...iconProps} />;
33
+ case "file":
34
+ return <IconFile {...iconProps} />;
35
+ default:
36
+ return <IconStack2 {...iconProps} />;
37
+ }
38
+ }
39
+
40
+ const MentionReferenceComponent = ({ node }: { node: any }) => {
41
+ return (
42
+ <NodeViewWrapper as="span" className="inline">
43
+ <span
44
+ className="inline-flex items-center gap-1 rounded-md border border-input bg-muted/50 px-1.5 py-0.5 text-xs font-medium text-foreground align-middle mx-0.5 max-w-[200px] select-none"
45
+ title={node.attrs.refPath || node.attrs.refId || node.attrs.label}
46
+ >
47
+ <MentionIcon icon={node.attrs.icon} />
48
+ <span className="truncate">{node.attrs.label}</span>
49
+ </span>
50
+ </NodeViewWrapper>
51
+ );
52
+ };
53
+
54
+ export const MentionReference = Node.create({
55
+ name: "mentionReference",
56
+ group: "inline",
57
+ inline: true,
58
+ selectable: true,
59
+ atom: true,
60
+
61
+ addAttributes() {
62
+ return {
63
+ label: { default: null },
64
+ icon: { default: "file" },
65
+ source: { default: "" },
66
+ refType: { default: "file" },
67
+ refId: { default: null },
68
+ refPath: { default: null },
69
+ slotKey: { default: null },
70
+ slotLabel: { default: null },
71
+ metadata: { default: null },
72
+ };
73
+ },
74
+
75
+ parseHTML() {
76
+ return [{ tag: 'span[data-type="mention-reference"]' }];
77
+ },
78
+
79
+ renderHTML({ HTMLAttributes }) {
80
+ return [
81
+ "span",
82
+ mergeAttributes({ "data-type": "mention-reference" }, HTMLAttributes),
83
+ ];
84
+ },
85
+
86
+ renderText({ node }) {
87
+ return `@${node.attrs.label}`;
88
+ },
89
+
90
+ addNodeView() {
91
+ return ReactNodeViewRenderer(MentionReferenceComponent);
92
+ },
93
+ });