@tangle-network/sandbox-ui 0.85.1 → 0.87.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,38 @@ 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
+ /**
335
+ * Extra classes merged onto the suggestion popover's root element
336
+ * (`role="listbox"`), applied last so they win over the component's own —
337
+ * the supported way to retheme the popover instead of targeting it by its
338
+ * ARIA attributes.
339
+ */
340
+ popoverClassName?: string;
341
+ }
310
342
  /** A staged attachment shown as a chip above the input. */
311
343
  interface ComposerFile {
312
344
  id: string;
@@ -406,6 +438,12 @@ interface AgentComposerProps {
406
438
  * message body becomes. Default false.
407
439
  */
408
440
  canSubmitAttachmentsOnly?: boolean;
441
+ /**
442
+ * Opt-in `@`-mentions. When set, the textarea is replaced by a rich input
443
+ * that renders mentions as atomic pills and serializes them to `@<id>` in
444
+ * `value`. Omit for exactly today's plain-textarea behavior.
445
+ */
446
+ mention?: AgentComposerMention;
409
447
  /** Cmd/Ctrl+L focuses the input and shows a hint. Default false. */
410
448
  focusShortcut?: boolean;
411
449
  /** Minimum textarea rows before it grows. Default 2. */
@@ -432,7 +470,18 @@ interface AgentComposerProps {
432
470
  * button (`busy` + `onCancel`), a `controls` override slot for bespoke
433
471
  * picker rows, and a Cmd/Ctrl+L focus shortcut.
434
472
  */
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;
473
+ 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;
474
+
475
+ /**
476
+ * Visual contract for a rendered mention pill. The TipTap editor extension in
477
+ * `mention-editor.tsx` styles its inline atom node with this, and any other
478
+ * surface that renders a resolved mention (e.g. a sent-message transcript)
479
+ * should use the same constant so the two stay one visual contract instead of
480
+ * silently drifting apart. Kept in its own module (no TipTap import) so
481
+ * importing it never pulls the lazily-loaded editor chunk into a consumer's
482
+ * bundle.
483
+ */
484
+ declare const MENTION_PILL_CLASS: string;
436
485
 
437
486
  /**
438
487
  * Whether `harness` can run `modelId`. Router-backed harnesses, and provider-less
@@ -546,4 +595,4 @@ declare function createFetchTransport(opts: {
546
595
  fetchImpl?: typeof fetch;
547
596
  }): ArtifactAgentDockTransport;
548
597
 
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 };
598
+ 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, MENTION_PILL_CLASS, type MentionItem, type ReasoningLevel, type ReasoningLevelOption, ReasoningLevelPicker, type ReasoningLevelPickerProps, createFetchTransport, isAcceptedType, isModelCompatibleWithHarness, snapModelToHarness, validateComposerFiles };
package/dist/chat.js CHANGED
@@ -19,8 +19,11 @@ import {
19
19
  snapHarnessToModel,
20
20
  snapModelToHarness,
21
21
  validateComposerFiles
22
- } from "./chunk-666CEEHW.js";
22
+ } from "./chunk-R5OVYRIY.js";
23
23
  import "./chunk-TK46XFLM.js";
24
+ import {
25
+ MENTION_PILL_CLASS
26
+ } from "./chunk-WGOOQAW3.js";
24
27
  import "./chunk-2T6KBMLN.js";
25
28
  import "./chunk-R4AR3TKR.js";
26
29
  import "./chunk-I3G23XHN.js";
@@ -36,6 +39,7 @@ export {
36
39
  ChatMessage,
37
40
  DEFAULT_REASONING_LEVEL_OPTIONS,
38
41
  HARNESS_REASONING_OPTIONS,
42
+ MENTION_PILL_CLASS,
39
43
  MessageList,
40
44
  ReasoningLevelPicker,
41
45
  ThinkingIndicator,
@@ -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-6PITHKPR.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,
@@ -0,0 +1,12 @@
1
+ import {
2
+ cn
3
+ } from "./chunk-EI44GEQ5.js";
4
+
5
+ // src/chat/mention-pill.ts
6
+ var MENTION_PILL_CLASS = cn(
7
+ "rounded-md bg-primary/10 px-1 py-0.5 font-medium text-primary"
8
+ );
9
+
10
+ export {
11
+ MENTION_PILL_CLASS
12
+ };
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
  }
@@ -2034,6 +2037,12 @@
2034
2037
  border-color: color-mix(in oklab, var(--color-primary) 30%, transparent);
2035
2038
  }
2036
2039
  }
2040
+ .border-primary\/40 {
2041
+ border-color: color-mix(in srgb, hsl(var(--primary)) 40%, transparent);
2042
+ @supports (color: color-mix(in lab, red, red)) {
2043
+ border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
2044
+ }
2045
+ }
2037
2046
  .border-primary\/50 {
2038
2047
  border-color: color-mix(in srgb, hsl(var(--primary)) 50%, transparent);
2039
2048
  @supports (color: color-mix(in lab, red, red)) {
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, MENTION_PILL_CLASS, 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-R5OVYRIY.js";
95
95
  import {
96
96
  TOOL_CATEGORY_ICONS,
97
97
  cn,
@@ -105,6 +105,9 @@ import {
105
105
  timeAgo,
106
106
  truncateText
107
107
  } from "./chunk-TK46XFLM.js";
108
+ import {
109
+ MENTION_PILL_CLASS
110
+ } from "./chunk-WGOOQAW3.js";
108
111
  import {
109
112
  ExpandedToolDetail,
110
113
  InlineThinkingItem,
@@ -455,6 +458,7 @@ export {
455
458
  LiveDuration,
456
459
  LoginLayout,
457
460
  Logo,
461
+ MENTION_PILL_CLASS,
458
462
  Markdown,
459
463
  MessageList,
460
464
  MetricAreaChart,
@@ -0,0 +1,490 @@
1
+ import {
2
+ MENTION_PILL_CLASS
3
+ } from "./chunk-WGOOQAW3.js";
4
+ import {
5
+ cn
6
+ } from "./chunk-EI44GEQ5.js";
7
+ import "./chunk-U67V476Y.js";
8
+
9
+ // src/chat/mention-editor.tsx
10
+ import { mergeAttributes } from "@tiptap/core";
11
+ import Mention from "@tiptap/extension-mention";
12
+ import { EditorContent, useEditor } from "@tiptap/react";
13
+ import StarterKit from "@tiptap/starter-kit";
14
+ import { exitSuggestion } from "@tiptap/suggestion";
15
+ import * as React2 from "react";
16
+
17
+ // src/chat/mention-list.tsx
18
+ import { Loader2 } from "lucide-react";
19
+ import * as React from "react";
20
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
21
+ var MentionList = React.forwardRef(
22
+ function MentionList2({
23
+ items,
24
+ loading,
25
+ error,
26
+ emptyText = "No matches",
27
+ renderItem,
28
+ onSelect,
29
+ className
30
+ }, ref) {
31
+ const [selected, setSelected] = React.useState(0);
32
+ const selectedRef = React.useRef(0);
33
+ const move = (next) => {
34
+ selectedRef.current = next;
35
+ setSelected(next);
36
+ };
37
+ React.useEffect(() => {
38
+ move(0);
39
+ }, [items]);
40
+ React.useImperativeHandle(
41
+ ref,
42
+ () => ({
43
+ onKeyDown(event) {
44
+ const count = items.length;
45
+ switch (event.key) {
46
+ case "ArrowDown":
47
+ if (count > 0) move((selectedRef.current + 1) % count);
48
+ return true;
49
+ case "ArrowUp":
50
+ if (count > 0) move((selectedRef.current - 1 + count) % count);
51
+ return true;
52
+ case "Enter":
53
+ case "Tab":
54
+ if (count > 0) onSelect(items[selectedRef.current]);
55
+ return true;
56
+ default:
57
+ return false;
58
+ }
59
+ }
60
+ }),
61
+ [items, onSelect]
62
+ );
63
+ return /* @__PURE__ */ jsxs(
64
+ "div",
65
+ {
66
+ role: "listbox",
67
+ "aria-label": "File mentions",
68
+ className: cn(
69
+ "max-h-64 min-w-[16rem] max-w-sm overflow-y-auto rounded-xl border border-border",
70
+ "bg-popover text-popover-foreground p-1 shadow-lg",
71
+ className
72
+ ),
73
+ children: [
74
+ loading && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-2.5 py-2 text-muted-foreground text-sm", children: [
75
+ /* @__PURE__ */ jsx(Loader2, { className: "size-3.5 animate-spin" }),
76
+ "Searching\u2026"
77
+ ] }),
78
+ !loading && error && /* @__PURE__ */ jsx("div", { className: "px-2.5 py-2 text-[var(--status-error,#ff4d6d)] text-sm", children: "Couldn\u2019t load matches" }),
79
+ !loading && !error && items.length === 0 && /* @__PURE__ */ jsx("div", { className: "px-2.5 py-2 text-muted-foreground text-sm", children: emptyText }),
80
+ !loading && !error && items.map((item, index) => {
81
+ const active = index === selected;
82
+ return /* @__PURE__ */ jsx(
83
+ "button",
84
+ {
85
+ type: "button",
86
+ role: "option",
87
+ "aria-selected": active,
88
+ onMouseDown: (event) => {
89
+ event.preventDefault();
90
+ onSelect(item);
91
+ },
92
+ onMouseEnter: () => move(index),
93
+ className: cn(
94
+ "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm",
95
+ active ? "bg-primary/10 text-foreground" : "text-muted-foreground"
96
+ ),
97
+ children: renderItem ? renderItem(item) : /* @__PURE__ */ jsxs(Fragment, { children: [
98
+ /* @__PURE__ */ jsx("span", { className: "truncate text-foreground", children: item.label }),
99
+ item.detail && /* @__PURE__ */ jsx("span", { className: "ml-auto truncate text-muted-foreground text-xs", children: item.detail })
100
+ ] })
101
+ },
102
+ item.id
103
+ );
104
+ })
105
+ ]
106
+ }
107
+ );
108
+ }
109
+ );
110
+
111
+ // src/chat/mention-serialize.ts
112
+ var MENTION_TYPE = "mention";
113
+ function serializeMentionDoc(doc) {
114
+ return (doc.content ?? []).map(serializeBlock).join("\n");
115
+ }
116
+ function serializeBlock(node) {
117
+ if (node.type === "paragraph") {
118
+ return (node.content ?? []).map(serializeInline).join("");
119
+ }
120
+ return "";
121
+ }
122
+ function serializeInline(node) {
123
+ if (node.type === "text") return node.text ?? "";
124
+ if (node.type === "hardBreak") return "\n";
125
+ if (node.type === MENTION_TYPE) return `@${node.attrs?.id ?? ""}`;
126
+ return "";
127
+ }
128
+ function collectMentions(doc) {
129
+ const out = [];
130
+ const walk = (node) => {
131
+ if (node.type === MENTION_TYPE && node.attrs?.id) {
132
+ out.push({
133
+ id: node.attrs.id,
134
+ label: node.attrs.label ?? node.attrs.id,
135
+ kind: node.attrs.kind ?? void 0
136
+ });
137
+ }
138
+ for (const child of node.content ?? []) walk(child);
139
+ };
140
+ walk(doc);
141
+ return out;
142
+ }
143
+ function parseMentionValue(value, known) {
144
+ const lines = value.split("\n");
145
+ return {
146
+ type: "doc",
147
+ content: lines.map((line) => ({
148
+ type: "paragraph",
149
+ content: parseLine(line, known)
150
+ }))
151
+ };
152
+ }
153
+ function parseLine(line, known) {
154
+ const content = [];
155
+ let text = "";
156
+ const flush = () => {
157
+ if (text) {
158
+ content.push({ type: "text", text });
159
+ text = "";
160
+ }
161
+ };
162
+ let i = 0;
163
+ while (i < line.length) {
164
+ if (line[i] === "@") {
165
+ const id = matchKnownId(line, i + 1, known);
166
+ if (id) {
167
+ const item = known.get(id);
168
+ flush();
169
+ content.push({
170
+ type: MENTION_TYPE,
171
+ attrs: {
172
+ id: item.id,
173
+ label: item.label,
174
+ kind: item.kind ?? null
175
+ }
176
+ });
177
+ i += 1 + id.length;
178
+ continue;
179
+ }
180
+ }
181
+ text += line[i];
182
+ i += 1;
183
+ }
184
+ flush();
185
+ return content;
186
+ }
187
+ function matchKnownId(line, pos, known) {
188
+ let best = null;
189
+ for (const id of known.keys()) {
190
+ if (id.length === 0) continue;
191
+ if (best !== null && id.length <= best.length) continue;
192
+ if (!line.startsWith(id, pos)) continue;
193
+ const after = pos + id.length;
194
+ if (after < line.length && !/\s/.test(line[after])) continue;
195
+ best = id;
196
+ }
197
+ return best;
198
+ }
199
+
200
+ // src/chat/mention-editor.tsx
201
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
202
+ function buildComposerStarterKit() {
203
+ return StarterKit.configure({
204
+ blockquote: false,
205
+ bold: false,
206
+ bulletList: false,
207
+ code: false,
208
+ codeBlock: false,
209
+ dropcursor: false,
210
+ heading: false,
211
+ horizontalRule: false,
212
+ italic: false,
213
+ link: false,
214
+ listItem: false,
215
+ listKeymap: false,
216
+ orderedList: false,
217
+ strike: false,
218
+ trailingNode: false,
219
+ underline: false
220
+ });
221
+ }
222
+ function buildMentionExtension(trigger, suggestion) {
223
+ return Mention.extend({
224
+ addAttributes() {
225
+ return {
226
+ id: {
227
+ default: null,
228
+ parseHTML: (element) => element.getAttribute("data-id"),
229
+ renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {}
230
+ },
231
+ label: {
232
+ default: null,
233
+ parseHTML: (element) => element.getAttribute("data-label"),
234
+ renderHTML: (attrs) => attrs.label ? { "data-label": attrs.label } : {}
235
+ },
236
+ kind: {
237
+ default: null,
238
+ parseHTML: (element) => element.getAttribute("data-kind"),
239
+ renderHTML: (attrs) => attrs.kind ? { "data-kind": attrs.kind } : {}
240
+ }
241
+ };
242
+ }
243
+ }).configure({
244
+ HTMLAttributes: { class: MENTION_PILL_CLASS },
245
+ renderText: ({ node }) => `${trigger}${node.attrs.id}`,
246
+ renderHTML: ({ options, node }) => [
247
+ "span",
248
+ mergeAttributes(options.HTMLAttributes, {
249
+ title: node.attrs.id ?? void 0
250
+ }),
251
+ `${trigger}${node.attrs.label ?? node.attrs.id}`
252
+ ],
253
+ suggestion
254
+ });
255
+ }
256
+ var EDITOR_CLASS = cn(
257
+ "w-full whitespace-pre-wrap break-words bg-transparent px-2 py-1",
258
+ "text-foreground text-sm leading-relaxed outline-none"
259
+ );
260
+ var FETCH_DEBOUNCE_MS = 100;
261
+ function mentionsKey(mentions) {
262
+ return mentions.map((item) => item.id).join("\0");
263
+ }
264
+ function MentionEditor({
265
+ value,
266
+ onChange,
267
+ onSubmit,
268
+ placeholder,
269
+ disabled,
270
+ autoFocus,
271
+ minRows,
272
+ maxHeight,
273
+ mention,
274
+ registerFocus,
275
+ onPasteFiles
276
+ }) {
277
+ const trigger = mention.trigger ?? "@";
278
+ const [anchor, setAnchor] = React2.useState(null);
279
+ const [open, setOpen] = React2.useState(false);
280
+ const [query, setQuery] = React2.useState("");
281
+ const [items, setItems] = React2.useState([]);
282
+ const [loading, setLoading] = React2.useState(false);
283
+ const [errored, setErrored] = React2.useState(false);
284
+ const openRef = React2.useRef(false);
285
+ const commandRef = React2.useRef(null);
286
+ const listRef = React2.useRef(null);
287
+ const knownRef = React2.useRef(/* @__PURE__ */ new Map());
288
+ const requestIdRef = React2.useRef(0);
289
+ const clientRectRef = React2.useRef(null);
290
+ const lastMentionsKeyRef = React2.useRef("");
291
+ const onSubmitRef = React2.useRef(onSubmit);
292
+ onSubmitRef.current = onSubmit;
293
+ const onChangeRef = React2.useRef(onChange);
294
+ onChangeRef.current = onChange;
295
+ const onMentionsChangeRef = React2.useRef(mention.onMentionsChange);
296
+ onMentionsChangeRef.current = mention.onMentionsChange;
297
+ const onPasteFilesRef = React2.useRef(onPasteFiles);
298
+ onPasteFilesRef.current = onPasteFiles;
299
+ const fetchItemsRef = React2.useRef(mention.fetchItems);
300
+ fetchItemsRef.current = mention.fetchItems;
301
+ const editor = useEditor(
302
+ {
303
+ immediatelyRender: false,
304
+ editable: !disabled,
305
+ autofocus: autoFocus ? "end" : false,
306
+ content: parseMentionValue(value, knownRef.current),
307
+ editorProps: {
308
+ attributes: {
309
+ class: EDITOR_CLASS,
310
+ role: "textbox",
311
+ "aria-multiline": "true",
312
+ "aria-label": "Message input"
313
+ },
314
+ handleKeyDown: (_view, event) => {
315
+ if (event.key !== "Enter" || event.shiftKey) return false;
316
+ if (event.isComposing || event.keyCode === 229) return false;
317
+ if (openRef.current) return false;
318
+ event.preventDefault();
319
+ onSubmitRef.current();
320
+ return true;
321
+ },
322
+ handlePaste: (_view, event) => {
323
+ const files = event.clipboardData?.files;
324
+ if (files && files.length > 0 && onPasteFilesRef.current) {
325
+ return onPasteFilesRef.current(files);
326
+ }
327
+ return false;
328
+ }
329
+ },
330
+ onUpdate: ({ editor: editor2 }) => {
331
+ const json = editor2.getJSON();
332
+ onChangeRef.current(serializeMentionDoc(json));
333
+ const mentions = collectMentions(json);
334
+ for (const item of mentions) knownRef.current.set(item.id, item);
335
+ lastMentionsKeyRef.current = mentionsKey(mentions);
336
+ onMentionsChangeRef.current?.(mentions);
337
+ },
338
+ extensions: [
339
+ buildComposerStarterKit(),
340
+ buildMentionExtension(trigger, {
341
+ char: trigger,
342
+ allowSpaces: false,
343
+ // Items are fetched by this component (so it can model loading and
344
+ // error states), not by the suggestion plugin.
345
+ items: () => [],
346
+ command: ({ editor: editor2, range, props }) => {
347
+ const item = props;
348
+ editor2.chain().focus().insertContentAt(range, [
349
+ {
350
+ type: "mention",
351
+ attrs: {
352
+ id: item.id,
353
+ label: item.label,
354
+ kind: item.kind ?? null
355
+ }
356
+ },
357
+ { type: "text", text: " " }
358
+ ]).run();
359
+ knownRef.current.set(item.id, item);
360
+ },
361
+ render: () => ({
362
+ onStart: (props) => {
363
+ openRef.current = true;
364
+ commandRef.current = props.command;
365
+ clientRectRef.current = props.clientRect ?? null;
366
+ setOpen(true);
367
+ setQuery(props.query);
368
+ setAnchor(props.clientRect?.() ?? null);
369
+ },
370
+ onUpdate: (props) => {
371
+ commandRef.current = props.command;
372
+ clientRectRef.current = props.clientRect ?? null;
373
+ setQuery(props.query);
374
+ setAnchor(props.clientRect?.() ?? null);
375
+ },
376
+ onKeyDown: (props) => {
377
+ if (props.event.key === "Escape") {
378
+ exitSuggestion(props.view);
379
+ return true;
380
+ }
381
+ return listRef.current?.onKeyDown(props.event) ?? false;
382
+ },
383
+ onExit: () => {
384
+ openRef.current = false;
385
+ commandRef.current = null;
386
+ setOpen(false);
387
+ setItems([]);
388
+ setLoading(false);
389
+ setErrored(false);
390
+ }
391
+ })
392
+ })
393
+ ]
394
+ },
395
+ []
396
+ );
397
+ React2.useEffect(() => {
398
+ if (!open) return;
399
+ const requestId = requestIdRef.current += 1;
400
+ setLoading(true);
401
+ setErrored(false);
402
+ const timer = setTimeout(() => {
403
+ Promise.resolve(fetchItemsRef.current(query)).then((results) => {
404
+ if (requestId !== requestIdRef.current) return;
405
+ for (const item of results) knownRef.current.set(item.id, item);
406
+ setItems(results);
407
+ setLoading(false);
408
+ }).catch(() => {
409
+ if (requestId !== requestIdRef.current) return;
410
+ setErrored(true);
411
+ setLoading(false);
412
+ });
413
+ }, FETCH_DEBOUNCE_MS);
414
+ return () => clearTimeout(timer);
415
+ }, [open, query]);
416
+ React2.useEffect(() => {
417
+ if (!open) return;
418
+ const reposition = () => setAnchor(clientRectRef.current?.() ?? null);
419
+ window.addEventListener("scroll", reposition, true);
420
+ window.addEventListener("resize", reposition);
421
+ return () => {
422
+ window.removeEventListener("scroll", reposition, true);
423
+ window.removeEventListener("resize", reposition);
424
+ };
425
+ }, [open]);
426
+ React2.useEffect(() => {
427
+ if (!editor) return;
428
+ if (serializeMentionDoc(editor.getJSON()) === value) return;
429
+ const doc = parseMentionValue(value, knownRef.current);
430
+ editor.commands.setContent(doc, { emitUpdate: false });
431
+ const mentions = collectMentions(doc);
432
+ const key = mentionsKey(mentions);
433
+ if (key !== lastMentionsKeyRef.current) {
434
+ lastMentionsKeyRef.current = key;
435
+ onMentionsChangeRef.current?.(mentions);
436
+ }
437
+ }, [editor, value]);
438
+ React2.useEffect(() => {
439
+ editor?.setEditable(!disabled);
440
+ }, [editor, disabled]);
441
+ React2.useEffect(() => {
442
+ if (!editor || !registerFocus) return;
443
+ registerFocus(() => editor.commands.focus());
444
+ }, [editor, registerFocus]);
445
+ return /* @__PURE__ */ jsxs2("div", { className: "relative", children: [
446
+ /* @__PURE__ */ jsxs2(
447
+ "div",
448
+ {
449
+ className: "overflow-y-auto",
450
+ style: { maxHeight, minHeight: `${minRows * 1.5}rem` },
451
+ children: [
452
+ /* @__PURE__ */ jsx2(EditorContent, { editor }),
453
+ value.length === 0 && /* @__PURE__ */ jsx2("div", { className: "pointer-events-none absolute top-1 left-2 text-muted-foreground text-sm leading-relaxed", children: placeholder })
454
+ ]
455
+ }
456
+ ),
457
+ open && /* @__PURE__ */ jsx2(
458
+ "div",
459
+ {
460
+ className: "absolute z-20",
461
+ style: anchorStyle(anchor),
462
+ "data-testid": "mention-popover",
463
+ children: /* @__PURE__ */ jsx2(
464
+ MentionList,
465
+ {
466
+ ref: listRef,
467
+ items,
468
+ loading,
469
+ error: errored,
470
+ emptyText: mention.emptyText,
471
+ renderItem: mention.renderItem,
472
+ onSelect: (item) => commandRef.current?.(item),
473
+ className: mention.popoverClassName
474
+ }
475
+ )
476
+ }
477
+ )
478
+ ] });
479
+ }
480
+ function anchorStyle(rect) {
481
+ if (!rect || rect.left === 0 && rect.bottom === 0) {
482
+ return { position: "absolute", top: "100%", left: 0 };
483
+ }
484
+ return { position: "fixed", top: rect.bottom + 4, left: rect.left };
485
+ }
486
+ export {
487
+ buildComposerStarterKit,
488
+ buildMentionExtension,
489
+ MentionEditor as default
490
+ };
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
  }
@@ -2034,6 +2037,12 @@
2034
2037
  border-color: color-mix(in oklab, var(--color-primary) 30%, transparent);
2035
2038
  }
2036
2039
  }
2040
+ .border-primary\/40 {
2041
+ border-color: color-mix(in srgb, hsl(var(--primary)) 40%, transparent);
2042
+ @supports (color: color-mix(in lab, red, red)) {
2043
+ border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
2044
+ }
2045
+ }
2037
2046
  .border-primary\/50 {
2038
2047
  border-color: color-mix(in srgb, hsl(var(--primary)) 50%, transparent);
2039
2048
  @supports (color: color-mix(in lab, red, red)) {
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.87.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",