@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.
- package/dist/composer/ComposerContextMenu.d.ts +2 -1
- package/dist/composer/ComposerContextMenu.d.ts.map +1 -1
- package/dist/composer/ComposerContextMenu.js +7 -2
- package/dist/composer/ComposerContextMenu.js.map +1 -1
- package/dist/composer/ComposerContextMenu.spec.js +9 -0
- package/dist/composer/ComposerContextMenu.spec.js.map +1 -1
- package/dist/composer/ComposerPlusMenu.js +1 -1
- package/dist/composer/ComposerPlusMenu.js.map +1 -1
- package/dist/composer/PromptComposer.context.spec.js +34 -0
- package/dist/composer/PromptComposer.context.spec.js.map +1 -1
- package/dist/composer/PromptComposer.d.ts +85 -1
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +39 -4
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +136 -1
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +101 -2
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/package.json +1 -1
- package/src/composer/ComposerContextMenu.spec.tsx +15 -0
- package/src/composer/ComposerContextMenu.tsx +10 -1
- package/src/composer/ComposerPlusMenu.tsx +2 -2
- package/src/composer/PromptComposer.context.spec.tsx +48 -0
- package/src/composer/PromptComposer.tsx +131 -0
- package/src/composer/TiptapComposer.tsx +221 -0
|
@@ -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) {
|