@agent-native/toolkit 0.22.2-nightly-20260927082735 → 0.22.2

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.
@@ -529,6 +529,7 @@ function plainTextToDoc(text) {
529
529
  })),
530
530
  };
531
531
  }
532
+ /** Tiptap keeps the Editor object truthy after destroy but clears commandManager. */
532
533
  export function isComposerEditorUsable(editor) {
533
534
  return Boolean(editor && editor.isDestroyed !== true);
534
535
  }
@@ -664,9 +665,23 @@ const PICKER_HELP_ICON_CLASS = "size-2";
664
665
  export function shouldShowModelSelectorSkeleton(isLoading, engineCount) {
665
666
  return isLoading && engineCount === 0;
666
667
  }
668
+ /**
669
+ * With nothing connected, every family is a dead "needs API key" row, so the
670
+ * picker shows only the connect CTAs. Never hide the list unless a CTA is
671
+ * there to replace it — an empty popover reads as more broken, not less.
672
+ */
667
673
  export function shouldShowOnlyConnectPath(showBuilderCta, groups) {
668
674
  return showBuilderCta && groups.every((group) => !group.configured);
669
675
  }
676
+ /**
677
+ * When nothing is routable yet, the model hook resolves `selectedModel` to
678
+ * `""` rather than pre-selecting something unusable — that reflects "nothing
679
+ * chosen," not "nothing to show." The picker itself still has a job to do in
680
+ * that state (its connect-provider CTAs). During the initial discovery window
681
+ * the list is empty too, but the button still needs to exist so the picker can
682
+ * reveal its loading or setup state instead of making the composer look
683
+ * incomplete.
684
+ */
670
685
  export function shouldRenderModelSelector(availableModels, onModelChange) {
671
686
  return Boolean(availableModels && onModelChange);
672
687
  }
@@ -679,11 +694,13 @@ function friendlyModelName(model, t) {
679
694
  if (FRIENDLY_MODEL_NAMES[model])
680
695
  return FRIENDLY_MODEL_NAMES[model];
681
696
  const normalizedModel = model.replace(/^(?:anthropic|openai|google)\//, "");
697
+ // Claude: claude-{tier}-{major}[-minor][-dateYYYYMMDD].
682
698
  const claude = normalizedModel.match(/^claude-(opus|sonnet|haiku|fable)-(\d+)(?:[-.](\d+))?(?:-\d{8,})?$/);
683
699
  if (claude) {
684
700
  const tier = claude[1][0].toUpperCase() + claude[1].slice(1);
685
701
  return `Claude ${tier} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
686
702
  }
703
+ // GPT: gpt-{major}[-minor][-variant] → GPT-Major[.Minor] Variant.
687
704
  const gpt = normalizedModel.match(/^gpt-(\d+)(?:[.-](\d+))?(?:[.-](.+))?$/);
688
705
  if (gpt) {
689
706
  const version = `${gpt[1]}${gpt[2] ? `.${gpt[2]}` : ""}`;
@@ -695,6 +712,7 @@ function friendlyModelName(model, t) {
695
712
  }
696
713
  if (/^o\d/.test(normalizedModel))
697
714
  return normalizedModel;
715
+ // Gemini: gemini-{version.parts}-{variant}[-preview] → Gemini Version Variant.
698
716
  const geminiVersioned = normalizedModel.match(/^gemini-(\d+(?:[-.]\d+)+)-(.+?)(?:-preview)?$/);
699
717
  if (geminiVersioned) {
700
718
  const variant = geminiVersioned[2]
@@ -704,6 +722,7 @@ function friendlyModelName(model, t) {
704
722
  const version = geminiVersioned[1].replace(/-/g, ".");
705
723
  return `Gemini ${version} ${variant}`.replace("Flash Lite", "Flash-Lite");
706
724
  }
725
+ // Gemini: gemini-{version.parts}[-preview] → Gemini Version Parts
707
726
  const gemini = normalizedModel.match(/^gemini-(.+?)(?:-preview)?$/);
708
727
  if (gemini) {
709
728
  const parts = gemini[1]
@@ -805,6 +824,7 @@ function compareModelVersions(left, right) {
805
824
  }
806
825
  return 0;
807
826
  }
827
+ /** Keep the newest version of each Claude, Gemini, and GPT tier. */
808
828
  function latestModelsOnly(models) {
809
829
  const latest = new Map();
810
830
  for (const id of models) {
@@ -820,6 +840,15 @@ function latestModelsOnly(models) {
820
840
  const latestIds = new Set([...latest.values()].map(({ id }) => id));
821
841
  return models.filter((id) => !versionedModelFamily(id) || latestIds.has(id));
822
842
  }
843
+ /**
844
+ * Coarse relative cost per model, rendered as a quiet `$`…`$$$` suffix.
845
+ *
846
+ * Tokens and their order mirror `MODEL_COST_ORDER` in `@agent-native/core`'s
847
+ * chat-model-groups, which sorts these same rows — the toolkit cannot import
848
+ * from core, so a new model family has to be added in both places. Tiers are
849
+ * each provider's own entry/mid/flagship ladder, not a cross-provider price
850
+ * claim; anything unlisted has no tier rather than a guessed one.
851
+ */
823
852
  const MODEL_COST_TIERS = [
824
853
  ["luna", 1],
825
854
  ["terra", 2],
@@ -937,6 +966,8 @@ function ModelSelector({ model, effort, engines, agents, selectedAgent, selected
937
966
  }, [agentOnly, controlledOpen, onModelSelectorOpenChange]);
938
967
  const visibleProviderGroups = modelProviderGroups;
939
968
  const showModelListSkeleton = shouldShowModelSelectorSkeleton(modelListLoading, engines.length);
969
+ // Keep setup actions visible, but do not show unusable model rows until one
970
+ // provider or local agent is ready.
940
971
  const builderFlow = adapters.builder.useConnectFlow({
941
972
  enabled: providerConnectStatusEnabled,
942
973
  provisionAccount: true,
@@ -1175,7 +1206,7 @@ function ModelSelectorSkeleton() {
1175
1206
  defaultValue: "Loading models…",
1176
1207
  }) }), ["w-24", "w-32", "w-20", "w-28"].map((width, index) => (_jsxs("div", { className: "flex items-center gap-1.5 px-1 py-1.5", children: [_jsx(Skeleton, { className: "size-3 rounded-sm" }), _jsx(Skeleton, { className: `h-3 ${width}` })] }, index)))] }));
1177
1208
  }
1178
- export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submissionDisabled = false, submitting = false, maxDocumentAttachmentBytes = MAX_DOCUMENT_ATTACHMENT_BYTES, documentAttachmentLimitLabel = "PDFs", attachmentsEnabled = true, onAttachmentRequest, focusRef, initialText, initialTextKey, onSubmit, onBeforeSubmit, clearOnSubmit = true, onTextChange, actionButton, willQueue = false, extraActionButton, stopButton, attachButton, modeControl, toolbarSlot, layoutVariant = "default", slashCommands = [], slashSkills = [], includeDefaultSlashCommands = true, includeDefaultSlashSkills = true, onSlashCommand, execMode, onExecModeChange, planModeDisabled = false, planModeDisabledReason, voiceEnabled = DEFAULT_VOICE_DICTATION_ENABLED, selectedModel, selectedEngine, selectedEffort, showAutoModelOption = true, modelSelectorOpen, availableModels, modelListLoading, onModelChange, onEffortChange, availableAgents, selectedAgent, agentOnly = false, hostedHarness, onAgentChange, onModelSelectorOpenChange, providerConnectStatusEnabled, onConnectProvider, onConnectLocalRuntime, imageModelMenu, draftScope, contextItems: providedContextItems, onRemoveContextItem, onInspectContextItem, onRetryContextItem, contextMenuItems, plusMenuMode = "full", terminalModeControl, extensionTools = false, interceptBuildRequestsForBuilder = false, onAttachmentError, }) {
1209
+ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submissionDisabled = false, submitting = false, maxDocumentAttachmentBytes = MAX_DOCUMENT_ATTACHMENT_BYTES, documentAttachmentLimitLabel = "PDFs", attachmentsEnabled = true, onAttachmentRequest, contextButtonTooltipDisabled = false, focusRef, initialText, initialTextKey, onSubmit, onBeforeSubmit, clearOnSubmit = true, onTextChange, actionButton, willQueue = false, extraActionButton, stopButton, attachButton, modeControl, toolbarSlot, layoutVariant = "default", slashCommands = [], slashSkills = [], includeDefaultSlashCommands = true, includeDefaultSlashSkills = true, onSlashCommand, execMode, onExecModeChange, planModeDisabled = false, planModeDisabledReason, voiceEnabled = DEFAULT_VOICE_DICTATION_ENABLED, selectedModel, selectedEngine, selectedEffort, showAutoModelOption = true, modelSelectorOpen, availableModels, modelListLoading, onModelChange, onEffortChange, availableAgents, selectedAgent, agentOnly = false, hostedHarness, onAgentChange, onModelSelectorOpenChange, providerConnectStatusEnabled, onConnectProvider, onConnectLocalRuntime, imageModelMenu, draftScope, contextItems: providedContextItems, onRemoveContextItem, onInspectContextItem, onRetryContextItem, contextMenuItems, plusMenuMode = "full", terminalModeControl, extensionTools = false, interceptBuildRequestsForBuilder = false, onAttachmentError, }) {
1179
1210
  const contextItems = providedContextItems ?? [];
1180
1211
  const adapters = useComposerRuntimeAdapters();
1181
1212
  const t = adapters.translate;
@@ -1211,6 +1242,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1211
1242
  const composerModeRef = useRef(null);
1212
1243
  const isMac = typeof navigator !== "undefined" &&
1213
1244
  /Mac|iPhone|iPad/.test(navigator.userAgent);
1245
+ // Refs for values accessed in handleKeyDown (ProseMirror doesn't re-bind)
1214
1246
  const popoverStateRef = useRef(null);
1215
1247
  const onAttachmentErrorRef = useRef(onAttachmentError);
1216
1248
  onAttachmentErrorRef.current = onAttachmentError;
@@ -1224,6 +1256,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1224
1256
  const filteredMentionItems = useMemo(() => filterMentionItemsForSlots(mentionItems, slotReferences), [mentionItems, slotReferences]);
1225
1257
  const { skills, hint, isLoading: skillsLoading, } = useSkills(includeDefaultSlashSkills && popover?.type === "/");
1226
1258
  const allSlashCommands = useMemo(() => {
1259
+ // A command without a host callback would be deleted as an invisible no-op.
1227
1260
  if (!onSlashCommand)
1228
1261
  return [];
1229
1262
  return mergeSlashCommands([
@@ -1253,6 +1286,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1253
1286
  return allSlashSkills.filter((s) => s.name.toLowerCase().includes(q) ||
1254
1287
  s.description?.toLowerCase().includes(q));
1255
1288
  }, [allSlashSkills, popover]);
1289
+ // Keep refs in sync with state
1256
1290
  const mentionItemsRef = useRef(filteredMentionItems);
1257
1291
  mentionItemsRef.current = filteredMentionItems;
1258
1292
  const filteredCommandsRef = useRef(filteredCommands);
@@ -1296,6 +1330,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1296
1330
  setPopover(null);
1297
1331
  popoverStateRef.current = null;
1298
1332
  }, []);
1333
+ // Persist draft to localStorage so refreshes don't lose the prompt.
1299
1334
  const hasDraftScope = Boolean(draftScope?.trim());
1300
1335
  const draftKey = hasDraftScope || initialText === undefined
1301
1336
  ? getComposerDraftKey(draftScope)
@@ -1349,6 +1384,8 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1349
1384
  useEffect(() => {
1350
1385
  lastComposerRuntimeSyncRef.current = null;
1351
1386
  }, [composerRuntime]);
1387
+ // Tiptap reads extension config once at init; ref keeps runtime prop
1388
+ // changes visible to Placeholder's function form.
1352
1389
  const resolvedPlaceholder = composerMode
1353
1390
  ? localizedComposerModeConfig(composerMode, t).placeholder
1354
1391
  : (placeholder ??
@@ -1361,6 +1398,8 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1361
1398
  extensions: createTiptapComposerExtensions(() => placeholderRef.current),
1362
1399
  editable: !disabled,
1363
1400
  onUpdate: ({ editor: ed }) => {
1401
+ // Drive the send button's enabled state from the actual editor contents;
1402
+ // the composer runtime is only synced on submit, so its isEmpty lags.
1364
1403
  setEditorHasText(composerDocumentHasContent(ed.state.doc));
1365
1404
  onTextChangeRef.current?.(ed.state.doc.textContent.trim());
1366
1405
  scheduleComposerDraftPersist(ed);
@@ -1406,9 +1445,17 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1406
1445
  if (files.length > 0) {
1407
1446
  event.preventDefault();
1408
1447
  const attachments = files.map((file) => {
1448
+ // SimpleImageAttachmentAdapter uses file.name as the attachment id.
1449
+ // Clipboard images (e.g. screenshots) are typically all named
1450
+ // "image.png", so a second paste would replace the first instead of
1451
+ // appending. Prepend a unique token so each paste gets a distinct id.
1409
1452
  const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2)}-${file.name}`;
1410
1453
  return new File([file], uniqueName, { type: file.type });
1411
1454
  });
1455
+ // Google Docs rich clipboard payloads can contain both embedded
1456
+ // image files and the document text. Since handling files means we
1457
+ // prevent Tiptap's default paste, preserve any text as its own chip
1458
+ // instead of silently dropping the source material.
1412
1459
  if (pastedText.trim()) {
1413
1460
  attachments.push(createPastedAttachmentFile(paste));
1414
1461
  }
@@ -1420,6 +1467,13 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1420
1467
  });
1421
1468
  return true;
1422
1469
  }
1470
+ // Page-sized pastes turn into a `Pasted text` attachment chip so the
1471
+ // prompt stays readable while normal paragraphs and lists stay inline.
1472
+ // When the paste is HTML (e.g. an Alpine.js extension or a document the
1473
+ // user wants hosted), it's stored as a real .html attachment so it
1474
+ // travels the same rail as uploading that file — the agent reads it
1475
+ // verbatim via contentFromAttachment instead of retyping it inline,
1476
+ // which cuts off mid-stream on large files and triggers a spin.
1423
1477
  if (shouldConvertClipboardToAttachment(paste)) {
1424
1478
  event.preventDefault();
1425
1479
  void addAttachmentForCurrentScope(createPastedAttachmentFile(paste)).catch((error) => {
@@ -1440,6 +1494,9 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1440
1494
  }
1441
1495
  return false;
1442
1496
  }
1497
+ // Drag-and-drop files (decks, images, PDFs, etc.) into the composer.
1498
+ // Mark handled drops as consumed so the chat-wide drop target does not
1499
+ // add the same file a second time.
1443
1500
  return handleComposerFileDrop({
1444
1501
  event: event,
1445
1502
  addAttachment: addAttachmentForCurrentScope,
@@ -1454,6 +1511,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1454
1511
  },
1455
1512
  handleKeyDown: (view, event) => {
1456
1513
  const pop = popoverStateRef.current;
1514
+ // Handle popover keyboard nav
1457
1515
  if (pop) {
1458
1516
  if (event.key === "ArrowUp") {
1459
1517
  event.preventDefault();
@@ -1529,6 +1587,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1529
1587
  selectedContextItemKeyRef.current = null;
1530
1588
  setSelectedContextItemKey(null);
1531
1589
  }
1590
+ // Backspace removes composer mode chip when editor is empty
1532
1591
  if (event.key === "Backspace" && composerModeRef.current) {
1533
1592
  if (view.state.doc.textContent.trim() === "" &&
1534
1593
  from === to &&
@@ -1538,6 +1597,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1538
1597
  return true;
1539
1598
  }
1540
1599
  }
1600
+ // Keyboard shortcut toggles Act/Plan mode from inside the editor.
1541
1601
  if (event.key === "Tab" && event.shiftKey) {
1542
1602
  event.preventDefault();
1543
1603
  const current = execModeRef.current;
@@ -1550,6 +1610,9 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1550
1610
  }
1551
1611
  return true;
1552
1612
  }
1613
+ // Submit on Enter. Shift+Enter inserts a newline and keeps the
1614
+ // composer scrolled to the caret.
1615
+ // Cmd+Enter on macOS / Ctrl+Enter elsewhere marks the submit queued.
1553
1616
  if (event.key === "Enter" && event.shiftKey) {
1554
1617
  event.preventDefault();
1555
1618
  return insertComposerHardBreakAndScrollIntoView(view);
@@ -1560,6 +1623,8 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1560
1623
  void submitComposer(submitIntent);
1561
1624
  return true;
1562
1625
  }
1626
+ // Detect @ trigger — only when preceded by start-of-text, space, or newline
1627
+ // (not after alphanumeric chars, which would indicate an email address)
1563
1628
  if (event.key === "@") {
1564
1629
  const { from } = view.state.selection;
1565
1630
  const textBefore = view.state.doc.textBetween(Math.max(0, from - 1), from);
@@ -1580,6 +1645,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1580
1645
  }
1581
1646
  return false;
1582
1647
  }
1648
+ // Detect / trigger (only at start of line or after whitespace)
1583
1649
  if (event.key === "/") {
1584
1650
  const { from } = view.state.selection;
1585
1651
  const textBefore = view.state.doc.textBetween(Math.max(0, from - 1), from);
@@ -1623,11 +1689,16 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1623
1689
  draftEditorRef.current = null;
1624
1690
  };
1625
1691
  }, [cancelScheduledDraftPersist, draftKey, editor]);
1692
+ // Placeholder decorations are computed by ProseMirror. Dispatching an empty
1693
+ // transaction makes a locale or composer-mode change visible immediately.
1626
1694
  useEffect(() => {
1627
1695
  if (!isComposerEditorUsable(editor))
1628
1696
  return;
1629
1697
  editor.view.dispatch(editor.state.tr.setSelection(editor.state.selection));
1630
1698
  }, [editor, resolvedPlaceholder]);
1699
+ // A tab can stay mounted while becoming the active composer later. Publish
1700
+ // its existing draft when the host starts observing it so contextual UI is
1701
+ // correct immediately after a tab switch, not only after the next keystroke.
1631
1702
  useEffect(() => {
1632
1703
  if (!isComposerEditorUsable(editor) || !onTextChange)
1633
1704
  return;
@@ -1725,6 +1796,9 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1725
1796
  if (isComposerEditorUsable(editor))
1726
1797
  editor.commands.focus("end");
1727
1798
  },
1799
+ addAttachment(file) {
1800
+ return addAttachmentForCurrentScope(file);
1801
+ },
1728
1802
  insertText(text) {
1729
1803
  if (!isComposerEditorUsable(editor))
1730
1804
  return;
@@ -1757,6 +1831,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1757
1831
  editor.commands.focus("end");
1758
1832
  }, 50);
1759
1833
  }, [editor]);
1834
+ // --- Live voice transcription: text appears in the editor as the user speaks ---
1760
1835
  const voiceAnchorRef = useRef(null);
1761
1836
  const prevVoiceInsertRef = useRef("");
1762
1837
  const handleLiveUpdate = useCallback((finalText, interimText) => {
@@ -1883,6 +1958,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1883
1958
  });
1884
1959
  const voiceCancelRef = useRef(voice.cancel);
1885
1960
  voiceCancelRef.current = voice.cancel;
1961
+ // Clean up live text if voice session ends without a final transcript (cancel/error)
1886
1962
  useEffect(() => {
1887
1963
  if (voice.state === "idle" && voiceAnchorRef.current != null) {
1888
1964
  const anchor = voiceAnchorRef.current;
@@ -1908,10 +1984,14 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1908
1984
  prevVoiceInsertRef.current = "";
1909
1985
  }
1910
1986
  }, [voice.state, editor]);
1987
+ // Global shortcut: Cmd/Ctrl + Shift + M toggles dictation. Escape cancels
1988
+ // while recording. Scoped to avoid firing when focus is outside the app.
1911
1989
  useEffect(() => {
1912
1990
  if (!voiceEnabled || !voice.supported)
1913
1991
  return;
1914
1992
  const handler = (e) => {
1993
+ // e.key can be undefined on some trusted keydown events (autofill/IME
1994
+ // quirks) — seen crashing in production (AGENT-NATIVE-BROWSER-S).
1915
1995
  const isToggleCombo = typeof e.key === "string" &&
1916
1996
  e.key.toLowerCase() === "m" &&
1917
1997
  e.shiftKey &&
@@ -1945,6 +2025,8 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1945
2025
  };
1946
2026
  }
1947
2027
  const references = slotReferences.map(referenceFromComposerReference);
2028
+ // Build text that preserves @mentions (getText() strips them).
2029
+ // Walk the document and reconstruct with @name for mention/file/skill nodes.
1948
2030
  const textParts = [];
1949
2031
  ed.state.doc.descendants((node) => {
1950
2032
  if (node.isText) {
@@ -1974,6 +2056,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
1974
2056
  slotReferences.map((ref) => slotReferenceTitle(ref)).join(", ");
1975
2057
  ed.state.doc.descendants((node) => {
1976
2058
  if (node.type.name === "fileReference") {
2059
+ // Legacy support
1977
2060
  references.push({
1978
2061
  type: "file",
1979
2062
  path: node.attrs.path,
@@ -2139,6 +2222,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2139
2222
  voice.cancel();
2140
2223
  }
2141
2224
  };
2225
+ // Intercept slash commands typed directly (e.g. "/clear" + Enter)
2142
2226
  const trimmed = text.trim();
2143
2227
  if (trimmed.startsWith("/") && references.length === 0) {
2144
2228
  const cmdName = normalizeSlashCommandName(trimmed);
@@ -2149,6 +2233,12 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2149
2233
  return true;
2150
2234
  }
2151
2235
  }
2236
+ // Builder iframe delegation: when this app is mounted inside the
2237
+ // Builder.io webview and the user typed a "build me an app/agent"
2238
+ // prompt, hand it up to the parent Builder chat instead of sending
2239
+ // it to this app's domain agent. Builder is the code-writing agent;
2240
+ // the local agent (dispatch, mail, etc.) cannot scaffold workspace
2241
+ // apps from inside its own iframe.
2152
2242
  if (!composerMode &&
2153
2243
  interceptBuildRequestsForBuilder &&
2154
2244
  adapters.builder.tryDelegateBuildRequest(trimmed)) {
@@ -2171,6 +2261,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2171
2261
  return false;
2172
2262
  if (!isCurrentDraftScope())
2173
2263
  return false;
2264
+ // Composer mode: send with context via agent chat bridge
2174
2265
  if (composerMode) {
2175
2266
  const config = localizedComposerModeConfig(composerMode, t);
2176
2267
  config.beforeSend?.();
@@ -2249,6 +2340,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2249
2340
  }
2250
2341
  if (!isCurrentDraftScope())
2251
2342
  return true;
2343
+ // Clear any pending attachments now that the host has them.
2252
2344
  void composerRuntime.clearAttachments().catch(() => { });
2253
2345
  if (!clearOnSubmit) {
2254
2346
  closePopover();
@@ -2291,11 +2383,14 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2291
2383
  announceSlashCommand,
2292
2384
  t,
2293
2385
  ]);
2386
+ // Helper functions that operate on the editor view directly
2387
+ // These are called from handleKeyDown which can't use React state
2294
2388
  function selectMention(_view, pop, item) {
2295
2389
  const ed = editor;
2296
2390
  if (!isComposerEditorUsable(ed))
2297
2391
  return;
2298
2392
  const currentPos = ed.state.selection.from;
2393
+ // startPos is after the trigger char, so -1 to include the @ or /
2299
2394
  const deleteFrom = Math.max(0, pop.startPos - 1);
2300
2395
  ed.chain().focus().deleteRange({ from: deleteFrom, to: currentPos }).run();
2301
2396
  insertReference(composerReferenceFromMentionItem(item));
@@ -2331,6 +2426,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2331
2426
  popoverStateRef.current = null;
2332
2427
  setPopover(null);
2333
2428
  }
2429
+ // Popover select handlers for click-based selection (from MentionPopover)
2334
2430
  const handleSelectMention = useCallback((item) => {
2335
2431
  if (!isComposerEditorUsable(editor) || !popover)
2336
2432
  return;
@@ -2374,6 +2470,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2374
2470
  .run();
2375
2471
  closePopover();
2376
2472
  }, [editor, popover, closePopover]);
2473
+ // Track query text as user types after trigger
2377
2474
  useEffect(() => {
2378
2475
  if (!isComposerEditorUsable(editor) || !popover)
2379
2476
  return;
@@ -2388,6 +2485,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2388
2485
  return;
2389
2486
  }
2390
2487
  const text = editor.state.doc.textBetween(startPos, from);
2488
+ // Verify the trigger character is still there
2391
2489
  if (startPos > 0) {
2392
2490
  const triggerChar = editor.state.doc.textBetween(startPos - 1, startPos);
2393
2491
  if ((type === "@" && triggerChar !== "@") ||
@@ -2495,6 +2593,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2495
2593
  initialTextKey,
2496
2594
  scheduleComposerDraftPersist,
2497
2595
  ]);
2596
+ // Tiptap only reads `editable` at init; prop changes need setEditable.
2498
2597
  useEffect(() => {
2499
2598
  if (!isComposerEditorUsable(editor))
2500
2599
  return;
@@ -2542,7 +2641,7 @@ export function TiptapComposer({ placeholder, ariaLabel, disabled = false, submi
2542
2641
  }, "aria-label": t("agentChat.composer.removeContext", {
2543
2642
  defaultValue: "Remove {{name}} context",
2544
2643
  name: item.title,
2545
- }), className: "ms-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground", children: _jsx(IconX, { className: "h-3 w-3" }) })) : null] }, item.key)))] })), contextSubmissionError ? (_jsx("p", { role: "alert", className: "px-2 text-xs text-destructive", children: contextSubmissionError })) : null, _jsx("div", { "data-agent-composer-variant": layoutVariant, "data-agent-composer-slot": "editor-wrap", className: `agent-composer-editor-wrap ${composerMode || hasContextRows ? "px-2 pt-1 pb-1" : "px-2 pt-2 pb-1"}`, children: _jsx(EditorContent, { editor: editor, "data-agent-composer-variant": layoutVariant, "data-agent-composer-slot": "editor", className: "agent-composer-editor aui-composer flex-1 min-w-0 [&_.ProseMirror]:outline-none [&_.ProseMirror_p]:m-0 px-0.5" }) }), voiceEnabled && _jsx(VoiceRecordingOverlay, { voice: voice }), _jsxs("div", { "data-agent-composer-variant": layoutVariant, "data-agent-composer-slot": "toolbar", className: "agent-composer-toolbar flex items-center gap-1 px-2 py-1.5", children: [!disabled && attachmentsEnabled && attachButton ? (attachButton) : contextMenuItems !== undefined || plusMenuMode === "upload-only" ? (_jsx(ComposerContextMenu, { items: contextMenuItems ?? [], addAttachment: attachmentsEnabled ? addAttachmentForCurrentScope : undefined, onAttachmentRequest: onAttachmentRequest, attachmentAccept: composerRuntime.getState().attachmentAccept, onAttachmentError: onAttachmentError, onDisabledFocus: () => {
2644
+ }), className: "ms-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground", children: _jsx(IconX, { className: "h-3 w-3" }) })) : null] }, item.key)))] })), contextSubmissionError ? (_jsx("p", { role: "alert", className: "px-2 text-xs text-destructive", children: contextSubmissionError })) : null, _jsx("div", { "data-agent-composer-variant": layoutVariant, "data-agent-composer-slot": "editor-wrap", className: `agent-composer-editor-wrap ${composerMode || hasContextRows ? "px-2 pt-1 pb-1" : "px-2 pt-2 pb-1"}`, children: _jsx(EditorContent, { editor: editor, "data-agent-composer-variant": layoutVariant, "data-agent-composer-slot": "editor", className: "agent-composer-editor aui-composer flex-1 min-w-0 [&_.ProseMirror]:outline-none [&_.ProseMirror_p]:m-0 px-0.5" }) }), voiceEnabled && _jsx(VoiceRecordingOverlay, { voice: voice }), _jsxs("div", { "data-agent-composer-variant": layoutVariant, "data-agent-composer-slot": "toolbar", className: "agent-composer-toolbar flex items-center gap-1 px-2 py-1.5", children: [!disabled && attachmentsEnabled && attachButton ? (attachButton) : contextMenuItems !== undefined || plusMenuMode === "upload-only" ? (_jsx(ComposerContextMenu, { items: contextMenuItems ?? [], addAttachment: attachmentsEnabled ? addAttachmentForCurrentScope : undefined, onAttachmentRequest: onAttachmentRequest, contextButtonTooltipDisabled: contextButtonTooltipDisabled, attachmentAccept: composerRuntime.getState().attachmentAccept, onAttachmentError: onAttachmentError, onDisabledFocus: () => {
2546
2645
  const root = editor?.view.dom.closest('[data-agent-composer-slot="root"]');
2547
2646
  const localTarget = Array.from(root?.querySelectorAll('[data-agent-composer-slot="stop-button"]:not(:disabled), button:not(:disabled)') ?? []).find((element) => !element.closest("[hidden]"));
2548
2647
  if (localTarget) {