@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.
- package/README.md +17 -1
- 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 +17 -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,983 @@
|
|
|
1
|
+
import { useComposerRuntime } from "@assistant-ui/react";
|
|
2
|
+
import {
|
|
3
|
+
IconPlus,
|
|
4
|
+
IconUpload,
|
|
5
|
+
IconBulb,
|
|
6
|
+
IconClock,
|
|
7
|
+
IconBolt,
|
|
8
|
+
IconTool,
|
|
9
|
+
IconPlugConnected,
|
|
10
|
+
IconPhotoPlus,
|
|
11
|
+
IconLoader2,
|
|
12
|
+
IconArrowLeft,
|
|
13
|
+
IconX,
|
|
14
|
+
} from "@tabler/icons-react";
|
|
15
|
+
import React, {
|
|
16
|
+
useState,
|
|
17
|
+
useRef,
|
|
18
|
+
useEffect,
|
|
19
|
+
useMemo,
|
|
20
|
+
useCallback,
|
|
21
|
+
} from "react";
|
|
22
|
+
import { createPortal } from "react-dom";
|
|
23
|
+
|
|
24
|
+
import { Popover, PopoverTrigger, PopoverContent } from "../ui/popover.js";
|
|
25
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
|
|
26
|
+
import { cn } from "../utils.js";
|
|
27
|
+
import {
|
|
28
|
+
createAssetPickerHandoffId,
|
|
29
|
+
isExternalAssetPickerUrl,
|
|
30
|
+
standaloneAssetPickerUrl,
|
|
31
|
+
} from "./asset-picker-url.js";
|
|
32
|
+
import { useComposerRuntimeAdapters } from "./runtime-adapters.js";
|
|
33
|
+
import type { ComposerMode } from "./types.js";
|
|
34
|
+
|
|
35
|
+
interface ComposerPlusMenuProps {
|
|
36
|
+
onSelectMode?: (mode: ComposerMode) => void;
|
|
37
|
+
onAttachmentError?: (message: string) => void;
|
|
38
|
+
/**
|
|
39
|
+
* "full" (default): full + menu with Upload File, Create Skill, Schedule Task,
|
|
40
|
+
* Automation, Extension, MCP Server. "upload-only": clicking + opens the file
|
|
41
|
+
* picker directly — no popover, no other modes. Use for prompt popovers
|
|
42
|
+
* (create extension, create deck, create dashboard, etc.) where the only thing
|
|
43
|
+
* to attach is a file.
|
|
44
|
+
*/
|
|
45
|
+
mode?: "full" | "upload-only";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type View = "menu" | "skill-upload";
|
|
49
|
+
|
|
50
|
+
const DEFAULT_ASSETS_PICKER_URL = "https://assets.agent-native.com/picker";
|
|
51
|
+
const EMBED_PROTOCOL = "agent-native.embed";
|
|
52
|
+
const EMBED_VERSION = 1;
|
|
53
|
+
|
|
54
|
+
interface EmbedEnvelope<TPayload = unknown> {
|
|
55
|
+
protocol?: string;
|
|
56
|
+
version?: number;
|
|
57
|
+
type?: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
payload?: TPayload;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface AssetPickerPayload {
|
|
63
|
+
assetId?: unknown;
|
|
64
|
+
handoffId?: unknown;
|
|
65
|
+
url?: unknown;
|
|
66
|
+
previewUrl?: unknown;
|
|
67
|
+
downloadUrl?: unknown;
|
|
68
|
+
embedUrl?: unknown;
|
|
69
|
+
altText?: unknown;
|
|
70
|
+
title?: unknown;
|
|
71
|
+
prompt?: unknown;
|
|
72
|
+
mediaType?: unknown;
|
|
73
|
+
libraryId?: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assetPickerUrl() {
|
|
77
|
+
const env =
|
|
78
|
+
(import.meta as ImportMeta & { env?: Record<string, string | undefined> })
|
|
79
|
+
.env ?? {};
|
|
80
|
+
return env.VITE_AGENT_NATIVE_ASSETS_PICKER_URL || DEFAULT_ASSETS_PICKER_URL;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function withEmbeddedParams(url: string): string {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = new URL(url, window.location.href);
|
|
86
|
+
parsed.searchParams.set("embedded", "1");
|
|
87
|
+
parsed.searchParams.set("mediaType", "image");
|
|
88
|
+
return parsed.toString();
|
|
89
|
+
} catch {
|
|
90
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
91
|
+
return `${url}${separator}embedded=1&mediaType=image`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assetPickerOrigin(url: string): string | null {
|
|
96
|
+
try {
|
|
97
|
+
return new URL(url, window.location.href).origin;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function embedEnvelope(
|
|
104
|
+
type: "message" | "ready",
|
|
105
|
+
options: { name?: string; payload?: unknown } = {},
|
|
106
|
+
): EmbedEnvelope {
|
|
107
|
+
return {
|
|
108
|
+
protocol: EMBED_PROTOCOL,
|
|
109
|
+
version: EMBED_VERSION,
|
|
110
|
+
type,
|
|
111
|
+
...options,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isEmbedEnvelope(value: unknown): value is EmbedEnvelope {
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
117
|
+
const candidate = value as EmbedEnvelope;
|
|
118
|
+
return (
|
|
119
|
+
candidate.protocol === EMBED_PROTOCOL &&
|
|
120
|
+
candidate.version === EMBED_VERSION &&
|
|
121
|
+
typeof candidate.type === "string"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assetString(value: unknown): string | null {
|
|
126
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assetImageSource(payload: unknown): string | null {
|
|
130
|
+
if (!payload || typeof payload !== "object") return null;
|
|
131
|
+
const asset = payload as AssetPickerPayload;
|
|
132
|
+
return (
|
|
133
|
+
assetString(asset.url) ??
|
|
134
|
+
assetString(asset.previewUrl) ??
|
|
135
|
+
assetString(asset.downloadUrl) ??
|
|
136
|
+
assetString(asset.embedUrl)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assetTitle(payload: unknown, url: string): string {
|
|
141
|
+
if (payload && typeof payload === "object") {
|
|
142
|
+
const title = assetString((payload as AssetPickerPayload).title);
|
|
143
|
+
if (title) return title;
|
|
144
|
+
const prompt = assetString((payload as AssetPickerPayload).prompt);
|
|
145
|
+
if (prompt) return prompt.slice(0, 80);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const name = new URL(url).pathname.split("/").filter(Boolean).pop();
|
|
149
|
+
return name ? decodeURIComponent(name) : "Generated image";
|
|
150
|
+
} catch {
|
|
151
|
+
return "Generated image";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function assetContext(payload: unknown, url: string): string {
|
|
156
|
+
const lines = [`Image URL: ${url}`];
|
|
157
|
+
if (payload && typeof payload === "object") {
|
|
158
|
+
const asset = payload as AssetPickerPayload;
|
|
159
|
+
const assetId = assetString(asset.assetId);
|
|
160
|
+
const libraryId = assetString(asset.libraryId);
|
|
161
|
+
const prompt = assetString(asset.prompt);
|
|
162
|
+
const altText = assetString(asset.altText);
|
|
163
|
+
if (assetId) lines.push(`Asset ID: ${assetId}`);
|
|
164
|
+
if (libraryId) lines.push(`Library ID: ${libraryId}`);
|
|
165
|
+
if (prompt) lines.push(`Prompt: ${prompt}`);
|
|
166
|
+
if (altText) lines.push(`Alt text: ${altText}`);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function slugifyName(value: string): string {
|
|
172
|
+
return (
|
|
173
|
+
value
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
176
|
+
.replace(/^-+|-+$/g, "") || "uploaded-skill"
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatAttachmentError(error: unknown, fallback: string): string {
|
|
181
|
+
return error instanceof Error ? error.message : fallback;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function UploadOnlyAttachButton({
|
|
185
|
+
onAttachmentError,
|
|
186
|
+
}: Pick<ComposerPlusMenuProps, "onAttachmentError">) {
|
|
187
|
+
const composerRuntime = useComposerRuntime();
|
|
188
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
189
|
+
const handleFilesSelected = async (files: FileList | null) => {
|
|
190
|
+
if (!files || files.length === 0) return;
|
|
191
|
+
try {
|
|
192
|
+
await Promise.all(
|
|
193
|
+
Array.from(files).map((file) => composerRuntime.addAttachment(file)),
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
onAttachmentError?.(
|
|
197
|
+
formatAttachmentError(error, "Could not upload the selected file."),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<>
|
|
204
|
+
<input
|
|
205
|
+
ref={inputRef}
|
|
206
|
+
type="file"
|
|
207
|
+
multiple
|
|
208
|
+
className="hidden"
|
|
209
|
+
onChange={(event) => {
|
|
210
|
+
void handleFilesSelected(event.target.files);
|
|
211
|
+
event.target.value = "";
|
|
212
|
+
}}
|
|
213
|
+
/>
|
|
214
|
+
<Tooltip>
|
|
215
|
+
<TooltipTrigger asChild>
|
|
216
|
+
<span className="inline-flex shrink-0">
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className="shrink-0 flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
220
|
+
aria-label="Upload"
|
|
221
|
+
onClick={() => inputRef.current?.click()}
|
|
222
|
+
>
|
|
223
|
+
<IconPlus className="h-4 w-4" />
|
|
224
|
+
</button>
|
|
225
|
+
</span>
|
|
226
|
+
</TooltipTrigger>
|
|
227
|
+
<TooltipContent>Upload</TooltipContent>
|
|
228
|
+
</Tooltip>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function ComposerPlusMenu({
|
|
234
|
+
onSelectMode,
|
|
235
|
+
onAttachmentError,
|
|
236
|
+
mode = "full",
|
|
237
|
+
}: ComposerPlusMenuProps) {
|
|
238
|
+
if (mode === "upload-only") {
|
|
239
|
+
return <UploadOnlyAttachButton onAttachmentError={onAttachmentError} />;
|
|
240
|
+
}
|
|
241
|
+
return (
|
|
242
|
+
<ComposerPlusMenuFull
|
|
243
|
+
onSelectMode={onSelectMode}
|
|
244
|
+
onAttachmentError={onAttachmentError}
|
|
245
|
+
/>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function ComposerPlusMenuFull({
|
|
250
|
+
onSelectMode,
|
|
251
|
+
onAttachmentError,
|
|
252
|
+
}: Pick<ComposerPlusMenuProps, "onSelectMode" | "onAttachmentError">) {
|
|
253
|
+
const adapters = useComposerRuntimeAdapters();
|
|
254
|
+
const t = adapters.translate!;
|
|
255
|
+
const resources = adapters.resources!;
|
|
256
|
+
const composerRuntime = useComposerRuntime();
|
|
257
|
+
const [open, setOpen] = useState(false);
|
|
258
|
+
const [assetsPickerOpen, setAssetsPickerOpen] = useState(false);
|
|
259
|
+
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
|
260
|
+
const [view, setView] = useState<View>("menu");
|
|
261
|
+
const showMcpIntegrations = useMemo(
|
|
262
|
+
() => resources.isMcpIntegrationAvailable!(),
|
|
263
|
+
[resources],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const { data: org } = resources.useOrg!();
|
|
267
|
+
const canCreateOrgMcp =
|
|
268
|
+
!org?.orgId || org.role === "owner" || org.role === "admin";
|
|
269
|
+
const hasOrg = !!org?.orgId;
|
|
270
|
+
const defaultMcpScope: "org" | "user" =
|
|
271
|
+
hasOrg && canCreateOrgMcp ? "org" : "user";
|
|
272
|
+
const createMcp = resources.useCreateMcpServer!();
|
|
273
|
+
const McpIntegrationDialog = resources.McpIntegrationDialog;
|
|
274
|
+
|
|
275
|
+
const fileUploadRef = useRef<HTMLInputElement>(null);
|
|
276
|
+
const skillFileInputRef = useRef<HTMLInputElement>(null);
|
|
277
|
+
const skillHoverTimerRef = useRef<number | null>(null);
|
|
278
|
+
const [skillUploadSlug, setSkillUploadSlug] = useState("");
|
|
279
|
+
const [skillUploadContent, setSkillUploadContent] = useState("");
|
|
280
|
+
const [skillUploadFileName, setSkillUploadFileName] = useState("");
|
|
281
|
+
const [skillUploadStatus, setSkillUploadStatus] = useState<{
|
|
282
|
+
kind: "ok" | "err";
|
|
283
|
+
message: string;
|
|
284
|
+
} | null>(null);
|
|
285
|
+
const [skillUploadBusy, setSkillUploadBusy] = useState(false);
|
|
286
|
+
const [skillFlyoutOpen, setSkillFlyoutOpen] = useState(false);
|
|
287
|
+
const [skillFlyoutSide, setSkillFlyoutSide] = useState<"right" | "left">(
|
|
288
|
+
"right",
|
|
289
|
+
);
|
|
290
|
+
const skillFlyoutCloseTimerRef = useRef<number | null>(null);
|
|
291
|
+
const openSkillFlyout = (rowEl?: HTMLElement | null) => {
|
|
292
|
+
if (skillFlyoutCloseTimerRef.current) {
|
|
293
|
+
window.clearTimeout(skillFlyoutCloseTimerRef.current);
|
|
294
|
+
skillFlyoutCloseTimerRef.current = null;
|
|
295
|
+
}
|
|
296
|
+
if (rowEl && typeof window !== "undefined") {
|
|
297
|
+
const rect = rowEl.getBoundingClientRect();
|
|
298
|
+
const FLYOUT_WIDTH = 248;
|
|
299
|
+
setSkillFlyoutSide(
|
|
300
|
+
window.innerWidth - rect.right < FLYOUT_WIDTH ? "left" : "right",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
setSkillFlyoutOpen(true);
|
|
304
|
+
};
|
|
305
|
+
const scheduleSkillFlyoutClose = () => {
|
|
306
|
+
if (skillFlyoutCloseTimerRef.current)
|
|
307
|
+
window.clearTimeout(skillFlyoutCloseTimerRef.current);
|
|
308
|
+
skillFlyoutCloseTimerRef.current = window.setTimeout(() => {
|
|
309
|
+
setSkillFlyoutOpen(false);
|
|
310
|
+
}, 160);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (open) {
|
|
315
|
+
setView("menu");
|
|
316
|
+
setSkillUploadSlug("");
|
|
317
|
+
setSkillUploadContent("");
|
|
318
|
+
setSkillUploadFileName("");
|
|
319
|
+
setSkillUploadStatus(null);
|
|
320
|
+
setSkillUploadBusy(false);
|
|
321
|
+
setSkillFlyoutOpen(false);
|
|
322
|
+
}
|
|
323
|
+
}, [open]);
|
|
324
|
+
|
|
325
|
+
const handleSkillFileSelected = async (files: FileList | null) => {
|
|
326
|
+
if (!files || files.length === 0) return;
|
|
327
|
+
const file = files[0];
|
|
328
|
+
const text = await file.text();
|
|
329
|
+
const baseName = file.name.replace(/\.[^./]+$/, "");
|
|
330
|
+
const slug = slugifyName(
|
|
331
|
+
baseName.toLowerCase() === "skill" ? "uploaded-skill" : baseName,
|
|
332
|
+
);
|
|
333
|
+
setSkillUploadSlug(slug);
|
|
334
|
+
setSkillUploadContent(text);
|
|
335
|
+
setSkillUploadFileName(file.name);
|
|
336
|
+
setSkillUploadStatus(null);
|
|
337
|
+
setView("skill-upload");
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const handleFilesSelected = async (files: FileList | null) => {
|
|
341
|
+
if (!files || files.length === 0) return;
|
|
342
|
+
try {
|
|
343
|
+
await Promise.all(
|
|
344
|
+
Array.from(files).map((file) => composerRuntime.addAttachment(file)),
|
|
345
|
+
);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
onAttachmentError?.(
|
|
348
|
+
formatAttachmentError(error, "Could not upload the selected file."),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const submitSkillUpload = async () => {
|
|
354
|
+
if (skillUploadBusy) return;
|
|
355
|
+
const slug = slugifyName(skillUploadSlug || "uploaded-skill");
|
|
356
|
+
const path = `skills/${slug}/SKILL.md`;
|
|
357
|
+
setSkillUploadBusy(true);
|
|
358
|
+
setSkillUploadStatus(null);
|
|
359
|
+
try {
|
|
360
|
+
const res = await fetch(
|
|
361
|
+
adapters.resolvePath!("/_agent-native/resources"),
|
|
362
|
+
{
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "application/json" },
|
|
365
|
+
body: JSON.stringify({
|
|
366
|
+
path,
|
|
367
|
+
content: skillUploadContent,
|
|
368
|
+
mimeType: "text/markdown",
|
|
369
|
+
shared: false,
|
|
370
|
+
}),
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
const body = await res.text().catch(() => "");
|
|
375
|
+
throw new Error(body || `Upload failed (${res.status})`);
|
|
376
|
+
}
|
|
377
|
+
setSkillUploadStatus({
|
|
378
|
+
kind: "ok",
|
|
379
|
+
message: `Skill "${skillUploadFileName || `${slug}/SKILL.md`}" added`,
|
|
380
|
+
});
|
|
381
|
+
window.setTimeout(() => setOpen(false), 1200);
|
|
382
|
+
} catch (err: any) {
|
|
383
|
+
setSkillUploadStatus({
|
|
384
|
+
kind: "err",
|
|
385
|
+
message: err?.message || "Failed to save skill file",
|
|
386
|
+
});
|
|
387
|
+
} finally {
|
|
388
|
+
setSkillUploadBusy(false);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const menuItems: {
|
|
393
|
+
icon: React.ReactNode;
|
|
394
|
+
label: string;
|
|
395
|
+
desc: string;
|
|
396
|
+
action: () => void;
|
|
397
|
+
hoverAction?: () => void;
|
|
398
|
+
}[] = [
|
|
399
|
+
{
|
|
400
|
+
icon: <IconUpload className="h-3.5 w-3.5" />,
|
|
401
|
+
label: "Upload File",
|
|
402
|
+
desc: "Images, PDFs, text/code, JSON, CSV",
|
|
403
|
+
action: () => {
|
|
404
|
+
setOpen(false);
|
|
405
|
+
setTimeout(() => fileUploadRef.current?.click(), 0);
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
icon: <IconPhotoPlus className="h-3.5 w-3.5" />,
|
|
410
|
+
label: "Generate Image",
|
|
411
|
+
desc: "Open the Assets image picker",
|
|
412
|
+
action: () => {
|
|
413
|
+
setOpen(false);
|
|
414
|
+
setAssetsPickerOpen(true);
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
icon: <IconClock className="h-3.5 w-3.5" />,
|
|
419
|
+
label: "Schedule Task",
|
|
420
|
+
desc: "Run something on a schedule",
|
|
421
|
+
action: () => {
|
|
422
|
+
onSelectMode?.("job");
|
|
423
|
+
setOpen(false);
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
icon: <IconBolt className="h-3.5 w-3.5" />,
|
|
428
|
+
label: "Create Automation",
|
|
429
|
+
desc: "Set up a when-X-do-Y rule",
|
|
430
|
+
action: () => {
|
|
431
|
+
onSelectMode?.("automation");
|
|
432
|
+
setOpen(false);
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
icon: <IconTool className="h-3.5 w-3.5" />,
|
|
437
|
+
label: "Create Extension",
|
|
438
|
+
desc: "Build a mini app extension",
|
|
439
|
+
action: () => {
|
|
440
|
+
onSelectMode?.("extension");
|
|
441
|
+
setOpen(false);
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
...(showMcpIntegrations
|
|
445
|
+
? [
|
|
446
|
+
{
|
|
447
|
+
icon: <IconPlugConnected className="h-3.5 w-3.5" />,
|
|
448
|
+
label: t("mcpIntegrations.menuLabel"),
|
|
449
|
+
desc: t("mcpIntegrations.menuDescription"),
|
|
450
|
+
action: () => {
|
|
451
|
+
setOpen(false);
|
|
452
|
+
setMcpDialogOpen(true);
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
]
|
|
456
|
+
: []),
|
|
457
|
+
{
|
|
458
|
+
icon: <IconBulb className="h-3.5 w-3.5" />,
|
|
459
|
+
label: "Create Skill",
|
|
460
|
+
desc: "Teach the agent a new ability",
|
|
461
|
+
action: openSkillFlyout,
|
|
462
|
+
hoverAction: openSkillFlyout,
|
|
463
|
+
},
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<>
|
|
468
|
+
{/* Hidden input to trigger the native file upload with explicit errors. */}
|
|
469
|
+
<input
|
|
470
|
+
ref={fileUploadRef}
|
|
471
|
+
type="file"
|
|
472
|
+
multiple
|
|
473
|
+
className="hidden"
|
|
474
|
+
onChange={(event) => {
|
|
475
|
+
void handleFilesSelected(event.target.files);
|
|
476
|
+
event.target.value = "";
|
|
477
|
+
}}
|
|
478
|
+
/>
|
|
479
|
+
<input
|
|
480
|
+
ref={skillFileInputRef}
|
|
481
|
+
type="file"
|
|
482
|
+
accept=".md,text/markdown"
|
|
483
|
+
className="hidden"
|
|
484
|
+
onChange={(e) => {
|
|
485
|
+
void handleSkillFileSelected(e.target.files);
|
|
486
|
+
e.target.value = "";
|
|
487
|
+
}}
|
|
488
|
+
/>
|
|
489
|
+
|
|
490
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
491
|
+
<Tooltip>
|
|
492
|
+
<TooltipTrigger asChild>
|
|
493
|
+
<PopoverTrigger asChild>
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
497
|
+
>
|
|
498
|
+
<IconPlus className="h-4 w-4" />
|
|
499
|
+
</button>
|
|
500
|
+
</PopoverTrigger>
|
|
501
|
+
</TooltipTrigger>
|
|
502
|
+
<TooltipContent>Add...</TooltipContent>
|
|
503
|
+
</Tooltip>
|
|
504
|
+
<PopoverContent
|
|
505
|
+
side="top"
|
|
506
|
+
align="start"
|
|
507
|
+
sideOffset={8}
|
|
508
|
+
className={cn(
|
|
509
|
+
"p-0 rounded-lg",
|
|
510
|
+
view === "skill-upload"
|
|
511
|
+
? "max-h-[70vh] w-[calc(100vw-24px)] max-w-[380px] overflow-y-auto"
|
|
512
|
+
: "w-[260px]",
|
|
513
|
+
)}
|
|
514
|
+
style={{ fontSize: 13, lineHeight: "normal" }}
|
|
515
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
516
|
+
>
|
|
517
|
+
{view === "menu" && (
|
|
518
|
+
<div className="py-1">
|
|
519
|
+
{menuItems.map((item) => {
|
|
520
|
+
const isSkill = item.label === "Create Skill";
|
|
521
|
+
return (
|
|
522
|
+
<div
|
|
523
|
+
key={item.label}
|
|
524
|
+
className={cn("relative", isSkill && "group/skill")}
|
|
525
|
+
onMouseEnter={(e) => {
|
|
526
|
+
if (isSkill) {
|
|
527
|
+
openSkillFlyout(e.currentTarget);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (!item.hoverAction) return;
|
|
531
|
+
if (skillHoverTimerRef.current)
|
|
532
|
+
window.clearTimeout(skillHoverTimerRef.current);
|
|
533
|
+
skillHoverTimerRef.current = window.setTimeout(() => {
|
|
534
|
+
item.hoverAction?.();
|
|
535
|
+
}, 180);
|
|
536
|
+
}}
|
|
537
|
+
onMouseLeave={() => {
|
|
538
|
+
if (isSkill) {
|
|
539
|
+
scheduleSkillFlyoutClose();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (skillHoverTimerRef.current) {
|
|
543
|
+
window.clearTimeout(skillHoverTimerRef.current);
|
|
544
|
+
skillHoverTimerRef.current = null;
|
|
545
|
+
}
|
|
546
|
+
}}
|
|
547
|
+
>
|
|
548
|
+
<button
|
|
549
|
+
type="button"
|
|
550
|
+
onClick={item.action}
|
|
551
|
+
className={cn(
|
|
552
|
+
"flex w-full items-center gap-2.5 px-3 py-2 text-start hover:bg-accent/50",
|
|
553
|
+
isSkill && skillFlyoutOpen && "bg-accent/50",
|
|
554
|
+
)}
|
|
555
|
+
>
|
|
556
|
+
<span className="text-muted-foreground">{item.icon}</span>
|
|
557
|
+
<div className="min-w-0 flex-1">
|
|
558
|
+
<div className="text-[12px] font-medium text-foreground">
|
|
559
|
+
{item.label}
|
|
560
|
+
</div>
|
|
561
|
+
<div className="mt-0.5 text-[10px] text-muted-foreground/60">
|
|
562
|
+
{item.desc}
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
{isSkill && (
|
|
566
|
+
<span className="ms-auto text-muted-foreground/60">
|
|
567
|
+
›
|
|
568
|
+
</span>
|
|
569
|
+
)}
|
|
570
|
+
</button>
|
|
571
|
+
{isSkill && skillFlyoutOpen && (
|
|
572
|
+
<div
|
|
573
|
+
role="menu"
|
|
574
|
+
onMouseEnter={() => openSkillFlyout()}
|
|
575
|
+
onMouseLeave={scheduleSkillFlyoutClose}
|
|
576
|
+
className={cn(
|
|
577
|
+
"absolute top-0 z-20 w-[240px] rounded-lg border border-border bg-popover py-1 shadow-md",
|
|
578
|
+
skillFlyoutSide === "right"
|
|
579
|
+
? "left-full ml-1"
|
|
580
|
+
: "right-full mr-1",
|
|
581
|
+
)}
|
|
582
|
+
>
|
|
583
|
+
<button
|
|
584
|
+
type="button"
|
|
585
|
+
onClick={() => {
|
|
586
|
+
onSelectMode?.("skill");
|
|
587
|
+
setSkillFlyoutOpen(false);
|
|
588
|
+
setOpen(false);
|
|
589
|
+
}}
|
|
590
|
+
className="flex w-full items-center gap-2.5 px-3 py-2 text-start hover:bg-accent/50"
|
|
591
|
+
>
|
|
592
|
+
<span className="text-muted-foreground">
|
|
593
|
+
<IconBulb className="h-3.5 w-3.5" />
|
|
594
|
+
</span>
|
|
595
|
+
<div className="min-w-0">
|
|
596
|
+
<div className="text-[12px] font-medium text-foreground">
|
|
597
|
+
Create new skill
|
|
598
|
+
</div>
|
|
599
|
+
<div className="mt-0.5 text-[10px] text-muted-foreground/60">
|
|
600
|
+
Describe a skill and let the agent draft it
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</button>
|
|
604
|
+
<button
|
|
605
|
+
type="button"
|
|
606
|
+
onClick={() => {
|
|
607
|
+
setSkillFlyoutOpen(false);
|
|
608
|
+
skillFileInputRef.current?.click();
|
|
609
|
+
}}
|
|
610
|
+
className="flex w-full items-center gap-2.5 px-3 py-2 text-start hover:bg-accent/50"
|
|
611
|
+
>
|
|
612
|
+
<span className="text-muted-foreground">
|
|
613
|
+
<IconUpload className="h-3.5 w-3.5" />
|
|
614
|
+
</span>
|
|
615
|
+
<div className="min-w-0">
|
|
616
|
+
<div className="text-[12px] font-medium text-foreground">
|
|
617
|
+
Upload skill file
|
|
618
|
+
</div>
|
|
619
|
+
<div className="mt-0.5 text-[10px] text-muted-foreground/60">
|
|
620
|
+
Import an existing SKILL.md file
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
</button>
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
626
|
+
</div>
|
|
627
|
+
);
|
|
628
|
+
})}
|
|
629
|
+
</div>
|
|
630
|
+
)}
|
|
631
|
+
|
|
632
|
+
{view === "skill-upload" && (
|
|
633
|
+
<div className="p-3">
|
|
634
|
+
<button
|
|
635
|
+
type="button"
|
|
636
|
+
onClick={() => setView("menu")}
|
|
637
|
+
className="mb-1.5 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
|
638
|
+
>
|
|
639
|
+
<IconArrowLeft className="h-3 w-3 rtl:-scale-x-100" />
|
|
640
|
+
Back
|
|
641
|
+
</button>
|
|
642
|
+
<label className="mb-1 block text-[11px] font-semibold text-foreground">
|
|
643
|
+
Upload skill file
|
|
644
|
+
</label>
|
|
645
|
+
<p className="mb-2 text-[10px] leading-relaxed text-muted-foreground/60">
|
|
646
|
+
Review the content from{" "}
|
|
647
|
+
<span className="font-mono">
|
|
648
|
+
{skillUploadFileName || "the selected file"}
|
|
649
|
+
</span>{" "}
|
|
650
|
+
before saving.
|
|
651
|
+
</p>
|
|
652
|
+
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">
|
|
653
|
+
Skill name
|
|
654
|
+
</label>
|
|
655
|
+
<input
|
|
656
|
+
value={skillUploadSlug}
|
|
657
|
+
onChange={(e) => setSkillUploadSlug(e.target.value)}
|
|
658
|
+
className="mb-2 w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent"
|
|
659
|
+
placeholder="my-skill"
|
|
660
|
+
/>
|
|
661
|
+
<p className="mb-2 text-[10px] text-muted-foreground/60">
|
|
662
|
+
Saved at{" "}
|
|
663
|
+
<span className="font-mono">
|
|
664
|
+
skills/{slugifyName(skillUploadSlug || "uploaded-skill")}
|
|
665
|
+
/SKILL.md
|
|
666
|
+
</span>
|
|
667
|
+
</p>
|
|
668
|
+
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">
|
|
669
|
+
Content
|
|
670
|
+
</label>
|
|
671
|
+
<textarea
|
|
672
|
+
value={skillUploadContent}
|
|
673
|
+
onChange={(e) => setSkillUploadContent(e.target.value)}
|
|
674
|
+
rows={10}
|
|
675
|
+
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 font-mono text-[11px] leading-relaxed text-foreground outline-none focus:ring-1 focus:ring-accent"
|
|
676
|
+
/>
|
|
677
|
+
{skillUploadStatus && (
|
|
678
|
+
<div
|
|
679
|
+
className={cn(
|
|
680
|
+
"mt-2 text-[11px] leading-snug",
|
|
681
|
+
skillUploadStatus.kind === "ok"
|
|
682
|
+
? "text-green-600 dark:text-green-400"
|
|
683
|
+
: "text-red-600 dark:text-red-400",
|
|
684
|
+
)}
|
|
685
|
+
>
|
|
686
|
+
{skillUploadStatus.message}
|
|
687
|
+
</div>
|
|
688
|
+
)}
|
|
689
|
+
<div className="mt-2.5 flex justify-end gap-2">
|
|
690
|
+
<button
|
|
691
|
+
type="button"
|
|
692
|
+
onClick={() => setView("menu")}
|
|
693
|
+
className="rounded-md px-3 py-1.5 text-[12px] font-medium text-muted-foreground hover:bg-accent/40"
|
|
694
|
+
>
|
|
695
|
+
Cancel
|
|
696
|
+
</button>
|
|
697
|
+
<button
|
|
698
|
+
type="button"
|
|
699
|
+
onClick={submitSkillUpload}
|
|
700
|
+
disabled={
|
|
701
|
+
skillUploadBusy ||
|
|
702
|
+
!skillUploadContent.trim() ||
|
|
703
|
+
!skillUploadSlug.trim()
|
|
704
|
+
}
|
|
705
|
+
className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-medium text-foreground hover:bg-accent/80 disabled:opacity-40 disabled:pointer-events-none"
|
|
706
|
+
>
|
|
707
|
+
{skillUploadBusy ? (
|
|
708
|
+
<IconLoader2 className="h-3 w-3 animate-spin" />
|
|
709
|
+
) : (
|
|
710
|
+
"Save"
|
|
711
|
+
)}
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
)}
|
|
716
|
+
</PopoverContent>
|
|
717
|
+
</Popover>
|
|
718
|
+
{McpIntegrationDialog ? (
|
|
719
|
+
<McpIntegrationDialog
|
|
720
|
+
open={mcpDialogOpen}
|
|
721
|
+
onOpenChange={setMcpDialogOpen}
|
|
722
|
+
defaultScope={defaultMcpScope}
|
|
723
|
+
canCreateOrgMcp={canCreateOrgMcp}
|
|
724
|
+
hasOrg={hasOrg}
|
|
725
|
+
onCreateMcpServer={(args: unknown) => createMcp.mutateAsync(args)}
|
|
726
|
+
/>
|
|
727
|
+
) : null}
|
|
728
|
+
<AssetsPickerModal
|
|
729
|
+
open={assetsPickerOpen}
|
|
730
|
+
onOpenChange={setAssetsPickerOpen}
|
|
731
|
+
/>
|
|
732
|
+
</>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function AssetsPickerModal({
|
|
737
|
+
open,
|
|
738
|
+
onOpenChange,
|
|
739
|
+
}: {
|
|
740
|
+
open: boolean;
|
|
741
|
+
onOpenChange: (open: boolean) => void;
|
|
742
|
+
}) {
|
|
743
|
+
const adapters = useComposerRuntimeAdapters();
|
|
744
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
745
|
+
const standaloneWindowRef = useRef<Window | null>(null);
|
|
746
|
+
const [pickerReady, setPickerReady] = useState(false);
|
|
747
|
+
const [standaloneHandoffId, setStandaloneHandoffId] = useState<string | null>(
|
|
748
|
+
null,
|
|
749
|
+
);
|
|
750
|
+
const sourceUrl = useMemo(() => assetPickerUrl(), []);
|
|
751
|
+
const externalPicker = useMemo(
|
|
752
|
+
() =>
|
|
753
|
+
typeof window !== "undefined" &&
|
|
754
|
+
isExternalAssetPickerUrl(sourceUrl, window.location.origin),
|
|
755
|
+
[sourceUrl],
|
|
756
|
+
);
|
|
757
|
+
const standaloneUrl = useMemo(
|
|
758
|
+
() =>
|
|
759
|
+
standaloneAssetPickerUrl(
|
|
760
|
+
sourceUrl,
|
|
761
|
+
typeof window !== "undefined" ? window.location.href : undefined,
|
|
762
|
+
{
|
|
763
|
+
handoffId: standaloneHandoffId ?? undefined,
|
|
764
|
+
returnOrigin:
|
|
765
|
+
typeof window !== "undefined" ? window.location.origin : undefined,
|
|
766
|
+
},
|
|
767
|
+
),
|
|
768
|
+
[sourceUrl, standaloneHandoffId],
|
|
769
|
+
);
|
|
770
|
+
const iframeUrl = useMemo(() => withEmbeddedParams(sourceUrl), [sourceUrl]);
|
|
771
|
+
const targetOrigin = useMemo(() => assetPickerOrigin(iframeUrl), [iframeUrl]);
|
|
772
|
+
const configurePicker = useCallback(() => {
|
|
773
|
+
if (!targetOrigin) return;
|
|
774
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
775
|
+
embedEnvelope("message", {
|
|
776
|
+
name: "configure",
|
|
777
|
+
payload: { mediaType: "image", count: 3 },
|
|
778
|
+
}),
|
|
779
|
+
targetOrigin,
|
|
780
|
+
);
|
|
781
|
+
}, [targetOrigin]);
|
|
782
|
+
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
if (open) {
|
|
785
|
+
setPickerReady(false);
|
|
786
|
+
if (externalPicker) {
|
|
787
|
+
standaloneWindowRef.current = null;
|
|
788
|
+
setStandaloneHandoffId(createAssetPickerHandoffId());
|
|
789
|
+
} else {
|
|
790
|
+
setStandaloneHandoffId(null);
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (!standaloneWindowRef.current) setStandaloneHandoffId(null);
|
|
795
|
+
}, [externalPicker, iframeUrl, open]);
|
|
796
|
+
|
|
797
|
+
useEffect(() => {
|
|
798
|
+
if (
|
|
799
|
+
!targetOrigin ||
|
|
800
|
+
(!open && !standaloneWindowRef.current) ||
|
|
801
|
+
(externalPicker && !standaloneHandoffId)
|
|
802
|
+
)
|
|
803
|
+
return;
|
|
804
|
+
|
|
805
|
+
const handleMessage = (event: MessageEvent) => {
|
|
806
|
+
const expectedSource = externalPicker
|
|
807
|
+
? standaloneWindowRef.current
|
|
808
|
+
: iframeRef.current?.contentWindow;
|
|
809
|
+
if (!expectedSource || event.source !== expectedSource) return;
|
|
810
|
+
if (event.origin !== targetOrigin) return;
|
|
811
|
+
if (!isEmbedEnvelope(event.data)) return;
|
|
812
|
+
|
|
813
|
+
if (event.data.type === "ready") {
|
|
814
|
+
setPickerReady(true);
|
|
815
|
+
configurePicker();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (event.data.type !== "message") return;
|
|
820
|
+
if (externalPicker) {
|
|
821
|
+
const payload = event.data.payload;
|
|
822
|
+
const handoffId =
|
|
823
|
+
payload && typeof payload === "object"
|
|
824
|
+
? assetString((payload as AssetPickerPayload).handoffId)
|
|
825
|
+
: null;
|
|
826
|
+
if (handoffId !== standaloneHandoffId) return;
|
|
827
|
+
}
|
|
828
|
+
if (event.data.name === "close") {
|
|
829
|
+
onOpenChange(false);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (
|
|
833
|
+
event.data.name !== "chooseImage" &&
|
|
834
|
+
event.data.name !== "chooseAsset"
|
|
835
|
+
)
|
|
836
|
+
return;
|
|
837
|
+
|
|
838
|
+
const url = assetImageSource(event.data.payload);
|
|
839
|
+
if (!url) return;
|
|
840
|
+
const title = assetTitle(event.data.payload, url);
|
|
841
|
+
const assetId =
|
|
842
|
+
event.data.payload && typeof event.data.payload === "object"
|
|
843
|
+
? assetString((event.data.payload as AssetPickerPayload).assetId)
|
|
844
|
+
: null;
|
|
845
|
+
adapters.agentChat!.setContextItem!({
|
|
846
|
+
key: `asset-image:${assetId ?? url}`,
|
|
847
|
+
title: `Image: ${title}`,
|
|
848
|
+
context: assetContext(event.data.payload, url),
|
|
849
|
+
});
|
|
850
|
+
standaloneWindowRef.current = null;
|
|
851
|
+
setStandaloneHandoffId(null);
|
|
852
|
+
onOpenChange(false);
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
856
|
+
if (event.key === "Escape") onOpenChange(false);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
window.addEventListener("message", handleMessage);
|
|
860
|
+
window.addEventListener("keydown", handleKey);
|
|
861
|
+
return () => {
|
|
862
|
+
window.removeEventListener("message", handleMessage);
|
|
863
|
+
window.removeEventListener("keydown", handleKey);
|
|
864
|
+
};
|
|
865
|
+
}, [
|
|
866
|
+
configurePicker,
|
|
867
|
+
adapters,
|
|
868
|
+
externalPicker,
|
|
869
|
+
onOpenChange,
|
|
870
|
+
open,
|
|
871
|
+
standaloneHandoffId,
|
|
872
|
+
targetOrigin,
|
|
873
|
+
]);
|
|
874
|
+
|
|
875
|
+
const openStandalonePicker = useCallback(
|
|
876
|
+
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
877
|
+
if (!standaloneHandoffId) return;
|
|
878
|
+
event.preventDefault();
|
|
879
|
+
const pickerWindow = window.open(standaloneUrl, "_blank");
|
|
880
|
+
if (!pickerWindow) return;
|
|
881
|
+
standaloneWindowRef.current = pickerWindow;
|
|
882
|
+
onOpenChange(false);
|
|
883
|
+
},
|
|
884
|
+
[onOpenChange, standaloneHandoffId, standaloneUrl],
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
if (!open || typeof document === "undefined") return null;
|
|
888
|
+
|
|
889
|
+
return createPortal(
|
|
890
|
+
<div
|
|
891
|
+
className="fixed inset-0 z-[280] flex items-center justify-center bg-black/50 p-3"
|
|
892
|
+
role="dialog"
|
|
893
|
+
aria-modal="true"
|
|
894
|
+
aria-labelledby="composer-assets-picker-title"
|
|
895
|
+
onMouseDown={(event) => {
|
|
896
|
+
if (event.target === event.currentTarget) onOpenChange(false);
|
|
897
|
+
}}
|
|
898
|
+
>
|
|
899
|
+
<div className="flex h-[min(86vh,760px)] w-[min(96vw,1040px)] flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl">
|
|
900
|
+
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border px-4">
|
|
901
|
+
<div
|
|
902
|
+
id="composer-assets-picker-title"
|
|
903
|
+
className="text-sm font-medium text-foreground"
|
|
904
|
+
>
|
|
905
|
+
Generate image
|
|
906
|
+
</div>
|
|
907
|
+
<button
|
|
908
|
+
type="button"
|
|
909
|
+
onClick={() => onOpenChange(false)}
|
|
910
|
+
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
911
|
+
aria-label="Close image picker"
|
|
912
|
+
>
|
|
913
|
+
<IconX className="h-4 w-4" />
|
|
914
|
+
</button>
|
|
915
|
+
</div>
|
|
916
|
+
{externalPicker ? (
|
|
917
|
+
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
|
918
|
+
<div className="max-w-md text-sm text-muted-foreground">
|
|
919
|
+
Open Assets in a new tab to sign in and choose an image securely.
|
|
920
|
+
</div>
|
|
921
|
+
<a
|
|
922
|
+
href={standaloneUrl}
|
|
923
|
+
target="_blank"
|
|
924
|
+
onClick={openStandalonePicker}
|
|
925
|
+
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
|
926
|
+
>
|
|
927
|
+
Open Assets image picker
|
|
928
|
+
</a>
|
|
929
|
+
</div>
|
|
930
|
+
) : targetOrigin ? (
|
|
931
|
+
<div className="relative min-h-0 flex-1 overflow-hidden bg-background">
|
|
932
|
+
{!pickerReady && <AssetsPickerLoadingSkeleton />}
|
|
933
|
+
<iframe
|
|
934
|
+
ref={iframeRef}
|
|
935
|
+
src={iframeUrl}
|
|
936
|
+
title="Assets image picker"
|
|
937
|
+
className={cn(
|
|
938
|
+
"absolute inset-0 h-full w-full border-0 bg-background transition-opacity duration-150",
|
|
939
|
+
pickerReady ? "opacity-100" : "pointer-events-none opacity-0",
|
|
940
|
+
)}
|
|
941
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
|
|
942
|
+
allow="clipboard-read; clipboard-write; microphone; fullscreen"
|
|
943
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
944
|
+
onLoad={() => {
|
|
945
|
+
configurePicker();
|
|
946
|
+
setPickerReady(true);
|
|
947
|
+
}}
|
|
948
|
+
/>
|
|
949
|
+
</div>
|
|
950
|
+
) : (
|
|
951
|
+
<div className="flex min-h-0 flex-1 items-center justify-center p-8 text-center text-sm text-muted-foreground">
|
|
952
|
+
The configured image picker URL is not valid.
|
|
953
|
+
</div>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
956
|
+
</div>,
|
|
957
|
+
document.body,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function AssetsPickerLoadingSkeleton() {
|
|
962
|
+
return (
|
|
963
|
+
<div
|
|
964
|
+
className="absolute inset-0 flex flex-col gap-5 p-5"
|
|
965
|
+
role="status"
|
|
966
|
+
aria-label="Loading Assets picker"
|
|
967
|
+
>
|
|
968
|
+
<div className="flex items-center gap-3">
|
|
969
|
+
<div className="h-9 flex-1 animate-pulse rounded-md bg-muted" />
|
|
970
|
+
<div className="h-9 w-24 animate-pulse rounded-md bg-muted" />
|
|
971
|
+
</div>
|
|
972
|
+
<div className="grid min-h-0 flex-1 grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
|
973
|
+
{Array.from({ length: 8 }).map((_, index) => (
|
|
974
|
+
<div key={index} className="flex min-w-0 flex-col gap-2">
|
|
975
|
+
<div className="aspect-square w-full animate-pulse rounded-lg bg-muted" />
|
|
976
|
+
<div className="h-3 w-3/4 animate-pulse rounded bg-muted" />
|
|
977
|
+
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
|
978
|
+
</div>
|
|
979
|
+
))}
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
);
|
|
983
|
+
}
|