@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
|
@@ -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={() => {
|