@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.
@@ -103,8 +103,12 @@ import { useVoiceDictation } from "./useVoiceDictation.js";
103
103
  import { VoiceButton, VoiceRecordingOverlay } from "./VoiceButton.js";
104
104
  export interface TiptapComposerHandle {
105
105
  focus(): void;
106
+ /** Add a file through the same attachment pipeline as paste and drop. */
107
+ addAttachment(file: File): Promise<unknown>;
108
+ /** Insert text through the editor's normal input path. */
106
109
  insertText(text: string): void;
107
110
  setText(text: string): void;
111
+ /** Submit replacement text with the current attachments and context, without editing the draft on failure. */
108
112
  submitWithText(text: string): Promise<boolean>;
109
113
  insertReference(ref: AgentComposerReference): void;
110
114
  }
@@ -765,59 +769,112 @@ function ComposerModeChip({
765
769
  type ExecMode = "build" | "plan";
766
770
 
767
771
  export interface ComposerAgentOption {
772
+ /** Stable host-defined identifier for the agent runtime. */
768
773
  id: string;
774
+ /** Human-readable runtime name shown in the picker. */
769
775
  label: string;
776
+ /** Optional icon shown beside the runtime name. */
770
777
  icon?: React.ReactNode;
778
+ /** Optional short detail shown below the runtime name. */
771
779
  description?: string;
780
+ /** Whether this runtime can be selected right now. */
772
781
  configured?: boolean;
782
+ /** Optional status text such as "Installed" or "Sign in". */
773
783
  statusLabel?: string;
774
784
  }
775
785
 
776
786
  export interface TiptapComposerProps {
777
787
  placeholder?: string;
788
+ /** Accessible name for the editable prompt surface. */
778
789
  ariaLabel?: string;
779
790
  disabled?: boolean;
791
+ /** Prevent submission without making the editable surface lose focus. */
780
792
  submissionDisabled?: boolean;
793
+ /** Prevent submission while a host request is in flight. */
781
794
  submitting?: boolean;
795
+ /** Override the generic document attachment cap for a multipart host. */
782
796
  maxDocumentAttachmentBytes?: number;
797
+ /** Disable file attachments while keeping text chat available. */
783
798
  attachmentsEnabled?: boolean;
784
799
  onAttachmentRequest?: () => void;
800
+ contextButtonTooltipDisabled?: boolean;
801
+ /** Label used in the visible document attachment limit error. */
785
802
  documentAttachmentLimitLabel?: string;
786
803
  focusRef?: React.Ref<TiptapComposerHandle>;
804
+ /** Programmatically seed the editor with plain text. */
787
805
  initialText?: string;
806
+ /** Stable key used to re-apply the seeded text. */
788
807
  initialTextKey?: string | number;
808
+ /**
809
+ * When provided, called instead of composerRuntime.send(). Used for queue
810
+ * mode and standalone prompt popovers. Receives the live composer
811
+ * attachments so callers (e.g. PromptComposer) can surface uploaded files.
812
+ */
789
813
  onSubmit?: (
790
814
  text: string,
791
815
  references: Reference[],
792
816
  attachments?: ReadonlyArray<unknown>,
793
817
  options?: TiptapComposerSubmitOptions,
794
818
  ) => void | Promise<void>;
819
+ /** Return false to stop a submit before it enters the chat runtime. */
795
820
  onBeforeSubmit?: () => boolean | Promise<boolean>;
821
+ /**
822
+ * Clear the editor after an onSubmit handler runs. Standalone workflows that
823
+ * may fail outside the composer can keep the draft visible for quick edits.
824
+ */
796
825
  clearOnSubmit?: boolean;
826
+ /** Called whenever the plain editor text changes. */
797
827
  onTextChange?: (text: string) => void;
828
+ /** Custom action button (e.g. stop button) to render instead of the default send button. */
798
829
  actionButton?: React.ReactNode;
830
+ /** Whether the default send action will wait behind existing work. */
799
831
  willQueue?: boolean;
832
+ /** Extra button to render alongside the primary action. */
800
833
  extraActionButton?: React.ReactNode;
834
+ /**
835
+ * Stop control shown instead of the disabled send button while the composer
836
+ * has no sendable content. Typing or attaching content restores send.
837
+ */
801
838
  stopButton?: React.ReactNode;
839
+ /** Custom attachment button to render instead of ComposerPrimitive.AddAttachment. */
802
840
  attachButton?: React.ReactNode;
841
+ /** Custom host-owned control rendered next to the attachment affordance. */
803
842
  modeControl?: React.ReactNode;
843
+ /** Explicit host-owned toolbar slot rendered next to the attachment affordance. */
804
844
  toolbarSlot?: React.ReactNode;
845
+ /** Shared sizing/layout variant for host surfaces. Default keeps sidebar behavior. */
805
846
  layoutVariant?: AgentComposerLayoutVariant;
847
+ /** Additional slash commands surfaced in the shared / menu. */
806
848
  slashCommands?: SlashCommand[];
849
+ /** Additional slash skills surfaced in the shared / menu. */
807
850
  slashSkills?: SkillResult[];
851
+ /** Include built-in sidebar slash commands when onSlashCommand is provided. */
808
852
  includeDefaultSlashCommands?: boolean;
853
+ /** Include app-discovered skills from the default agent endpoint. Default true. */
809
854
  includeDefaultSlashSkills?: boolean;
855
+ /** Called when a slash command (e.g. /clear, /help) is executed */
810
856
  onSlashCommand?: (command: string) => void;
857
+ /** Current execution mode (build/plan) */
811
858
  execMode?: ExecMode;
859
+ /** Callback to change execution mode */
812
860
  onExecModeChange?: (mode: ExecMode) => void;
861
+ /** Disable Plan mode while leaving Act mode available. */
813
862
  planModeDisabled?: boolean;
863
+ /** Explanation shown next to the disabled Plan option. */
814
864
  planModeDisabledReason?: string;
865
+ /** Show the microphone button for voice dictation. Defaults to DEFAULT_VOICE_DICTATION_ENABLED. */
815
866
  voiceEnabled?: boolean;
867
+ /** Selected model override for this conversation */
816
868
  selectedModel?: string;
869
+ /** Selected provider engine for this conversation */
817
870
  selectedEngine?: string;
871
+ /** Selected effort override for this conversation */
818
872
  selectedEffort?: ReasoningEffort;
873
+ /** Show the legacy provider-level Auto model option (default: true). */
819
874
  showAutoModelOption?: boolean;
875
+ /** Controlled open state for hosts that resize around the model picker. */
820
876
  modelSelectorOpen?: boolean;
877
+ /** Available models grouped by provider */
821
878
  availableModels?: Array<{
822
879
  engine: string;
823
880
  label: string;
@@ -826,29 +883,81 @@ export interface TiptapComposerProps {
826
883
  statusLabel?: string;
827
884
  isSubscription?: boolean;
828
885
  }>;
886
+ /** Whether the model list is still being resolved. */
829
887
  modelListLoading?: boolean;
888
+ /** Callback when user picks a model */
830
889
  onModelChange?: (model: string, engine: string) => void;
890
+ /** Callback when user picks an effort */
831
891
  onEffortChange?: (effort: ReasoningEffort) => void;
892
+ /** Local or hosted agent runtimes shown above the model list. */
832
893
  availableAgents?: ComposerAgentOption[];
894
+ /** Selected agent runtime identifier. Defaults to the built-in agent. */
833
895
  selectedAgent?: string;
896
+ /** Show only the selected agent in the model control. */
834
897
  agentOnly?: boolean;
898
+ /** Mark the selected runtime as the hosted tools-only harness mode. */
835
899
  hostedHarness?: boolean;
900
+ /** Callback when the user picks an agent runtime. */
836
901
  onAgentChange?: (agent: string) => void;
902
+ /** Called when the shared model picker opens or closes. */
837
903
  onModelSelectorOpenChange?: (open: boolean) => void;
904
+ /**
905
+ * Disable Builder/provider status polling for hosts that supply provider
906
+ * state through another channel, such as Electron IPC.
907
+ */
838
908
  providerConnectStatusEnabled?: boolean;
909
+ /**
910
+ * Override the Builder.io connect action in the model picker. When provided,
911
+ * clicking "Connect Builder.io" calls this instead of opening a browser popup.
912
+ * Used by the Electron desktop app to route through the native IPC handler.
913
+ */
839
914
  onConnectProvider?: () => void;
915
+ /** Route local runtime setup through the host's native bridge. */
840
916
  onConnectLocalRuntime?: (engine: string) => void;
917
+ /**
918
+ * Optional secondary model menu (e.g. an image-generation model) rendered as
919
+ * an extra section inside the model picker. Opt-in; omit for chat-only apps.
920
+ */
841
921
  imageModelMenu?: ComposerImageModelMenu;
922
+ /** Stable scope for persisted drafts, usually the active thread or tab id. */
842
923
  draftScope?: string;
924
+ /** Keyed context nuggets staged for the next submitted prompt. */
843
925
  contextItems?: readonly AgentChatContextItem[];
926
+ /** Remove a staged context nugget by key. */
844
927
  onRemoveContextItem?: (key: string) => void;
845
928
  onInspectContextItem?: (key: string) => void;
846
929
  onRetryContextItem?: (key: string) => void;
847
930
  contextMenuItems?: readonly ComposerContextMenuItem[];
931
+ /**
932
+ * Controls the "+" menu next to the composer. `"full"` (default) shows the
933
+ * normal Upload / Skill / Job / Automation / MCP picker, plus Extension when
934
+ * `extensionTools` is true. `"upload-only"` collapses it to a single button
935
+ * that opens the file picker directly. `"hidden"` hides attachment controls
936
+ * for text-only prompt surfaces.
937
+ */
848
938
  plusMenuMode?: "full" | "upload-only" | "terminal" | "hidden";
939
+ /** Controls the terminal-specific plus menu when `plusMenuMode` is terminal. */
849
940
  terminalModeControl?: ComposerTerminalModeControl;
941
+ /**
942
+ * Include extension creation in the full "+" menu. Defaults to false so
943
+ * apps opt into the extension capability deliberately.
944
+ */
850
945
  extensionTools?: boolean;
946
+ /**
947
+ * When true and the composer is running inside the Builder.io webview/iframe,
948
+ * intercept "build me an app/agent" prompts and forward them to the parent
949
+ * Builder chat via `builder.submitChat` instead of sending to the local
950
+ * agent. Off by default — the chat sidebar opts in; standalone prompt
951
+ * forms (NewWorkspaceAppFlow, etc.) handle delegation themselves with
952
+ * extra context (vault keys, computed app ids) that the raw composer
953
+ * text lacks.
954
+ */
851
955
  interceptBuildRequestsForBuilder?: boolean;
956
+ /**
957
+ * Called when a drag-drop or paste attachment fails (e.g. unsupported format,
958
+ * size cap). Use this to surface a visible error in the parent chat surface
959
+ * rather than silently swallowing the problem.
960
+ */
852
961
  onAttachmentError?: (message: string) => void;
853
962
  }
854
963
 
@@ -863,6 +972,7 @@ function plainTextToDoc(text: string) {
863
972
  };
864
973
  }
865
974
 
975
+ /** Tiptap keeps the Editor object truthy after destroy but clears commandManager. */
866
976
  export function isComposerEditorUsable<T extends { isDestroyed?: boolean }>(
867
977
  editor: T | null | undefined,
868
978
  ): editor is T {
@@ -1110,6 +1220,11 @@ export function shouldShowModelSelectorSkeleton(
1110
1220
  return isLoading && engineCount === 0;
1111
1221
  }
1112
1222
 
1223
+ /**
1224
+ * With nothing connected, every family is a dead "needs API key" row, so the
1225
+ * picker shows only the connect CTAs. Never hide the list unless a CTA is
1226
+ * there to replace it — an empty popover reads as more broken, not less.
1227
+ */
1113
1228
  export function shouldShowOnlyConnectPath(
1114
1229
  showBuilderCta: boolean,
1115
1230
  groups: ReadonlyArray<{ configured: boolean }>,
@@ -1117,6 +1232,15 @@ export function shouldShowOnlyConnectPath(
1117
1232
  return showBuilderCta && groups.every((group) => !group.configured);
1118
1233
  }
1119
1234
 
1235
+ /**
1236
+ * When nothing is routable yet, the model hook resolves `selectedModel` to
1237
+ * `""` rather than pre-selecting something unusable — that reflects "nothing
1238
+ * chosen," not "nothing to show." The picker itself still has a job to do in
1239
+ * that state (its connect-provider CTAs). During the initial discovery window
1240
+ * the list is empty too, but the button still needs to exist so the picker can
1241
+ * reveal its loading or setup state instead of making the composer look
1242
+ * incomplete.
1243
+ */
1120
1244
  export function shouldRenderModelSelector(
1121
1245
  availableModels: ReadonlyArray<unknown> | undefined,
1122
1246
  onModelChange: unknown,
@@ -1134,6 +1258,7 @@ function friendlyModelName(model: string, t?: ComposerTranslate): string {
1134
1258
  }
1135
1259
  if (FRIENDLY_MODEL_NAMES[model]) return FRIENDLY_MODEL_NAMES[model];
1136
1260
  const normalizedModel = model.replace(/^(?:anthropic|openai|google)\//, "");
1261
+ // Claude: claude-{tier}-{major}[-minor][-dateYYYYMMDD].
1137
1262
  const claude = normalizedModel.match(
1138
1263
  /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:[-.](\d+))?(?:-\d{8,})?$/,
1139
1264
  );
@@ -1141,6 +1266,7 @@ function friendlyModelName(model: string, t?: ComposerTranslate): string {
1141
1266
  const tier = claude[1][0].toUpperCase() + claude[1].slice(1);
1142
1267
  return `Claude ${tier} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
1143
1268
  }
1269
+ // GPT: gpt-{major}[-minor][-variant] → GPT-Major[.Minor] Variant.
1144
1270
  const gpt = normalizedModel.match(/^gpt-(\d+)(?:[.-](\d+))?(?:[.-](.+))?$/);
1145
1271
  if (gpt) {
1146
1272
  const version = `${gpt[1]}${gpt[2] ? `.${gpt[2]}` : ""}`;
@@ -1151,6 +1277,7 @@ function friendlyModelName(model: string, t?: ComposerTranslate): string {
1151
1277
  return `GPT-${version}${variant ? ` ${variant}` : ""}`;
1152
1278
  }
1153
1279
  if (/^o\d/.test(normalizedModel)) return normalizedModel;
1280
+ // Gemini: gemini-{version.parts}-{variant}[-preview] → Gemini Version Variant.
1154
1281
  const geminiVersioned = normalizedModel.match(
1155
1282
  /^gemini-(\d+(?:[-.]\d+)+)-(.+?)(?:-preview)?$/,
1156
1283
  );
@@ -1162,6 +1289,7 @@ function friendlyModelName(model: string, t?: ComposerTranslate): string {
1162
1289
  const version = geminiVersioned[1].replace(/-/g, ".");
1163
1290
  return `Gemini ${version} ${variant}`.replace("Flash Lite", "Flash-Lite");
1164
1291
  }
1292
+ // Gemini: gemini-{version.parts}[-preview] → Gemini Version Parts
1165
1293
  const gemini = normalizedModel.match(/^gemini-(.+?)(?:-preview)?$/);
1166
1294
  if (gemini) {
1167
1295
  const parts = gemini[1]
@@ -1289,6 +1417,7 @@ function compareModelVersions(
1289
1417
  return 0;
1290
1418
  }
1291
1419
 
1420
+ /** Keep the newest version of each Claude, Gemini, and GPT tier. */
1292
1421
  function latestModelsOnly(models: readonly string[]): string[] {
1293
1422
  const latest = new Map<string, { id: string; version: number[] }>();
1294
1423
  for (const id of models) {
@@ -1306,6 +1435,15 @@ function latestModelsOnly(models: readonly string[]): string[] {
1306
1435
  return models.filter((id) => !versionedModelFamily(id) || latestIds.has(id));
1307
1436
  }
1308
1437
 
1438
+ /**
1439
+ * Coarse relative cost per model, rendered as a quiet `$`…`$$$` suffix.
1440
+ *
1441
+ * Tokens and their order mirror `MODEL_COST_ORDER` in `@agent-native/core`'s
1442
+ * chat-model-groups, which sorts these same rows — the toolkit cannot import
1443
+ * from core, so a new model family has to be added in both places. Tiers are
1444
+ * each provider's own entry/mid/flagship ladder, not a cross-provider price
1445
+ * claim; anything unlisted has no tier rather than a guessed one.
1446
+ */
1309
1447
  const MODEL_COST_TIERS: ReadonlyArray<readonly [string, 1 | 2 | 3]> = [
1310
1448
  ["luna", 1],
1311
1449
  ["terra", 2],
@@ -1336,10 +1474,21 @@ function ModelCostTier({ model }: { model: string }) {
1336
1474
  return <span className="sr-only">{costLabel}</span>;
1337
1475
  }
1338
1476
 
1477
+ /**
1478
+ * Optional secondary model menu for apps that drive a separate generation model
1479
+ * alongside the chat LLM (e.g. the Assets app's image-generation model). When
1480
+ * provided, the model picker renders an extra collapsible section so the user
1481
+ * can see and pick both "what reasons about my request" (the chat model) and
1482
+ * "what produces the output" (this model). Opt-in — omit it and nothing changes.
1483
+ */
1339
1484
  export interface ComposerImageModelMenu {
1485
+ /** Currently-selected model id for this secondary menu. */
1340
1486
  value: string;
1487
+ /** Selectable options (stable id + human label). */
1341
1488
  options: Array<{ value: string; label: string }>;
1489
+ /** Invoked when the user picks a different option. */
1342
1490
  onChange: (value: string) => void;
1491
+ /** Section header. Defaults to "Image model". */
1343
1492
  label?: string;
1344
1493
  }
1345
1494
 
@@ -1522,6 +1671,8 @@ function ModelSelector({
1522
1671
  engines.length,
1523
1672
  );
1524
1673
 
1674
+ // Keep setup actions visible, but do not show unusable model rows until one
1675
+ // provider or local agent is ready.
1525
1676
  const builderFlow = adapters.builder!.useConnectFlow!({
1526
1677
  enabled: providerConnectStatusEnabled,
1527
1678
  provisionAccount: true,
@@ -2329,6 +2480,7 @@ export function TiptapComposer({
2329
2480
  documentAttachmentLimitLabel = "PDFs",
2330
2481
  attachmentsEnabled = true,
2331
2482
  onAttachmentRequest,
2483
+ contextButtonTooltipDisabled = false,
2332
2484
  focusRef,
2333
2485
  initialText,
2334
2486
  initialTextKey,
@@ -2432,6 +2584,7 @@ export function TiptapComposer({
2432
2584
  typeof navigator !== "undefined" &&
2433
2585
  /Mac|iPhone|iPad/.test(navigator.userAgent);
2434
2586
 
2587
+ // Refs for values accessed in handleKeyDown (ProseMirror doesn't re-bind)
2435
2588
  const popoverStateRef = useRef<PopoverState>(null);
2436
2589
  const onAttachmentErrorRef = useRef(onAttachmentError);
2437
2590
  onAttachmentErrorRef.current = onAttachmentError;
@@ -2458,6 +2611,7 @@ export function TiptapComposer({
2458
2611
  } = useSkills(includeDefaultSlashSkills && popover?.type === "/");
2459
2612
 
2460
2613
  const allSlashCommands = useMemo(() => {
2614
+ // A command without a host callback would be deleted as an invisible no-op.
2461
2615
  if (!onSlashCommand) return [];
2462
2616
  return mergeSlashCommands([
2463
2617
  ...(includeDefaultSlashCommands ? builtInCommands(t) : []),
@@ -2496,6 +2650,7 @@ export function TiptapComposer({
2496
2650
  );
2497
2651
  }, [allSlashSkills, popover]);
2498
2652
 
2653
+ // Keep refs in sync with state
2499
2654
  const mentionItemsRef = useRef(filteredMentionItems);
2500
2655
  mentionItemsRef.current = filteredMentionItems;
2501
2656
  const filteredCommandsRef = useRef(filteredCommands);
@@ -2545,6 +2700,7 @@ export function TiptapComposer({
2545
2700
  popoverStateRef.current = null;
2546
2701
  }, []);
2547
2702
 
2703
+ // Persist draft to localStorage so refreshes don't lose the prompt.
2548
2704
  const hasDraftScope = Boolean(draftScope?.trim());
2549
2705
  const draftKey =
2550
2706
  hasDraftScope || initialText === undefined
@@ -2601,6 +2757,8 @@ export function TiptapComposer({
2601
2757
  useEffect(() => {
2602
2758
  lastComposerRuntimeSyncRef.current = null;
2603
2759
  }, [composerRuntime]);
2760
+ // Tiptap reads extension config once at init; ref keeps runtime prop
2761
+ // changes visible to Placeholder's function form.
2604
2762
  const resolvedPlaceholder = composerMode
2605
2763
  ? localizedComposerModeConfig(composerMode, t).placeholder
2606
2764
  : (placeholder ??
@@ -2614,6 +2772,8 @@ export function TiptapComposer({
2614
2772
  extensions: createTiptapComposerExtensions(() => placeholderRef.current),
2615
2773
  editable: !disabled,
2616
2774
  onUpdate: ({ editor: ed }) => {
2775
+ // Drive the send button's enabled state from the actual editor contents;
2776
+ // the composer runtime is only synced on submit, so its isEmpty lags.
2617
2777
  setEditorHasText(composerDocumentHasContent(ed.state.doc));
2618
2778
  onTextChangeRef.current?.(ed.state.doc.textContent.trim());
2619
2779
 
@@ -2663,10 +2823,18 @@ export function TiptapComposer({
2663
2823
  if (files.length > 0) {
2664
2824
  event.preventDefault();
2665
2825
  const attachments: File[] = files.map((file) => {
2826
+ // SimpleImageAttachmentAdapter uses file.name as the attachment id.
2827
+ // Clipboard images (e.g. screenshots) are typically all named
2828
+ // "image.png", so a second paste would replace the first instead of
2829
+ // appending. Prepend a unique token so each paste gets a distinct id.
2666
2830
  const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2)}-${file.name}`;
2667
2831
  return new File([file], uniqueName, { type: file.type });
2668
2832
  });
2669
2833
 
2834
+ // Google Docs rich clipboard payloads can contain both embedded
2835
+ // image files and the document text. Since handling files means we
2836
+ // prevent Tiptap's default paste, preserve any text as its own chip
2837
+ // instead of silently dropping the source material.
2670
2838
  if (pastedText.trim()) {
2671
2839
  attachments.push(createPastedAttachmentFile(paste));
2672
2840
  }
@@ -2686,6 +2854,13 @@ export function TiptapComposer({
2686
2854
  return true;
2687
2855
  }
2688
2856
 
2857
+ // Page-sized pastes turn into a `Pasted text` attachment chip so the
2858
+ // prompt stays readable while normal paragraphs and lists stay inline.
2859
+ // When the paste is HTML (e.g. an Alpine.js extension or a document the
2860
+ // user wants hosted), it's stored as a real .html attachment so it
2861
+ // travels the same rail as uploading that file — the agent reads it
2862
+ // verbatim via contentFromAttachment instead of retyping it inline,
2863
+ // which cuts off mid-stream on large files and triggers a spin.
2689
2864
  if (shouldConvertClipboardToAttachment(paste)) {
2690
2865
  event.preventDefault();
2691
2866
  void addAttachmentForCurrentScope(
@@ -2712,6 +2887,9 @@ export function TiptapComposer({
2712
2887
  }
2713
2888
  return false;
2714
2889
  }
2890
+ // Drag-and-drop files (decks, images, PDFs, etc.) into the composer.
2891
+ // Mark handled drops as consumed so the chat-wide drop target does not
2892
+ // add the same file a second time.
2715
2893
  return handleComposerFileDrop({
2716
2894
  event: event as DragEvent,
2717
2895
  addAttachment: addAttachmentForCurrentScope,
@@ -2731,6 +2909,7 @@ export function TiptapComposer({
2731
2909
  handleKeyDown: (view, event) => {
2732
2910
  const pop = popoverStateRef.current;
2733
2911
 
2912
+ // Handle popover keyboard nav
2734
2913
  if (pop) {
2735
2914
  if (event.key === "ArrowUp") {
2736
2915
  event.preventDefault();
@@ -2804,6 +2983,7 @@ export function TiptapComposer({
2804
2983
  setSelectedContextItemKey(null);
2805
2984
  }
2806
2985
 
2986
+ // Backspace removes composer mode chip when editor is empty
2807
2987
  if (event.key === "Backspace" && composerModeRef.current) {
2808
2988
  if (
2809
2989
  view.state.doc.textContent.trim() === "" &&
@@ -2816,6 +2996,7 @@ export function TiptapComposer({
2816
2996
  }
2817
2997
  }
2818
2998
 
2999
+ // Keyboard shortcut toggles Act/Plan mode from inside the editor.
2819
3000
  if (event.key === "Tab" && event.shiftKey) {
2820
3001
  event.preventDefault();
2821
3002
  const current = execModeRef.current;
@@ -2829,6 +3010,9 @@ export function TiptapComposer({
2829
3010
  return true;
2830
3011
  }
2831
3012
 
3013
+ // Submit on Enter. Shift+Enter inserts a newline and keeps the
3014
+ // composer scrolled to the caret.
3015
+ // Cmd+Enter on macOS / Ctrl+Enter elsewhere marks the submit queued.
2832
3016
  if (event.key === "Enter" && event.shiftKey) {
2833
3017
  event.preventDefault();
2834
3018
  return insertComposerHardBreakAndScrollIntoView(view);
@@ -2841,6 +3025,8 @@ export function TiptapComposer({
2841
3025
  return true;
2842
3026
  }
2843
3027
 
3028
+ // Detect @ trigger — only when preceded by start-of-text, space, or newline
3029
+ // (not after alphanumeric chars, which would indicate an email address)
2844
3030
  if (event.key === "@") {
2845
3031
  const { from } = view.state.selection;
2846
3032
  const textBefore = view.state.doc.textBetween(
@@ -2864,6 +3050,7 @@ export function TiptapComposer({
2864
3050
  return false;
2865
3051
  }
2866
3052
 
3053
+ // Detect / trigger (only at start of line or after whitespace)
2867
3054
  if (event.key === "/") {
2868
3055
  const { from } = view.state.selection;
2869
3056
  const textBefore = view.state.doc.textBetween(
@@ -2910,11 +3097,16 @@ export function TiptapComposer({
2910
3097
  };
2911
3098
  }, [cancelScheduledDraftPersist, draftKey, editor]);
2912
3099
 
3100
+ // Placeholder decorations are computed by ProseMirror. Dispatching an empty
3101
+ // transaction makes a locale or composer-mode change visible immediately.
2913
3102
  useEffect(() => {
2914
3103
  if (!isComposerEditorUsable(editor)) return;
2915
3104
  editor.view.dispatch(editor.state.tr.setSelection(editor.state.selection));
2916
3105
  }, [editor, resolvedPlaceholder]);
2917
3106
 
3107
+ // A tab can stay mounted while becoming the active composer later. Publish
3108
+ // its existing draft when the host starts observing it so contextual UI is
3109
+ // correct immediately after a tab switch, not only after the next keystroke.
2918
3110
  useEffect(() => {
2919
3111
  if (!isComposerEditorUsable(editor) || !onTextChange) return;
2920
3112
  onTextChange(editor.state.doc.textContent.trim());
@@ -3038,6 +3230,9 @@ export function TiptapComposer({
3038
3230
  focus() {
3039
3231
  if (isComposerEditorUsable(editor)) editor.commands.focus("end");
3040
3232
  },
3233
+ addAttachment(file: File) {
3234
+ return addAttachmentForCurrentScope(file);
3235
+ },
3041
3236
  insertText(text: string) {
3042
3237
  if (!isComposerEditorUsable(editor)) return;
3043
3238
  editor.commands.setContent(plainTextToDoc(""), { emitUpdate: false });
@@ -3072,6 +3267,7 @@ export function TiptapComposer({
3072
3267
  [editor],
3073
3268
  );
3074
3269
 
3270
+ // --- Live voice transcription: text appears in the editor as the user speaks ---
3075
3271
  const voiceAnchorRef = useRef<number | null>(null);
3076
3272
  const prevVoiceInsertRef = useRef("");
3077
3273
 
@@ -3225,6 +3421,7 @@ export function TiptapComposer({
3225
3421
  const voiceCancelRef = useRef(voice.cancel);
3226
3422
  voiceCancelRef.current = voice.cancel;
3227
3423
 
3424
+ // Clean up live text if voice session ends without a final transcript (cancel/error)
3228
3425
  useEffect(() => {
3229
3426
  if (voice.state === "idle" && voiceAnchorRef.current != null) {
3230
3427
  const anchor = voiceAnchorRef.current;
@@ -3254,9 +3451,13 @@ export function TiptapComposer({
3254
3451
  }
3255
3452
  }, [voice.state, editor]);
3256
3453
 
3454
+ // Global shortcut: Cmd/Ctrl + Shift + M toggles dictation. Escape cancels
3455
+ // while recording. Scoped to avoid firing when focus is outside the app.
3257
3456
  useEffect(() => {
3258
3457
  if (!voiceEnabled || !voice.supported) return;
3259
3458
  const handler = (e: KeyboardEvent) => {
3459
+ // e.key can be undefined on some trusted keydown events (autofill/IME
3460
+ // quirks) — seen crashing in production (AGENT-NATIVE-BROWSER-S).
3260
3461
  const isToggleCombo =
3261
3462
  typeof e.key === "string" &&
3262
3463
  e.key.toLowerCase() === "m" &&
@@ -3297,6 +3498,8 @@ export function TiptapComposer({
3297
3498
  referenceFromComposerReference,
3298
3499
  );
3299
3500
 
3501
+ // Build text that preserves @mentions (getText() strips them).
3502
+ // Walk the document and reconstruct with @name for mention/file/skill nodes.
3300
3503
  const textParts: string[] = [];
3301
3504
  ed.state.doc.descendants((node: any) => {
3302
3505
  if (node.isText) {
@@ -3325,6 +3528,7 @@ export function TiptapComposer({
3325
3528
 
3326
3529
  ed.state.doc.descendants((node: any) => {
3327
3530
  if (node.type.name === "fileReference") {
3531
+ // Legacy support
3328
3532
  references.push({
3329
3533
  type: "file",
3330
3534
  path: node.attrs.path,
@@ -3521,6 +3725,7 @@ export function TiptapComposer({
3521
3725
  }
3522
3726
  };
3523
3727
 
3728
+ // Intercept slash commands typed directly (e.g. "/clear" + Enter)
3524
3729
  const trimmed = text.trim();
3525
3730
  if (trimmed.startsWith("/") && references.length === 0) {
3526
3731
  const cmdName = normalizeSlashCommandName(trimmed);
@@ -3532,6 +3737,12 @@ export function TiptapComposer({
3532
3737
  }
3533
3738
  }
3534
3739
 
3740
+ // Builder iframe delegation: when this app is mounted inside the
3741
+ // Builder.io webview and the user typed a "build me an app/agent"
3742
+ // prompt, hand it up to the parent Builder chat instead of sending
3743
+ // it to this app's domain agent. Builder is the code-writing agent;
3744
+ // the local agent (dispatch, mail, etc.) cannot scaffold workspace
3745
+ // apps from inside its own iframe.
3535
3746
  if (
3536
3747
  !composerMode &&
3537
3748
  interceptBuildRequestsForBuilder &&
@@ -3554,6 +3765,7 @@ export function TiptapComposer({
3554
3765
  if (!isComposerEditorUsable(ed)) return false;
3555
3766
  if (!isCurrentDraftScope()) return false;
3556
3767
 
3768
+ // Composer mode: send with context via agent chat bridge
3557
3769
  if (composerMode) {
3558
3770
  const config = localizedComposerModeConfig(composerMode, t);
3559
3771
  config.beforeSend?.();
@@ -3636,6 +3848,7 @@ export function TiptapComposer({
3636
3848
  submitInFlightRef.current = false;
3637
3849
  }
3638
3850
  if (!isCurrentDraftScope()) return true;
3851
+ // Clear any pending attachments now that the host has them.
3639
3852
  void composerRuntime.clearAttachments().catch(() => {});
3640
3853
  if (!clearOnSubmit) {
3641
3854
  closePopover();
@@ -3678,6 +3891,8 @@ export function TiptapComposer({
3678
3891
  ],
3679
3892
  );
3680
3893
 
3894
+ // Helper functions that operate on the editor view directly
3895
+ // These are called from handleKeyDown which can't use React state
3681
3896
  function selectMention(
3682
3897
  _view: any,
3683
3898
  pop: NonNullable<PopoverState>,
@@ -3686,6 +3901,7 @@ export function TiptapComposer({
3686
3901
  const ed = editor;
3687
3902
  if (!isComposerEditorUsable(ed)) return;
3688
3903
  const currentPos = ed.state.selection.from;
3904
+ // startPos is after the trigger char, so -1 to include the @ or /
3689
3905
  const deleteFrom = Math.max(0, pop.startPos - 1);
3690
3906
  ed.chain().focus().deleteRange({ from: deleteFrom, to: currentPos }).run();
3691
3907
  insertReference(composerReferenceFromMentionItem(item));
@@ -3730,6 +3946,7 @@ export function TiptapComposer({
3730
3946
  setPopover(null);
3731
3947
  }
3732
3948
 
3949
+ // Popover select handlers for click-based selection (from MentionPopover)
3733
3950
  const handleSelectMention = useCallback(
3734
3951
  (item: MentionItem) => {
3735
3952
  if (!isComposerEditorUsable(editor) || !popover) return;
@@ -3782,6 +3999,7 @@ export function TiptapComposer({
3782
3999
  [editor, popover, closePopover],
3783
4000
  );
3784
4001
 
4002
+ // Track query text as user types after trigger
3785
4003
  useEffect(() => {
3786
4004
  if (!isComposerEditorUsable(editor) || !popover) return;
3787
4005
 
@@ -3798,6 +4016,7 @@ export function TiptapComposer({
3798
4016
 
3799
4017
  const text = editor.state.doc.textBetween(startPos, from);
3800
4018
 
4019
+ // Verify the trigger character is still there
3801
4020
  if (startPos > 0) {
3802
4021
  const triggerChar = editor.state.doc.textBetween(
3803
4022
  startPos - 1,
@@ -3907,6 +4126,7 @@ export function TiptapComposer({
3907
4126
  scheduleComposerDraftPersist,
3908
4127
  ]);
3909
4128
 
4129
+ // Tiptap only reads `editable` at init; prop changes need setEditable.
3910
4130
  useEffect(() => {
3911
4131
  if (!isComposerEditorUsable(editor)) return;
3912
4132
  editor.setEditable(!disabled);
@@ -4097,6 +4317,7 @@ export function TiptapComposer({
4097
4317
  attachmentsEnabled ? addAttachmentForCurrentScope : undefined
4098
4318
  }
4099
4319
  onAttachmentRequest={onAttachmentRequest}
4320
+ contextButtonTooltipDisabled={contextButtonTooltipDisabled}
4100
4321
  attachmentAccept={composerRuntime.getState().attachmentAccept}
4101
4322
  onAttachmentError={onAttachmentError}
4102
4323
  onDisabledFocus={() => {