@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,412 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IconFile,
|
|
3
|
+
IconFolder,
|
|
4
|
+
IconStack2,
|
|
5
|
+
IconFileText,
|
|
6
|
+
IconCheckbox,
|
|
7
|
+
IconMail,
|
|
8
|
+
IconUser,
|
|
9
|
+
IconPresentation,
|
|
10
|
+
IconMessageChatbot,
|
|
11
|
+
IconTrash,
|
|
12
|
+
IconPlus,
|
|
13
|
+
IconHelp,
|
|
14
|
+
IconHistory,
|
|
15
|
+
IconTerminal2,
|
|
16
|
+
IconClipboardList,
|
|
17
|
+
IconPencil,
|
|
18
|
+
} from "@tabler/icons-react";
|
|
19
|
+
import React, {
|
|
20
|
+
useState,
|
|
21
|
+
useEffect,
|
|
22
|
+
useRef,
|
|
23
|
+
useImperativeHandle,
|
|
24
|
+
forwardRef,
|
|
25
|
+
} from "react";
|
|
26
|
+
import { createPortal } from "react-dom";
|
|
27
|
+
|
|
28
|
+
import type { MentionItem, SkillResult, SlashCommand } from "./types.js";
|
|
29
|
+
|
|
30
|
+
export interface MentionPopoverRef {
|
|
31
|
+
moveUp: () => void;
|
|
32
|
+
moveDown: () => void;
|
|
33
|
+
getSelectedIndex: () => number;
|
|
34
|
+
getSelectedMention: () => MentionItem | null;
|
|
35
|
+
getSelectedCommand: () => SlashCommand | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MentionPopoverProps {
|
|
39
|
+
type: "@" | "/";
|
|
40
|
+
position: { top: number; left: number } | null;
|
|
41
|
+
mentionItems: MentionItem[];
|
|
42
|
+
skills: SkillResult[];
|
|
43
|
+
commands?: SlashCommand[];
|
|
44
|
+
hint?: string;
|
|
45
|
+
isLoading: boolean;
|
|
46
|
+
query: string;
|
|
47
|
+
onSelectMention: (item: MentionItem) => void;
|
|
48
|
+
onSelectSkill: (skill: SkillResult) => void;
|
|
49
|
+
onSelectCommand?: (command: SlashCommand) => void;
|
|
50
|
+
onClose: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const iconProps = { size: 14, className: "shrink-0 text-muted-foreground" };
|
|
54
|
+
|
|
55
|
+
function MentionItemIcon({ icon }: { icon?: string }) {
|
|
56
|
+
switch (icon) {
|
|
57
|
+
case "folder":
|
|
58
|
+
return <IconFolder {...iconProps} />;
|
|
59
|
+
case "document":
|
|
60
|
+
return <IconFileText {...iconProps} />;
|
|
61
|
+
case "form":
|
|
62
|
+
return <IconCheckbox {...iconProps} />;
|
|
63
|
+
case "email":
|
|
64
|
+
return <IconMail {...iconProps} />;
|
|
65
|
+
case "user":
|
|
66
|
+
return <IconUser {...iconProps} />;
|
|
67
|
+
case "deck":
|
|
68
|
+
return <IconPresentation {...iconProps} />;
|
|
69
|
+
case "agent":
|
|
70
|
+
return <IconMessageChatbot {...iconProps} />;
|
|
71
|
+
case "file":
|
|
72
|
+
return <IconFile {...iconProps} />;
|
|
73
|
+
default:
|
|
74
|
+
return <IconFile {...iconProps} />;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function CommandIcon({ icon }: { icon?: string }) {
|
|
79
|
+
switch (icon) {
|
|
80
|
+
case "clear":
|
|
81
|
+
return <IconTrash {...iconProps} />;
|
|
82
|
+
case "new":
|
|
83
|
+
return <IconPlus {...iconProps} />;
|
|
84
|
+
case "help":
|
|
85
|
+
return <IconHelp {...iconProps} />;
|
|
86
|
+
case "history":
|
|
87
|
+
return <IconHistory {...iconProps} />;
|
|
88
|
+
case "plan":
|
|
89
|
+
return <IconClipboardList {...iconProps} />;
|
|
90
|
+
case "act":
|
|
91
|
+
return <IconPencil {...iconProps} />;
|
|
92
|
+
default:
|
|
93
|
+
return <IconTerminal2 {...iconProps} />;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function HintWithLink({ hint }: { hint: string }) {
|
|
98
|
+
// If hint contains a URL, split it and render the URL as a link
|
|
99
|
+
const urlMatch = hint.match(/(https?:\/\/\S+)/);
|
|
100
|
+
if (!urlMatch) return <>{hint}</>;
|
|
101
|
+
const before = hint.slice(0, urlMatch.index);
|
|
102
|
+
const url = urlMatch[1];
|
|
103
|
+
return (
|
|
104
|
+
<>
|
|
105
|
+
{before}
|
|
106
|
+
<a
|
|
107
|
+
href={url}
|
|
108
|
+
target="_blank"
|
|
109
|
+
rel="noopener noreferrer"
|
|
110
|
+
className="underline hover:text-foreground"
|
|
111
|
+
>
|
|
112
|
+
Learn more
|
|
113
|
+
</a>
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function LoadingSkeleton() {
|
|
119
|
+
return (
|
|
120
|
+
<div className="space-y-1 p-1">
|
|
121
|
+
{[1, 2, 3].map((i) => (
|
|
122
|
+
<div key={i} className="flex items-center gap-2 rounded px-2 py-1.5">
|
|
123
|
+
<div className="h-3.5 w-3.5 rounded bg-muted animate-pulse" />
|
|
124
|
+
<div
|
|
125
|
+
className="h-3 rounded bg-muted animate-pulse"
|
|
126
|
+
style={{ width: `${60 + i * 20}px` }}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function LoadingSkeletonRow() {
|
|
135
|
+
return (
|
|
136
|
+
<div className="flex items-center gap-2 rounded px-2 py-1.5">
|
|
137
|
+
<div className="h-3.5 w-3.5 rounded bg-muted animate-pulse" />
|
|
138
|
+
<div className="h-3 w-24 rounded bg-muted animate-pulse" />
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const MentionPopover = forwardRef<
|
|
144
|
+
MentionPopoverRef,
|
|
145
|
+
MentionPopoverProps
|
|
146
|
+
>(function MentionPopover(props, ref) {
|
|
147
|
+
const {
|
|
148
|
+
type,
|
|
149
|
+
position,
|
|
150
|
+
mentionItems,
|
|
151
|
+
skills,
|
|
152
|
+
commands = [],
|
|
153
|
+
hint,
|
|
154
|
+
isLoading,
|
|
155
|
+
query,
|
|
156
|
+
onSelectMention,
|
|
157
|
+
onSelectSkill,
|
|
158
|
+
onSelectCommand,
|
|
159
|
+
onClose,
|
|
160
|
+
} = props;
|
|
161
|
+
|
|
162
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
163
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
164
|
+
|
|
165
|
+
const itemCount =
|
|
166
|
+
type === "@" ? mentionItems.length : commands.length + skills.length;
|
|
167
|
+
|
|
168
|
+
// Group mention items by section for @ popover
|
|
169
|
+
const groupedMentions = React.useMemo(() => {
|
|
170
|
+
if (type !== "@") return [];
|
|
171
|
+
const groups = new Map<string, MentionItem[]>();
|
|
172
|
+
for (const item of mentionItems) {
|
|
173
|
+
const section = item.section || "Other";
|
|
174
|
+
if (!groups.has(section)) groups.set(section, []);
|
|
175
|
+
groups.get(section)!.push(item);
|
|
176
|
+
}
|
|
177
|
+
// Sort: Agents first, then Connected Agents, then template-specific,
|
|
178
|
+
// then Files, then Other
|
|
179
|
+
const sorted: { section: string; items: MentionItem[] }[] = [];
|
|
180
|
+
const knownSections = new Set([
|
|
181
|
+
"Agents",
|
|
182
|
+
"Connected Agents",
|
|
183
|
+
"Files",
|
|
184
|
+
"Other",
|
|
185
|
+
]);
|
|
186
|
+
// Agents first
|
|
187
|
+
if (groups.has("Agents")) {
|
|
188
|
+
sorted.push({ section: "Agents", items: groups.get("Agents")! });
|
|
189
|
+
groups.delete("Agents");
|
|
190
|
+
}
|
|
191
|
+
if (groups.has("Connected Agents")) {
|
|
192
|
+
sorted.push({
|
|
193
|
+
section: "Connected Agents",
|
|
194
|
+
items: groups.get("Connected Agents")!,
|
|
195
|
+
});
|
|
196
|
+
groups.delete("Connected Agents");
|
|
197
|
+
}
|
|
198
|
+
// Template-specific sections (anything not in knownSections)
|
|
199
|
+
for (const [section, items] of groups) {
|
|
200
|
+
if (!knownSections.has(section)) {
|
|
201
|
+
sorted.push({ section, items });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Files
|
|
205
|
+
if (groups.has("Files")) {
|
|
206
|
+
sorted.push({ section: "Files", items: groups.get("Files")! });
|
|
207
|
+
}
|
|
208
|
+
// Other
|
|
209
|
+
if (groups.has("Other")) {
|
|
210
|
+
sorted.push({ section: "Other", items: groups.get("Other")! });
|
|
211
|
+
}
|
|
212
|
+
return sorted;
|
|
213
|
+
}, [type, mentionItems]);
|
|
214
|
+
|
|
215
|
+
// Flat list of mention items in section order for keyboard index tracking
|
|
216
|
+
const flatMentionItems = React.useMemo(() => {
|
|
217
|
+
return groupedMentions.flatMap((g) => g.items);
|
|
218
|
+
}, [groupedMentions]);
|
|
219
|
+
|
|
220
|
+
// Reset selection when items change
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
setSelectedIndex(0);
|
|
223
|
+
}, [commands, mentionItems, skills, query]);
|
|
224
|
+
|
|
225
|
+
// Scroll selected item into view
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const container = listRef.current;
|
|
228
|
+
if (!container) return;
|
|
229
|
+
// Find the actual item element by data attribute
|
|
230
|
+
const selected = container.querySelector(
|
|
231
|
+
`[data-mention-index="${selectedIndex}"]`,
|
|
232
|
+
) as HTMLElement | undefined;
|
|
233
|
+
if (selected) {
|
|
234
|
+
selected.scrollIntoView({ block: "nearest" });
|
|
235
|
+
}
|
|
236
|
+
}, [selectedIndex]);
|
|
237
|
+
|
|
238
|
+
useImperativeHandle(ref, () => ({
|
|
239
|
+
moveUp: () => {
|
|
240
|
+
setSelectedIndex((prev) =>
|
|
241
|
+
prev <= 0 ? Math.max(0, itemCount - 1) : prev - 1,
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
moveDown: () => {
|
|
245
|
+
setSelectedIndex((prev) => (prev >= itemCount - 1 ? 0 : prev + 1));
|
|
246
|
+
},
|
|
247
|
+
getSelectedIndex: () => selectedIndex,
|
|
248
|
+
getSelectedMention: () => flatMentionItems[selectedIndex] ?? null,
|
|
249
|
+
getSelectedCommand: () => {
|
|
250
|
+
if (type !== "/" || selectedIndex >= commands.length) return null;
|
|
251
|
+
return commands[selectedIndex] ?? null;
|
|
252
|
+
},
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
if (!position) return null;
|
|
256
|
+
|
|
257
|
+
const content = (
|
|
258
|
+
<>
|
|
259
|
+
{/* Backdrop to capture outside clicks */}
|
|
260
|
+
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
|
261
|
+
<div
|
|
262
|
+
data-agent-native-composer-popover="true"
|
|
263
|
+
className="fixed z-[9999] w-[320px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
|
|
264
|
+
style={{
|
|
265
|
+
bottom: `calc(100vh - ${position.top}px + 4px)`,
|
|
266
|
+
left: Math.max(8, Math.min(position.left, window.innerWidth - 336)),
|
|
267
|
+
maxHeight: Math.min(320, position.top - 8),
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
{isLoading && itemCount === 0 ? (
|
|
271
|
+
<LoadingSkeleton />
|
|
272
|
+
) : itemCount === 0 ? (
|
|
273
|
+
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
|
274
|
+
{type === "@" ? (
|
|
275
|
+
query ? (
|
|
276
|
+
"No results found"
|
|
277
|
+
) : (
|
|
278
|
+
"Type to search..."
|
|
279
|
+
)
|
|
280
|
+
) : hint ? (
|
|
281
|
+
<HintWithLink hint={hint} />
|
|
282
|
+
) : (
|
|
283
|
+
"No skills available"
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
) : (
|
|
287
|
+
<div ref={listRef} className="p-1">
|
|
288
|
+
{isLoading && <LoadingSkeletonRow />}
|
|
289
|
+
{type === "@"
|
|
290
|
+
? (() => {
|
|
291
|
+
let flatIndex = 0;
|
|
292
|
+
return groupedMentions.map((group) => (
|
|
293
|
+
<div key={group.section}>
|
|
294
|
+
<div className="px-2 pt-2 pb-1 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
|
|
295
|
+
{group.section}
|
|
296
|
+
</div>
|
|
297
|
+
{group.items.map((item) => {
|
|
298
|
+
const idx = flatIndex++;
|
|
299
|
+
return (
|
|
300
|
+
<button
|
|
301
|
+
key={item.id}
|
|
302
|
+
data-mention-index={idx}
|
|
303
|
+
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm ${
|
|
304
|
+
idx === selectedIndex
|
|
305
|
+
? "bg-accent text-accent-foreground"
|
|
306
|
+
: "hover:bg-accent/50"
|
|
307
|
+
}`}
|
|
308
|
+
onMouseEnter={() => setSelectedIndex(idx)}
|
|
309
|
+
onClick={() => onSelectMention(item)}
|
|
310
|
+
>
|
|
311
|
+
<MentionItemIcon icon={item.icon} />
|
|
312
|
+
<span className="truncate text-sm">
|
|
313
|
+
{item.label}
|
|
314
|
+
</span>
|
|
315
|
+
{item.description && (
|
|
316
|
+
<span className="ms-auto shrink-0 truncate max-w-[160px] text-xs text-muted-foreground">
|
|
317
|
+
{item.description}
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
</button>
|
|
321
|
+
);
|
|
322
|
+
})}
|
|
323
|
+
</div>
|
|
324
|
+
));
|
|
325
|
+
})()
|
|
326
|
+
: (() => {
|
|
327
|
+
let idx = 0;
|
|
328
|
+
return (
|
|
329
|
+
<>
|
|
330
|
+
{commands.length > 0 && (
|
|
331
|
+
<div>
|
|
332
|
+
<div className="px-2 pt-2 pb-1 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
|
|
333
|
+
Commands
|
|
334
|
+
</div>
|
|
335
|
+
{commands.map((cmd) => {
|
|
336
|
+
const i = idx++;
|
|
337
|
+
return (
|
|
338
|
+
<button
|
|
339
|
+
key={cmd.name}
|
|
340
|
+
data-mention-index={i}
|
|
341
|
+
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm ${
|
|
342
|
+
i === selectedIndex
|
|
343
|
+
? "bg-accent text-accent-foreground"
|
|
344
|
+
: "hover:bg-accent/50"
|
|
345
|
+
}`}
|
|
346
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
347
|
+
onClick={() => onSelectCommand?.(cmd)}
|
|
348
|
+
>
|
|
349
|
+
<CommandIcon icon={cmd.icon} />
|
|
350
|
+
<span className="min-w-0 flex-1">
|
|
351
|
+
<span className="block truncate text-sm">
|
|
352
|
+
/{cmd.name}
|
|
353
|
+
</span>
|
|
354
|
+
{cmd.description && (
|
|
355
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
356
|
+
{cmd.description}
|
|
357
|
+
</span>
|
|
358
|
+
)}
|
|
359
|
+
</span>
|
|
360
|
+
</button>
|
|
361
|
+
);
|
|
362
|
+
})}
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
{skills.length > 0 && (
|
|
366
|
+
<div>
|
|
367
|
+
{commands.length > 0 && (
|
|
368
|
+
<div className="px-2 pt-2 pb-1 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
|
|
369
|
+
Skills
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
{(skills as SkillResult[]).map((skill) => {
|
|
373
|
+
const i = idx++;
|
|
374
|
+
return (
|
|
375
|
+
<button
|
|
376
|
+
key={skill.path}
|
|
377
|
+
data-mention-index={i}
|
|
378
|
+
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm ${
|
|
379
|
+
i === selectedIndex
|
|
380
|
+
? "bg-accent text-accent-foreground"
|
|
381
|
+
: "hover:bg-accent/50"
|
|
382
|
+
}`}
|
|
383
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
384
|
+
onClick={() => onSelectSkill(skill)}
|
|
385
|
+
>
|
|
386
|
+
<IconStack2 {...iconProps} />
|
|
387
|
+
<span className="min-w-0 flex-1">
|
|
388
|
+
<span className="block truncate text-sm">
|
|
389
|
+
{skill.name}
|
|
390
|
+
</span>
|
|
391
|
+
{skill.description && (
|
|
392
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
393
|
+
{skill.description}
|
|
394
|
+
</span>
|
|
395
|
+
)}
|
|
396
|
+
</span>
|
|
397
|
+
</button>
|
|
398
|
+
);
|
|
399
|
+
})}
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
</>
|
|
403
|
+
);
|
|
404
|
+
})()}
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
</>
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return createPortal(content, document.body);
|
|
412
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Attachment } from "@assistant-ui/react";
|
|
2
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
|
+
import { IconClipboardText, IconX } from "@tabler/icons-react";
|
|
4
|
+
import { useCallback, useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../utils.js";
|
|
7
|
+
import { countLines, unwrapAttachmentEnvelope } from "./pasted-text.js";
|
|
8
|
+
|
|
9
|
+
function readAttachmentText(attachment: Attachment): Promise<string> | string {
|
|
10
|
+
if ("file" in attachment && attachment.file instanceof File) {
|
|
11
|
+
return attachment.file.text();
|
|
12
|
+
}
|
|
13
|
+
const textPart = attachment.content?.find(
|
|
14
|
+
(p): p is { type: "text"; text: string } =>
|
|
15
|
+
p.type === "text" && "text" in p && typeof p.text === "string",
|
|
16
|
+
);
|
|
17
|
+
return textPart ? unwrapAttachmentEnvelope(textPart.text) : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function usePastedAttachmentText(attachment: Attachment): {
|
|
21
|
+
text: string;
|
|
22
|
+
lines: number;
|
|
23
|
+
chars: number;
|
|
24
|
+
} {
|
|
25
|
+
const [text, setText] = useState("");
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
const result = readAttachmentText(attachment);
|
|
30
|
+
if (typeof result === "string") {
|
|
31
|
+
setText(result);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
result
|
|
35
|
+
.then((value) => {
|
|
36
|
+
if (!cancelled) setText(value);
|
|
37
|
+
})
|
|
38
|
+
.catch(() => {});
|
|
39
|
+
return () => {
|
|
40
|
+
cancelled = true;
|
|
41
|
+
};
|
|
42
|
+
}, [attachment]);
|
|
43
|
+
|
|
44
|
+
return { text, lines: countLines(text), chars: text.length };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PastedTextChipProps {
|
|
48
|
+
attachment: Attachment;
|
|
49
|
+
onRemove?: (id: string) => void;
|
|
50
|
+
/** Compact variant rendered inside sent user messages. */
|
|
51
|
+
compact?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function PastedTextChip({
|
|
55
|
+
attachment,
|
|
56
|
+
onRemove,
|
|
57
|
+
compact = false,
|
|
58
|
+
}: PastedTextChipProps) {
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
const { text, lines, chars } = usePastedAttachmentText(attachment);
|
|
61
|
+
|
|
62
|
+
const handleRemove = useCallback(
|
|
63
|
+
(event: React.MouseEvent) => {
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
onRemove?.(attachment.id);
|
|
66
|
+
},
|
|
67
|
+
[attachment.id, onRemove],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const summary = lines > 0 ? `${lines} lines` : `${chars} chars`;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
74
|
+
<PopoverPrimitive.Trigger asChild>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className={cn(
|
|
78
|
+
"group relative inline-flex items-center gap-2 rounded-md border border-border/70 bg-muted/50 text-start text-foreground hover:bg-muted/70 cursor-pointer",
|
|
79
|
+
compact
|
|
80
|
+
? "max-w-[220px] px-2 py-1.5 text-xs"
|
|
81
|
+
: "max-w-[260px] px-2.5 py-2 text-xs",
|
|
82
|
+
onRemove && !compact && "pe-7",
|
|
83
|
+
)}
|
|
84
|
+
aria-label="Preview pasted text"
|
|
85
|
+
>
|
|
86
|
+
<span
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex shrink-0 items-center justify-center rounded bg-background text-muted-foreground",
|
|
89
|
+
compact ? "h-6 w-6" : "h-8 w-8",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<IconClipboardText
|
|
93
|
+
className={compact ? "h-3.5 w-3.5" : "h-4 w-4"}
|
|
94
|
+
/>
|
|
95
|
+
</span>
|
|
96
|
+
<span className="min-w-0 flex-1">
|
|
97
|
+
<span className="block truncate font-medium">Pasted text</span>
|
|
98
|
+
<span className="block text-[11px] text-muted-foreground">
|
|
99
|
+
{summary}
|
|
100
|
+
</span>
|
|
101
|
+
</span>
|
|
102
|
+
{onRemove && (
|
|
103
|
+
<span
|
|
104
|
+
role="button"
|
|
105
|
+
tabIndex={-1}
|
|
106
|
+
onClick={handleRemove}
|
|
107
|
+
aria-label="Remove pasted text"
|
|
108
|
+
className={cn(
|
|
109
|
+
"flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground",
|
|
110
|
+
compact ? "" : "absolute end-1.5 top-1.5",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<IconX className="h-3 w-3" />
|
|
114
|
+
</span>
|
|
115
|
+
)}
|
|
116
|
+
</button>
|
|
117
|
+
</PopoverPrimitive.Trigger>
|
|
118
|
+
<PopoverPrimitive.Portal>
|
|
119
|
+
<PopoverPrimitive.Content
|
|
120
|
+
side="top"
|
|
121
|
+
align="start"
|
|
122
|
+
sideOffset={6}
|
|
123
|
+
collisionPadding={12}
|
|
124
|
+
className="z-50 w-[min(560px,calc(100vw-32px))] rounded-lg border border-border bg-popover text-popover-foreground shadow-lg origin-[var(--radix-popover-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95"
|
|
125
|
+
>
|
|
126
|
+
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
|
127
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
128
|
+
<IconClipboardText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
129
|
+
<span className="text-sm font-medium truncate">Pasted text</span>
|
|
130
|
+
<span className="text-[11px] text-muted-foreground shrink-0">
|
|
131
|
+
{lines} lines · {chars} chars
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={() => setOpen(false)}
|
|
137
|
+
aria-label="Close preview"
|
|
138
|
+
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
|
139
|
+
>
|
|
140
|
+
<IconX className="h-3.5 w-3.5" />
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
<pre className="max-h-[60vh] overflow-auto px-3 py-2 text-[12px] leading-[1.5] whitespace-pre-wrap break-words font-mono text-foreground">
|
|
144
|
+
{text}
|
|
145
|
+
</pre>
|
|
146
|
+
</PopoverPrimitive.Content>
|
|
147
|
+
</PopoverPrimitive.Portal>
|
|
148
|
+
</PopoverPrimitive.Root>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { buildPromptComposerSubmission } from "./PromptComposer.js";
|
|
6
|
+
|
|
7
|
+
describe("buildPromptComposerSubmission", () => {
|
|
8
|
+
it("passes images through files only — never inlines base64 into prompt text", async () => {
|
|
9
|
+
// Images are passed to `files` for the host to process through the
|
|
10
|
+
// attachment pipeline. They must NOT be inlined as base64 in `text`
|
|
11
|
+
// (≈700K tokens per MB of image data).
|
|
12
|
+
const file = new File(["fake image"], "sketch.png", {
|
|
13
|
+
type: "image/png",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const result = await buildPromptComposerSubmission({
|
|
17
|
+
text: "",
|
|
18
|
+
attachments: [
|
|
19
|
+
{
|
|
20
|
+
id: "sketch.png",
|
|
21
|
+
name: "sketch.png",
|
|
22
|
+
type: "image",
|
|
23
|
+
file,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.files).toEqual([file]);
|
|
29
|
+
// text must not contain any base64 data or uploaded-image markup
|
|
30
|
+
expect(result.text).not.toContain("data:image");
|
|
31
|
+
expect(result.text).not.toContain("<uploaded-image");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("escapes inline attachment metadata in standalone submissions", async () => {
|
|
35
|
+
const file = new File(["hello"], 'bad"name&.md', {
|
|
36
|
+
type: "text/markdown",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = await buildPromptComposerSubmission({
|
|
40
|
+
text: "Review this",
|
|
41
|
+
attachments: [
|
|
42
|
+
{
|
|
43
|
+
id: "bad",
|
|
44
|
+
name: file.name,
|
|
45
|
+
type: "document",
|
|
46
|
+
file,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(result.text).toContain('name="bad"name&.md"');
|
|
52
|
+
expect(result.text).not.toContain('name="bad"name&.md"');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not include image data in prompt text regardless of file size", async () => {
|
|
56
|
+
// Both small and large images stay in `files` only.
|
|
57
|
+
const smallFile = new File(["small image"], "small.png", {
|
|
58
|
+
type: "image/png",
|
|
59
|
+
});
|
|
60
|
+
const largeFile = new File([new Uint8Array(3 * 1024 * 1024)], "large.png", {
|
|
61
|
+
type: "image/png",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
for (const file of [smallFile, largeFile]) {
|
|
65
|
+
const result = await buildPromptComposerSubmission({
|
|
66
|
+
text: "",
|
|
67
|
+
attachments: [{ id: file.name, name: file.name, type: "image", file }],
|
|
68
|
+
});
|
|
69
|
+
expect(result.files).toEqual([file]);
|
|
70
|
+
expect(result.text).not.toContain("data:image");
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|