@tangle-network/sandbox-ui 0.85.1 → 0.86.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/dist/chat.d.ts CHANGED
@@ -307,6 +307,31 @@ declare function validateComposerFiles(files: File[] | FileList, config: Compose
307
307
  rejected: ComposerFileRejection[];
308
308
  };
309
309
 
310
+ /** A selectable item in the `@`-mention popover. Generic — file semantics live
311
+ * in the consuming app, not here. */
312
+ interface MentionItem {
313
+ id: string;
314
+ label: string;
315
+ detail?: string;
316
+ kind?: string;
317
+ }
318
+ /**
319
+ * Opt-in `@`-mention support. Present ⇒ the composer swaps its textarea for a
320
+ * TipTap rich input that renders mentions as atomic pills; absent ⇒ exactly
321
+ * today's plain-textarea behavior. Data-agnostic: the app supplies items.
322
+ */
323
+ interface AgentComposerMention {
324
+ /** The character that opens the popover. Default "@". */
325
+ trigger?: string;
326
+ /** Async provider called with the query typed after the trigger. */
327
+ fetchItems: (query: string) => Promise<MentionItem[]>;
328
+ /** Fired with the mentions currently in the document whenever they change. */
329
+ onMentionsChange?: (mentions: MentionItem[]) => void;
330
+ /** Custom row renderer for a popover item. */
331
+ renderItem?: (item: MentionItem) => React.ReactNode;
332
+ /** Shown when a fetch resolves to zero items. Default "No matches". */
333
+ emptyText?: string;
334
+ }
310
335
  /** A staged attachment shown as a chip above the input. */
311
336
  interface ComposerFile {
312
337
  id: string;
@@ -406,6 +431,12 @@ interface AgentComposerProps {
406
431
  * message body becomes. Default false.
407
432
  */
408
433
  canSubmitAttachmentsOnly?: boolean;
434
+ /**
435
+ * Opt-in `@`-mentions. When set, the textarea is replaced by a rich input
436
+ * that renders mentions as atomic pills and serializes them to `@<id>` in
437
+ * `value`. Omit for exactly today's plain-textarea behavior.
438
+ */
439
+ mention?: AgentComposerMention;
409
440
  /** Cmd/Ctrl+L focuses the input and shows a hint. Default false. */
410
441
  focusShortcut?: boolean;
411
442
  /** Minimum textarea rows before it grows. Default 2. */
@@ -432,7 +463,7 @@ interface AgentComposerProps {
432
463
  * button (`busy` + `onCancel`), a `controls` override slot for bespoke
433
464
  * picker rows, and a Cmd/Ctrl+L focus shortcut.
434
465
  */
435
- declare function AgentComposer({ value, onChange, onSubmit, placeholder, disabled, busy, onCancel, harness, profile, model, reasoning, context, controls, trailing, onAttach, onAttachFolder, attachments, onRemoveFile, onRetryFile, accept, onRejectFiles, dropTitle, dropDescription, canSubmitWhileBusy, canSubmitAttachmentsOnly, focusShortcut, minRows, maxHeight, className, sendLabel, autoFocus, }: AgentComposerProps): react_jsx_runtime.JSX.Element;
466
+ declare function AgentComposer({ value, onChange, onSubmit, placeholder, disabled, busy, onCancel, harness, profile, model, reasoning, context, controls, trailing, onAttach, onAttachFolder, attachments, onRemoveFile, onRetryFile, accept, onRejectFiles, dropTitle, dropDescription, canSubmitWhileBusy, canSubmitAttachmentsOnly, mention, focusShortcut, minRows, maxHeight, className, sendLabel, autoFocus, }: AgentComposerProps): react_jsx_runtime.JSX.Element;
436
467
 
437
468
  /**
438
469
  * Whether `harness` can run `modelId`. Router-backed harnesses, and provider-less
@@ -546,4 +577,4 @@ declare function createFetchTransport(opts: {
546
577
  fetchImpl?: typeof fetch;
547
578
  }): ArtifactAgentDockTransport;
548
579
 
549
- export { AgentComposer, type AgentComposerProps, type AgentProfileCapability, type AgentProfileDraft, type AgentProfileOption, AgentProfilePicker, type AgentProfilePickerProps, AgentSessionControls, type AgentSessionControlsProps, type AgentSessionHarnessControl, type AgentSessionModelControl, type AgentSessionProfileControl, type AgentSessionReasoningControl, ArtifactAgentDock, type ArtifactAgentDockProps, type ArtifactAgentDockTransport, type ArtifactDockMessage, type ArtifactDockStreamEvent, type ArtifactKind, type ArtifactScope, type ComposerFile, type ComposerFileRejection, type ComposerFileValidationConfig, DEFAULT_REASONING_LEVEL_OPTIONS, HARNESS_REASONING_OPTIONS, type ReasoningLevel, type ReasoningLevelOption, ReasoningLevelPicker, type ReasoningLevelPickerProps, createFetchTransport, isAcceptedType, isModelCompatibleWithHarness, snapModelToHarness, validateComposerFiles };
580
+ export { AgentComposer, type AgentComposerMention, type AgentComposerProps, type AgentProfileCapability, type AgentProfileDraft, type AgentProfileOption, AgentProfilePicker, type AgentProfilePickerProps, AgentSessionControls, type AgentSessionControlsProps, type AgentSessionHarnessControl, type AgentSessionModelControl, type AgentSessionProfileControl, type AgentSessionReasoningControl, ArtifactAgentDock, type ArtifactAgentDockProps, type ArtifactAgentDockTransport, type ArtifactDockMessage, type ArtifactDockStreamEvent, type ArtifactKind, type ArtifactScope, type ComposerFile, type ComposerFileRejection, type ComposerFileValidationConfig, DEFAULT_REASONING_LEVEL_OPTIONS, HARNESS_REASONING_OPTIONS, type MentionItem, type ReasoningLevel, type ReasoningLevelOption, ReasoningLevelPicker, type ReasoningLevelPickerProps, createFetchTransport, isAcceptedType, isModelCompatibleWithHarness, snapModelToHarness, validateComposerFiles };
package/dist/chat.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  snapHarnessToModel,
20
20
  snapModelToHarness,
21
21
  validateComposerFiles
22
- } from "./chunk-666CEEHW.js";
22
+ } from "./chunk-PKZVCCZ6.js";
23
23
  import "./chunk-TK46XFLM.js";
24
24
  import "./chunk-2T6KBMLN.js";
25
25
  import "./chunk-R4AR3TKR.js";
@@ -1147,6 +1147,7 @@ function imageExtension(file) {
1147
1147
  const fromName = /\.([a-z0-9]+)$/i.exec(file.name)?.[1];
1148
1148
  return fromName?.toLowerCase() ?? "png";
1149
1149
  }
1150
+ var MentionEditor = React4.lazy(() => import("./mention-editor-7FLUUWY5.js"));
1150
1151
  function AgentComposer({
1151
1152
  value,
1152
1153
  onChange,
@@ -1173,6 +1174,7 @@ function AgentComposer({
1173
1174
  dropDescription = "They attach to your next message.",
1174
1175
  canSubmitWhileBusy = false,
1175
1176
  canSubmitAttachmentsOnly = false,
1177
+ mention,
1176
1178
  focusShortcut = false,
1177
1179
  minRows = 2,
1178
1180
  maxHeight = 200,
@@ -1181,6 +1183,7 @@ function AgentComposer({
1181
1183
  autoFocus
1182
1184
  }) {
1183
1185
  const textareaRef = React4.useRef(null);
1186
+ const richFocusRef = React4.useRef(null);
1184
1187
  const fileInputRef = React4.useRef(null);
1185
1188
  const folderInputRef = React4.useRef(null);
1186
1189
  const [dragOver, setDragOver] = React4.useState(false);
@@ -1201,17 +1204,21 @@ function AgentComposer({
1201
1204
  function onKeyDown(event) {
1202
1205
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") {
1203
1206
  event.preventDefault();
1204
- textareaRef.current?.focus();
1207
+ if (mention) richFocusRef.current?.();
1208
+ else textareaRef.current?.focus();
1205
1209
  }
1206
1210
  }
1207
1211
  document.addEventListener("keydown", onKeyDown);
1208
1212
  return () => document.removeEventListener("keydown", onKeyDown);
1209
- }, [focusShortcut, disabled]);
1213
+ }, [focusShortcut, disabled, mention]);
1210
1214
  const canSend = !disabled && (!busy || canSubmitWhileBusy) && (value.trim().length > 0 || canSubmitAttachmentsOnly && attachments.length > 0);
1211
1215
  const submit = () => {
1212
1216
  if (!canSend) return;
1213
1217
  onSubmit();
1214
1218
  };
1219
+ const registerRichFocus = React4.useCallback((focus) => {
1220
+ richFocusRef.current = focus;
1221
+ }, []);
1215
1222
  const dropEnabled = Boolean(onAttach);
1216
1223
  const handleDragEnter = (event) => {
1217
1224
  if (!dropEnabled) return;
@@ -1254,23 +1261,28 @@ function AgentComposer({
1254
1261
  const files = event.dataTransfer?.files;
1255
1262
  if (files?.length) deliverFiles(Array.from(files));
1256
1263
  };
1264
+ const renamePastedFiles = (files) => files.map((file) => {
1265
+ if (file.type.startsWith("image/") && isGenericImageName(file.name)) {
1266
+ pastedImageCount.current += 1;
1267
+ return new File(
1268
+ [file],
1269
+ `pasted-image-${pastedImageCount.current}.${imageExtension(file)}`,
1270
+ { type: file.type }
1271
+ );
1272
+ }
1273
+ return file;
1274
+ });
1257
1275
  const handlePaste = (event) => {
1258
1276
  if (!onAttach) return;
1259
1277
  const clipboardFiles = event.clipboardData?.files;
1260
1278
  if (!clipboardFiles || clipboardFiles.length === 0) return;
1261
1279
  event.preventDefault();
1262
- const files = Array.from(clipboardFiles).map((file) => {
1263
- if (file.type.startsWith("image/") && isGenericImageName(file.name)) {
1264
- pastedImageCount.current += 1;
1265
- return new File(
1266
- [file],
1267
- `pasted-image-${pastedImageCount.current}.${imageExtension(file)}`,
1268
- { type: file.type }
1269
- );
1270
- }
1271
- return file;
1272
- });
1273
- deliverFiles(files);
1280
+ deliverFiles(renamePastedFiles(Array.from(clipboardFiles)));
1281
+ };
1282
+ const handleEditorPasteFiles = (clipboardFiles) => {
1283
+ if (!onAttach || clipboardFiles.length === 0) return false;
1284
+ deliverFiles(renamePastedFiles(Array.from(clipboardFiles)));
1285
+ return true;
1274
1286
  };
1275
1287
  const folderChips = attachments.filter((file) => file.kind === "folder");
1276
1288
  const fileChips = attachments.filter((file) => file.kind !== "folder");
@@ -1353,7 +1365,41 @@ function AgentComposer({
1353
1365
  file.id
1354
1366
  );
1355
1367
  }) }),
1356
- /* @__PURE__ */ jsx4(
1368
+ mention ? /* @__PURE__ */ jsx4(
1369
+ React4.Suspense,
1370
+ {
1371
+ fallback: /* @__PURE__ */ jsx4(
1372
+ "textarea",
1373
+ {
1374
+ rows: minRows,
1375
+ value,
1376
+ disabled: true,
1377
+ placeholder,
1378
+ "aria-label": "Message input",
1379
+ className: cn(
1380
+ "w-full resize-none bg-transparent px-2 py-1 text-sm leading-relaxed text-foreground outline-none",
1381
+ "placeholder:text-muted-foreground"
1382
+ )
1383
+ }
1384
+ ),
1385
+ children: /* @__PURE__ */ jsx4(
1386
+ MentionEditor,
1387
+ {
1388
+ value,
1389
+ onChange,
1390
+ onSubmit: submit,
1391
+ placeholder,
1392
+ disabled,
1393
+ autoFocus,
1394
+ minRows,
1395
+ maxHeight,
1396
+ mention,
1397
+ registerFocus: registerRichFocus,
1398
+ onPasteFiles: onAttach ? handleEditorPasteFiles : void 0
1399
+ }
1400
+ )
1401
+ }
1402
+ ) : /* @__PURE__ */ jsx4(
1357
1403
  "textarea",
1358
1404
  {
1359
1405
  ref: textareaRef,
package/dist/globals.css CHANGED
@@ -1292,6 +1292,9 @@
1292
1292
  .min-w-\[15rem\] {
1293
1293
  min-width: 15rem;
1294
1294
  }
1295
+ .min-w-\[16rem\] {
1296
+ min-width: 16rem;
1297
+ }
1295
1298
  .min-w-\[20px\] {
1296
1299
  min-width: 20px;
1297
1300
  }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { AgentWorkbench, ApprovalConfidenceStat, ApprovalItem, ApprovalQueue, Ap
5
5
  export { ArtifactView, BRAND_PRISM_THEME, CodeSurface, CodeSurfaceProps, CodeView, CodeViewProps, DiffStats, DiffView, DiffViewProps, FileBreadcrumb, FileBreadcrumbProps, GitStatusBadge, PanelHeader, PanelHeaderProps, PillTabItem, PillTabs, PillTabsProps, PreviewView, PreviewViewProps, SandboxArtifact, SandboxArtifactPane, SandboxArtifactPaneProps, SandboxArtifactTerminal, buildUnifiedPatch, computeDiffStats, resolveLanguage } from './workbench.js';
6
6
  export { OpenUIAction, OpenUIActionsNode, OpenUIArtifactRenderer, OpenUIArtifactRendererProps, OpenUIBadgeNode, OpenUICardNode, OpenUICodeNode, OpenUIComponentNode, OpenUIGridNode, OpenUIHeadingNode, OpenUIKeyValueNode, OpenUIMarkdownNode, OpenUIPrimitive, OpenUISeparatorNode, OpenUIStackNode, OpenUIStatNode, OpenUITableNode, OpenUITextNode } from '@tangle-network/ui/openui';
7
7
  export { AgentTimeline, AgentTimelineArtifactItem, AgentTimelineCustomItem, AgentTimelineItem, AgentTimelineMessageItem, AgentTimelineProps, AgentTimelineStatusItem, AgentTimelineTone, AgentTimelineToolGroupItem, AgentTimelineToolItem, ChatContainer, ChatContainerProps, ChatMessage, ChatMessageProps, MessageList, MessageListProps, MessageRole, ThinkingIndicator, ThinkingIndicatorProps, UserMessage, UserMessageProps } from '@tangle-network/ui/chat';
8
- export { AgentComposer, AgentComposerProps, AgentProfileCapability, AgentProfileDraft, AgentProfileOption, AgentProfilePicker, AgentProfilePickerProps, AgentSessionControls, AgentSessionControlsProps, AgentSessionHarnessControl, AgentSessionModelControl, AgentSessionProfileControl, AgentSessionReasoningControl, ArtifactAgentDock, ArtifactAgentDockProps, ArtifactAgentDockTransport, ArtifactDockMessage, ArtifactDockStreamEvent, ArtifactKind, ArtifactScope, ComposerFile, ComposerFileRejection, ComposerFileValidationConfig, DEFAULT_REASONING_LEVEL_OPTIONS, HARNESS_REASONING_OPTIONS, ReasoningLevel, ReasoningLevelOption, ReasoningLevelPicker, ReasoningLevelPickerProps, createFetchTransport, isAcceptedType, isModelCompatibleWithHarness, snapModelToHarness, validateComposerFiles } from './chat.js';
8
+ export { AgentComposer, AgentComposerMention, AgentComposerProps, AgentProfileCapability, AgentProfileDraft, AgentProfileOption, AgentProfilePicker, AgentProfilePickerProps, AgentSessionControls, AgentSessionControlsProps, AgentSessionHarnessControl, AgentSessionModelControl, AgentSessionProfileControl, AgentSessionReasoningControl, ArtifactAgentDock, ArtifactAgentDockProps, ArtifactAgentDockTransport, ArtifactDockMessage, ArtifactDockStreamEvent, ArtifactKind, ArtifactScope, ComposerFile, ComposerFileRejection, ComposerFileValidationConfig, DEFAULT_REASONING_LEVEL_OPTIONS, HARNESS_REASONING_OPTIONS, MentionItem, ReasoningLevel, ReasoningLevelOption, ReasoningLevelPicker, ReasoningLevelPickerProps, createFetchTransport, isAcceptedType, isModelCompatibleWithHarness, snapModelToHarness, validateComposerFiles } from './chat.js';
9
9
  export { ExpandedToolDetail, ExpandedToolDetailProps, InlineThinkingItem, InlineThinkingItemProps, InlineToolItem, InlineToolItemProps, LiveDuration, RunGroup, RunGroupProps } from '@tangle-network/ui/run';
10
10
  export { CommandPreview, DiffPreview, GlobResultsPreview, GrepResultsPreview, QuestionPreview, WebSearchPreview, WriteFilePreview } from '@tangle-network/ui/tool-previews';
11
11
  export { FileArtifactPane, FileArtifactPaneProps, FileNode, FilePreview, FilePreviewProps, FileTabData, FileTabs, FileTabsProps, FileTree, FileTreeProps, FileTreeVisibilityOptions, RichFileTree, RichFileTreeGitEntry, RichFileTreeGitStatus, RichFileTreeProps, RichFileTreeThemeVars, filterFileTree } from '@tangle-network/ui/files';
package/dist/index.js CHANGED
@@ -91,7 +91,7 @@ import {
91
91
  snapHarnessToModel,
92
92
  snapModelToHarness,
93
93
  validateComposerFiles
94
- } from "./chunk-666CEEHW.js";
94
+ } from "./chunk-PKZVCCZ6.js";
95
95
  import {
96
96
  TOOL_CATEGORY_ICONS,
97
97
  cn,
@@ -0,0 +1,480 @@
1
+ import {
2
+ cn
3
+ } from "./chunk-EI44GEQ5.js";
4
+ import "./chunk-U67V476Y.js";
5
+
6
+ // src/chat/mention-editor.tsx
7
+ import { mergeAttributes } from "@tiptap/core";
8
+ import Mention from "@tiptap/extension-mention";
9
+ import { EditorContent, useEditor } from "@tiptap/react";
10
+ import StarterKit from "@tiptap/starter-kit";
11
+ import { exitSuggestion } from "@tiptap/suggestion";
12
+ import * as React2 from "react";
13
+
14
+ // src/chat/mention-list.tsx
15
+ import { Loader2 } from "lucide-react";
16
+ import * as React from "react";
17
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
18
+ var MentionList = React.forwardRef(
19
+ function MentionList2({ items, loading, error, emptyText = "No matches", renderItem, onSelect }, ref) {
20
+ const [selected, setSelected] = React.useState(0);
21
+ const selectedRef = React.useRef(0);
22
+ const move = (next) => {
23
+ selectedRef.current = next;
24
+ setSelected(next);
25
+ };
26
+ React.useEffect(() => {
27
+ move(0);
28
+ }, [items]);
29
+ React.useImperativeHandle(
30
+ ref,
31
+ () => ({
32
+ onKeyDown(event) {
33
+ const count = items.length;
34
+ switch (event.key) {
35
+ case "ArrowDown":
36
+ if (count > 0) move((selectedRef.current + 1) % count);
37
+ return true;
38
+ case "ArrowUp":
39
+ if (count > 0) move((selectedRef.current - 1 + count) % count);
40
+ return true;
41
+ case "Enter":
42
+ case "Tab":
43
+ if (count > 0) onSelect(items[selectedRef.current]);
44
+ return true;
45
+ default:
46
+ return false;
47
+ }
48
+ }
49
+ }),
50
+ [items, onSelect]
51
+ );
52
+ return /* @__PURE__ */ jsxs(
53
+ "div",
54
+ {
55
+ role: "listbox",
56
+ "aria-label": "File mentions",
57
+ className: cn(
58
+ "max-h-64 min-w-[16rem] max-w-sm overflow-y-auto rounded-xl border border-[var(--md3-outline-variant)]",
59
+ "bg-surface-container-high p-1 shadow-lg"
60
+ ),
61
+ children: [
62
+ loading && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-2.5 py-2 text-muted-foreground text-sm", children: [
63
+ /* @__PURE__ */ jsx(Loader2, { className: "size-3.5 animate-spin" }),
64
+ "Searching\u2026"
65
+ ] }),
66
+ !loading && error && /* @__PURE__ */ jsx("div", { className: "px-2.5 py-2 text-[var(--status-error,#ff4d6d)] text-sm", children: "Couldn\u2019t load matches" }),
67
+ !loading && !error && items.length === 0 && /* @__PURE__ */ jsx("div", { className: "px-2.5 py-2 text-muted-foreground text-sm", children: emptyText }),
68
+ !loading && !error && items.map((item, index) => {
69
+ const active = index === selected;
70
+ return /* @__PURE__ */ jsx(
71
+ "button",
72
+ {
73
+ type: "button",
74
+ role: "option",
75
+ "aria-selected": active,
76
+ onMouseDown: (event) => {
77
+ event.preventDefault();
78
+ onSelect(item);
79
+ },
80
+ onMouseEnter: () => move(index),
81
+ className: cn(
82
+ "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm",
83
+ active ? "bg-primary/10 text-foreground" : "text-muted-foreground"
84
+ ),
85
+ children: renderItem ? renderItem(item) : /* @__PURE__ */ jsxs(Fragment, { children: [
86
+ /* @__PURE__ */ jsx("span", { className: "truncate text-foreground", children: item.label }),
87
+ item.detail && /* @__PURE__ */ jsx("span", { className: "ml-auto truncate text-muted-foreground text-xs", children: item.detail })
88
+ ] })
89
+ },
90
+ item.id
91
+ );
92
+ })
93
+ ]
94
+ }
95
+ );
96
+ }
97
+ );
98
+
99
+ // src/chat/mention-serialize.ts
100
+ var MENTION_TYPE = "mention";
101
+ function serializeMentionDoc(doc) {
102
+ return (doc.content ?? []).map(serializeBlock).join("\n");
103
+ }
104
+ function serializeBlock(node) {
105
+ if (node.type === "paragraph") {
106
+ return (node.content ?? []).map(serializeInline).join("");
107
+ }
108
+ return "";
109
+ }
110
+ function serializeInline(node) {
111
+ if (node.type === "text") return node.text ?? "";
112
+ if (node.type === "hardBreak") return "\n";
113
+ if (node.type === MENTION_TYPE) return `@${node.attrs?.id ?? ""}`;
114
+ return "";
115
+ }
116
+ function collectMentions(doc) {
117
+ const out = [];
118
+ const walk = (node) => {
119
+ if (node.type === MENTION_TYPE && node.attrs?.id) {
120
+ out.push({
121
+ id: node.attrs.id,
122
+ label: node.attrs.label ?? node.attrs.id,
123
+ kind: node.attrs.kind ?? void 0
124
+ });
125
+ }
126
+ for (const child of node.content ?? []) walk(child);
127
+ };
128
+ walk(doc);
129
+ return out;
130
+ }
131
+ function parseMentionValue(value, known) {
132
+ const lines = value.split("\n");
133
+ return {
134
+ type: "doc",
135
+ content: lines.map((line) => ({
136
+ type: "paragraph",
137
+ content: parseLine(line, known)
138
+ }))
139
+ };
140
+ }
141
+ function parseLine(line, known) {
142
+ const content = [];
143
+ let text = "";
144
+ const flush = () => {
145
+ if (text) {
146
+ content.push({ type: "text", text });
147
+ text = "";
148
+ }
149
+ };
150
+ let i = 0;
151
+ while (i < line.length) {
152
+ if (line[i] === "@") {
153
+ const id = matchKnownId(line, i + 1, known);
154
+ if (id) {
155
+ const item = known.get(id);
156
+ flush();
157
+ content.push({
158
+ type: MENTION_TYPE,
159
+ attrs: {
160
+ id: item.id,
161
+ label: item.label,
162
+ kind: item.kind ?? null
163
+ }
164
+ });
165
+ i += 1 + id.length;
166
+ continue;
167
+ }
168
+ }
169
+ text += line[i];
170
+ i += 1;
171
+ }
172
+ flush();
173
+ return content;
174
+ }
175
+ function matchKnownId(line, pos, known) {
176
+ let best = null;
177
+ for (const id of known.keys()) {
178
+ if (id.length === 0) continue;
179
+ if (best !== null && id.length <= best.length) continue;
180
+ if (!line.startsWith(id, pos)) continue;
181
+ const after = pos + id.length;
182
+ if (after < line.length && !/\s/.test(line[after])) continue;
183
+ best = id;
184
+ }
185
+ return best;
186
+ }
187
+
188
+ // src/chat/mention-editor.tsx
189
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
190
+ function buildComposerStarterKit() {
191
+ return StarterKit.configure({
192
+ blockquote: false,
193
+ bold: false,
194
+ bulletList: false,
195
+ code: false,
196
+ codeBlock: false,
197
+ dropcursor: false,
198
+ heading: false,
199
+ horizontalRule: false,
200
+ italic: false,
201
+ link: false,
202
+ listItem: false,
203
+ listKeymap: false,
204
+ orderedList: false,
205
+ strike: false,
206
+ trailingNode: false,
207
+ underline: false
208
+ });
209
+ }
210
+ var MENTION_PILL_CLASS = cn(
211
+ "rounded-md bg-primary/10 px-1 py-0.5 font-medium text-primary"
212
+ );
213
+ function buildMentionExtension(trigger, suggestion) {
214
+ return Mention.extend({
215
+ addAttributes() {
216
+ return {
217
+ id: {
218
+ default: null,
219
+ parseHTML: (element) => element.getAttribute("data-id"),
220
+ renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {}
221
+ },
222
+ label: {
223
+ default: null,
224
+ parseHTML: (element) => element.getAttribute("data-label"),
225
+ renderHTML: (attrs) => attrs.label ? { "data-label": attrs.label } : {}
226
+ },
227
+ kind: {
228
+ default: null,
229
+ parseHTML: (element) => element.getAttribute("data-kind"),
230
+ renderHTML: (attrs) => attrs.kind ? { "data-kind": attrs.kind } : {}
231
+ }
232
+ };
233
+ }
234
+ }).configure({
235
+ HTMLAttributes: { class: MENTION_PILL_CLASS },
236
+ renderText: ({ node }) => `${trigger}${node.attrs.id}`,
237
+ renderHTML: ({ options, node }) => [
238
+ "span",
239
+ mergeAttributes(options.HTMLAttributes, {
240
+ title: node.attrs.id ?? void 0
241
+ }),
242
+ `${trigger}${node.attrs.label ?? node.attrs.id}`
243
+ ],
244
+ suggestion
245
+ });
246
+ }
247
+ var EDITOR_CLASS = cn(
248
+ "w-full whitespace-pre-wrap break-words bg-transparent px-2 py-1",
249
+ "text-foreground text-sm leading-relaxed outline-none"
250
+ );
251
+ var FETCH_DEBOUNCE_MS = 100;
252
+ function mentionsKey(mentions) {
253
+ return mentions.map((item) => item.id).join("\0");
254
+ }
255
+ function MentionEditor({
256
+ value,
257
+ onChange,
258
+ onSubmit,
259
+ placeholder,
260
+ disabled,
261
+ autoFocus,
262
+ minRows,
263
+ maxHeight,
264
+ mention,
265
+ registerFocus,
266
+ onPasteFiles
267
+ }) {
268
+ const trigger = mention.trigger ?? "@";
269
+ const [anchor, setAnchor] = React2.useState(null);
270
+ const [open, setOpen] = React2.useState(false);
271
+ const [query, setQuery] = React2.useState("");
272
+ const [items, setItems] = React2.useState([]);
273
+ const [loading, setLoading] = React2.useState(false);
274
+ const [errored, setErrored] = React2.useState(false);
275
+ const openRef = React2.useRef(false);
276
+ const commandRef = React2.useRef(null);
277
+ const listRef = React2.useRef(null);
278
+ const knownRef = React2.useRef(/* @__PURE__ */ new Map());
279
+ const requestIdRef = React2.useRef(0);
280
+ const clientRectRef = React2.useRef(null);
281
+ const lastMentionsKeyRef = React2.useRef("");
282
+ const onSubmitRef = React2.useRef(onSubmit);
283
+ onSubmitRef.current = onSubmit;
284
+ const onChangeRef = React2.useRef(onChange);
285
+ onChangeRef.current = onChange;
286
+ const onMentionsChangeRef = React2.useRef(mention.onMentionsChange);
287
+ onMentionsChangeRef.current = mention.onMentionsChange;
288
+ const onPasteFilesRef = React2.useRef(onPasteFiles);
289
+ onPasteFilesRef.current = onPasteFiles;
290
+ const fetchItemsRef = React2.useRef(mention.fetchItems);
291
+ fetchItemsRef.current = mention.fetchItems;
292
+ const editor = useEditor(
293
+ {
294
+ immediatelyRender: false,
295
+ editable: !disabled,
296
+ autofocus: autoFocus ? "end" : false,
297
+ content: parseMentionValue(value, knownRef.current),
298
+ editorProps: {
299
+ attributes: {
300
+ class: EDITOR_CLASS,
301
+ role: "textbox",
302
+ "aria-multiline": "true",
303
+ "aria-label": "Message input"
304
+ },
305
+ handleKeyDown: (_view, event) => {
306
+ if (event.key !== "Enter" || event.shiftKey) return false;
307
+ if (event.isComposing || event.keyCode === 229) return false;
308
+ if (openRef.current) return false;
309
+ event.preventDefault();
310
+ onSubmitRef.current();
311
+ return true;
312
+ },
313
+ handlePaste: (_view, event) => {
314
+ const files = event.clipboardData?.files;
315
+ if (files && files.length > 0 && onPasteFilesRef.current) {
316
+ return onPasteFilesRef.current(files);
317
+ }
318
+ return false;
319
+ }
320
+ },
321
+ onUpdate: ({ editor: editor2 }) => {
322
+ const json = editor2.getJSON();
323
+ onChangeRef.current(serializeMentionDoc(json));
324
+ const mentions = collectMentions(json);
325
+ for (const item of mentions) knownRef.current.set(item.id, item);
326
+ lastMentionsKeyRef.current = mentionsKey(mentions);
327
+ onMentionsChangeRef.current?.(mentions);
328
+ },
329
+ extensions: [
330
+ buildComposerStarterKit(),
331
+ buildMentionExtension(trigger, {
332
+ char: trigger,
333
+ allowSpaces: false,
334
+ // Items are fetched by this component (so it can model loading and
335
+ // error states), not by the suggestion plugin.
336
+ items: () => [],
337
+ command: ({ editor: editor2, range, props }) => {
338
+ const item = props;
339
+ editor2.chain().focus().insertContentAt(range, [
340
+ {
341
+ type: "mention",
342
+ attrs: {
343
+ id: item.id,
344
+ label: item.label,
345
+ kind: item.kind ?? null
346
+ }
347
+ },
348
+ { type: "text", text: " " }
349
+ ]).run();
350
+ knownRef.current.set(item.id, item);
351
+ },
352
+ render: () => ({
353
+ onStart: (props) => {
354
+ openRef.current = true;
355
+ commandRef.current = props.command;
356
+ clientRectRef.current = props.clientRect ?? null;
357
+ setOpen(true);
358
+ setQuery(props.query);
359
+ setAnchor(props.clientRect?.() ?? null);
360
+ },
361
+ onUpdate: (props) => {
362
+ commandRef.current = props.command;
363
+ clientRectRef.current = props.clientRect ?? null;
364
+ setQuery(props.query);
365
+ setAnchor(props.clientRect?.() ?? null);
366
+ },
367
+ onKeyDown: (props) => {
368
+ if (props.event.key === "Escape") {
369
+ exitSuggestion(props.view);
370
+ return true;
371
+ }
372
+ return listRef.current?.onKeyDown(props.event) ?? false;
373
+ },
374
+ onExit: () => {
375
+ openRef.current = false;
376
+ commandRef.current = null;
377
+ setOpen(false);
378
+ setItems([]);
379
+ setLoading(false);
380
+ setErrored(false);
381
+ }
382
+ })
383
+ })
384
+ ]
385
+ },
386
+ []
387
+ );
388
+ React2.useEffect(() => {
389
+ if (!open) return;
390
+ const requestId = requestIdRef.current += 1;
391
+ setLoading(true);
392
+ setErrored(false);
393
+ const timer = setTimeout(() => {
394
+ Promise.resolve(fetchItemsRef.current(query)).then((results) => {
395
+ if (requestId !== requestIdRef.current) return;
396
+ for (const item of results) knownRef.current.set(item.id, item);
397
+ setItems(results);
398
+ setLoading(false);
399
+ }).catch(() => {
400
+ if (requestId !== requestIdRef.current) return;
401
+ setErrored(true);
402
+ setLoading(false);
403
+ });
404
+ }, FETCH_DEBOUNCE_MS);
405
+ return () => clearTimeout(timer);
406
+ }, [open, query]);
407
+ React2.useEffect(() => {
408
+ if (!open) return;
409
+ const reposition = () => setAnchor(clientRectRef.current?.() ?? null);
410
+ window.addEventListener("scroll", reposition, true);
411
+ window.addEventListener("resize", reposition);
412
+ return () => {
413
+ window.removeEventListener("scroll", reposition, true);
414
+ window.removeEventListener("resize", reposition);
415
+ };
416
+ }, [open]);
417
+ React2.useEffect(() => {
418
+ if (!editor) return;
419
+ if (serializeMentionDoc(editor.getJSON()) === value) return;
420
+ const doc = parseMentionValue(value, knownRef.current);
421
+ editor.commands.setContent(doc, { emitUpdate: false });
422
+ const mentions = collectMentions(doc);
423
+ const key = mentionsKey(mentions);
424
+ if (key !== lastMentionsKeyRef.current) {
425
+ lastMentionsKeyRef.current = key;
426
+ onMentionsChangeRef.current?.(mentions);
427
+ }
428
+ }, [editor, value]);
429
+ React2.useEffect(() => {
430
+ editor?.setEditable(!disabled);
431
+ }, [editor, disabled]);
432
+ React2.useEffect(() => {
433
+ if (!editor || !registerFocus) return;
434
+ registerFocus(() => editor.commands.focus());
435
+ }, [editor, registerFocus]);
436
+ return /* @__PURE__ */ jsxs2("div", { className: "relative", children: [
437
+ /* @__PURE__ */ jsxs2(
438
+ "div",
439
+ {
440
+ className: "overflow-y-auto",
441
+ style: { maxHeight, minHeight: `${minRows * 1.5}rem` },
442
+ children: [
443
+ /* @__PURE__ */ jsx2(EditorContent, { editor }),
444
+ value.length === 0 && /* @__PURE__ */ jsx2("div", { className: "pointer-events-none absolute top-1 left-2 text-muted-foreground text-sm leading-relaxed", children: placeholder })
445
+ ]
446
+ }
447
+ ),
448
+ open && /* @__PURE__ */ jsx2(
449
+ "div",
450
+ {
451
+ className: "absolute z-20",
452
+ style: anchorStyle(anchor),
453
+ "data-testid": "mention-popover",
454
+ children: /* @__PURE__ */ jsx2(
455
+ MentionList,
456
+ {
457
+ ref: listRef,
458
+ items,
459
+ loading,
460
+ error: errored,
461
+ emptyText: mention.emptyText,
462
+ renderItem: mention.renderItem,
463
+ onSelect: (item) => commandRef.current?.(item)
464
+ }
465
+ )
466
+ }
467
+ )
468
+ ] });
469
+ }
470
+ function anchorStyle(rect) {
471
+ if (!rect || rect.left === 0 && rect.bottom === 0) {
472
+ return { position: "absolute", top: "100%", left: 0 };
473
+ }
474
+ return { position: "fixed", top: rect.bottom + 4, left: rect.left };
475
+ }
476
+ export {
477
+ buildComposerStarterKit,
478
+ buildMentionExtension,
479
+ MentionEditor as default
480
+ };
package/dist/styles.css CHANGED
@@ -1292,6 +1292,9 @@
1292
1292
  .min-w-\[15rem\] {
1293
1293
  min-width: 15rem;
1294
1294
  }
1295
+ .min-w-\[16rem\] {
1296
+ min-width: 16rem;
1297
+ }
1295
1298
  .min-w-\[20px\] {
1296
1299
  min-width: 20px;
1297
1300
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tangle-network/sandbox-ui",
3
- "version": "0.85.1",
4
- "description": "Unified UI component library for Tangle Sandbox \u2014 primitives, chat, dashboard, terminal, editor, and workspace components",
3
+ "version": "0.86.0",
4
+ "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tangle-network/sandbox-ui"
@@ -192,8 +192,11 @@
192
192
  "@tiptap/core": "^3.20.4",
193
193
  "@tiptap/extension-collaboration": "^3.20.4",
194
194
  "@tiptap/extension-collaboration-caret": "^3.7.0",
195
+ "@tiptap/extension-mention": "^3.22.5",
196
+ "@tiptap/pm": "^3.22.5",
195
197
  "@tiptap/react": "^3.20.4",
196
198
  "@tiptap/starter-kit": "^3.20.4",
199
+ "@tiptap/suggestion": "^3.22.5",
197
200
  "@types/react": "^19.1.2",
198
201
  "@types/react-dom": "^19.1.2",
199
202
  "@xterm/addon-fit": "^0.11.0",