@agent-native/toolkit 0.6.0 → 0.8.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.
- package/README.md +17 -1
- package/agent-native.eject.json +411 -0
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +20 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- 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
|
+
});
|