@agent-native/toolkit 0.198.4 → 0.198.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/toolkit",
3
- "version": "0.198.4",
3
+ "version": "0.198.5",
4
4
  "description": "Reusable app-building UI and helpers for Agent-Native apps.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -665,7 +665,7 @@
665
665
  "y-protocols": "^1.0.7",
666
666
  "yjs": "^13.6.33",
667
667
  "zod": "^4.3.6",
668
- "@agent-native/agentkit": "^0.198.4"
668
+ "@agent-native/agentkit": "^0.198.5"
669
669
  },
670
670
  "devDependencies": {
671
671
  "@excalidraw/excalidraw": "0.18.1",
@@ -686,7 +686,7 @@
686
686
  "typescript-7": "npm:typescript@^7.0.2",
687
687
  "vite": "8.1.0",
688
688
  "vitest": "^4.1.5",
689
- "@agent-native/core": "^0.198.4"
689
+ "@agent-native/core": "^0.198.5"
690
690
  },
691
691
  "peerDependencies": {
692
692
  "@agent-native/core": ">=0.196.0",
@@ -95,6 +95,97 @@ describe("AgentKitChat interactions", () => {
95
95
  }
96
96
  });
97
97
 
98
+ it("announces async preflight when the transcript slot is custom", async () => {
99
+ const preflight = Promise.withResolvers<boolean>();
100
+ const client = new AgentKitClient({
101
+ transport: {
102
+ async startRun() {
103
+ return { runId: "run-preflight" };
104
+ },
105
+ async *subscribeToRun() {},
106
+ async cancelRun() {},
107
+ },
108
+ });
109
+ const composerRef = createRef<TiptapComposerHandle>();
110
+ const container = document.createElement("div");
111
+ document.body.appendChild(container);
112
+ const root = createRoot(container);
113
+ const actEnvironment = globalThis as typeof globalThis & {
114
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
115
+ };
116
+ const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
117
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
118
+
119
+ try {
120
+ await act(async () => {
121
+ root.render(
122
+ <AgentKitProvider
123
+ controller={client}
124
+ threadId="thread-preflight"
125
+ slots={{
126
+ transcript: ({ children }) =>
127
+ createElement(
128
+ "div",
129
+ { "data-custom-transcript": true },
130
+ children,
131
+ ),
132
+ }}
133
+ >
134
+ <AgentKitChat
135
+ composerProps={{
136
+ composerRef,
137
+ autoFocus: false,
138
+ modelStatusChecksEnabled: false,
139
+ onBeforeSubmit: () => preflight.promise,
140
+ voiceEnabled: false,
141
+ }}
142
+ />
143
+ </AgentKitProvider>,
144
+ );
145
+ });
146
+ await act(async () => composerRef.current!.setText("Check this message"));
147
+
148
+ const editor = container.querySelector<HTMLElement>(
149
+ '[contenteditable="true"]',
150
+ )!;
151
+ const send = container.querySelector<HTMLButtonElement>(
152
+ '[data-agent-composer-slot="send-button"]',
153
+ )!;
154
+ await act(async () => {
155
+ send.click();
156
+ await Promise.resolve();
157
+ });
158
+
159
+ expect(editor.textContent).toBe("");
160
+ expect(container.querySelector('[role="status"]')?.textContent).toBe(
161
+ "Thinking",
162
+ );
163
+ expect(send.disabled).toBe(true);
164
+ expect(send.getAttribute("aria-busy")).toBeNull();
165
+ expect(send.querySelector(".animate-spin")).toBeNull();
166
+
167
+ await act(async () => {
168
+ preflight.resolve(false);
169
+ await preflight.promise;
170
+ });
171
+
172
+ expect(editor.textContent).toBe("Check this message");
173
+ expect(send.disabled).toBe(false);
174
+ expect(container.querySelector('[role="status"]')).toBeNull();
175
+ } finally {
176
+ preflight.resolve(false);
177
+ await act(async () => root.unmount());
178
+ await client.shutdown();
179
+ container.remove();
180
+ localStorage.removeItem(getComposerDraftKey("agentkit:thread-preflight"));
181
+ if (previousActEnvironment === undefined) {
182
+ delete actEnvironment.IS_REACT_ACT_ENVIRONMENT;
183
+ } else {
184
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
185
+ }
186
+ }
187
+ });
188
+
98
189
  it.each(["default", "compact"] as const)(
99
190
  "clears the %s composer before startRun resolves and preserves the next draft",
100
191
  async (layoutVariant) => {
@@ -160,6 +251,8 @@ describe("AgentKitChat interactions", () => {
160
251
  ).toContain("First prompt");
161
252
  expect(client.getThread("thread-latency").activeRunIds).toEqual([]);
162
253
  expect(editor.textContent).toBe("");
254
+ expect(send.getAttribute("aria-busy")).toBeNull();
255
+ expect(send.querySelector(".animate-spin")).toBeNull();
163
256
 
164
257
  await act(async () => composerRef.current!.setText("Next draft"));
165
258
  await act(async () => started.resolve({ runId: "run-latency" }));
@@ -476,8 +476,7 @@ describe("AgentKitChat", () => {
476
476
  expect(source).toContain(
477
477
  "const submissionBlocked = Boolean(submissionDisabled) || command.pending",
478
478
  );
479
- expect(source).toContain("submitting={command.pending}");
480
- expect(source).toContain("submissionDisabled={submissionBlocked}");
479
+ expect(source).not.toContain("submitting={command.pending}");
481
480
  expect(source).toContain("if (disabled)");
482
481
  expect(source).toContain(
483
482
  "willQueue={active && queueWhileRunning && canQueue}",
@@ -3054,6 +3054,7 @@ export interface AgentKitComposerProps extends Omit<
3054
3054
  | "extraActionButton"
3055
3055
  | "onSubmit"
3056
3056
  | "onBeforeSubmit"
3057
+ | "onSubmissionPendingChange"
3057
3058
  | "onAttachmentError"
3058
3059
  | "interceptBuildRequestsForBuilder"
3059
3060
  | "planModeDisabled"
@@ -3068,6 +3069,8 @@ export interface AgentKitComposerProps extends Omit<
3068
3069
  composerRef?: { current: TiptapComposerHandle | null };
3069
3070
  threadId?: string;
3070
3071
  className?: string;
3072
+ /** The host transcript renders its own accessible submission status. */
3073
+ announcePendingSubmission?: boolean;
3071
3074
  queueWhileRunning?: boolean;
3072
3075
  showModelSelector?: boolean;
3073
3076
  /** Controlled execution mode for agent-native act/plan workflows. */
@@ -3087,6 +3090,7 @@ function hasActiveRuns(thread: AgentThreadState): boolean {
3087
3090
  export function AgentKitComposer({
3088
3091
  threadId: requestedThreadId,
3089
3092
  className,
3093
+ announcePendingSubmission = true,
3090
3094
  queueWhileRunning = true,
3091
3095
  showModelSelector = true,
3092
3096
  slashCommands,
@@ -3135,6 +3139,7 @@ export function AgentKitComposer({
3135
3139
  extraActionButton,
3136
3140
  onSubmit: onSubmitOverride,
3137
3141
  onBeforeSubmit,
3142
+ onSubmissionPendingChange,
3138
3143
  onAttachmentError,
3139
3144
  interceptBuildRequestsForBuilder,
3140
3145
  planModeDisabled,
@@ -3191,6 +3196,15 @@ export function AgentKitComposer({
3191
3196
  threadId,
3192
3197
  );
3193
3198
  const submissionBlocked = Boolean(submissionDisabled) || command.pending;
3199
+ const [submissionPending, setSubmissionPending] = useState(false);
3200
+ const handleSubmissionPendingChange = useCallback(
3201
+ (pending: boolean) => {
3202
+ setSubmissionPending(pending);
3203
+ onSubmissionPendingChange?.(pending);
3204
+ },
3205
+ [onSubmissionPendingChange],
3206
+ );
3207
+ const suggestionSubmitBlocked = submissionBlocked || submissionPending;
3194
3208
  const localComposerRef = useRef<TiptapComposerHandle>(null);
3195
3209
  const composerRef = hostComposerRef ?? localComposerRef;
3196
3210
  const selectedSuggestionRef = useRef<
@@ -3513,7 +3527,7 @@ export function AgentKitComposer({
3513
3527
  ) => {
3514
3528
  if (
3515
3529
  disabled ||
3516
- submissionBlocked ||
3530
+ suggestionSubmitBlocked ||
3517
3531
  !suggestionsCapability.enabled ||
3518
3532
  selectedSuggestionRef.current ||
3519
3533
  !isCurrentAgentSuggestion(controller.getThread(threadId), suggestion)
@@ -3536,6 +3550,15 @@ export function AgentKitComposer({
3536
3550
  const suggestions = selectAgentSuggestions(thread);
3537
3551
  return (
3538
3552
  <div className={`agentkit-composer-stack ${className ?? ""}`}>
3553
+ {announcePendingSubmission && submissionPending ? (
3554
+ <span
3555
+ className="agentkit-visually-hidden"
3556
+ role="status"
3557
+ aria-live="polite"
3558
+ >
3559
+ {labels.activityBuckets?.thinking ?? "Thinking"}
3560
+ </span>
3561
+ ) : null}
3539
3562
  {queueCapability.visible ? (
3540
3563
  Queue ? (
3541
3564
  <Queue
@@ -3611,14 +3634,14 @@ export function AgentKitComposer({
3611
3634
  <Suggestions
3612
3635
  suggestions={suggestions}
3613
3636
  threadId={threadId}
3614
- pending={submissionBlocked}
3637
+ pending={suggestionSubmitBlocked}
3615
3638
  onSelect={selectSuggestion}
3616
3639
  />
3617
3640
  ) : (
3618
3641
  <AgentSuggestionBar
3619
3642
  suggestions={suggestions.map((suggestion) => ({
3620
3643
  ...suggestion,
3621
- disabled: submissionBlocked || Boolean(disabled),
3644
+ disabled: suggestionSubmitBlocked || Boolean(disabled),
3622
3645
  }))}
3623
3646
  ariaLabel={labels.suggestions}
3624
3647
  onSelect={selectSuggestion}
@@ -3646,7 +3669,6 @@ export function AgentKitComposer({
3646
3669
  imageModelMenu={imageModelMenu}
3647
3670
  autoFocus={autoFocus}
3648
3671
  composerRef={composerRef}
3649
- submitting={command.pending}
3650
3672
  willQueue={active && queueWhileRunning && canQueue}
3651
3673
  showModelSelector={showModelSelector && canSelectModel}
3652
3674
  availableModels={availableModels}
@@ -3669,8 +3691,11 @@ export function AgentKitComposer({
3669
3691
  initialTextKey={composerInitialTextKey}
3670
3692
  onTextChange={onTextChange}
3671
3693
  extraActionButton={extraActionButton}
3694
+ sendButtonDisabled={submissionPending}
3672
3695
  onBeforeSubmit={onBeforeSubmit}
3696
+ onSubmissionPendingChange={handleSubmissionPendingChange}
3673
3697
  getSubmitFailureDraftScope={getSubmitFailureDraftScope}
3698
+ clearOnSubmitImmediately
3674
3699
  onAttachmentError={reportAttachmentError}
3675
3700
  interceptBuildRequestsForBuilder={interceptBuildRequestsForBuilder}
3676
3701
  planModeDisabled={planModeDisabled}
@@ -317,6 +317,7 @@ describe("AgentKit composer context submission", () => {
317
317
  expect(capture.props).toMatchObject({
318
318
  contextItems: source,
319
319
  contextMenuItems,
320
+ clearOnSubmitImmediately: true,
320
321
  onRemoveContextItem,
321
322
  onInspectContextItem,
322
323
  onRetryContextItem,
@@ -332,6 +333,7 @@ describe("AgentKit composer context submission", () => {
332
333
  ];
333
334
  let pending: void | Promise<void>;
334
335
  await act(async () => {
336
+ capture.props!.onSubmissionPendingChange?.(true);
335
337
  pending = capture.props!.onSubmit("Review", [], references, {
336
338
  contextItems: source,
337
339
  composerModeContext: "Use scheduling tools for this request.",
@@ -341,6 +343,9 @@ describe("AgentKit composer context submission", () => {
341
343
  expect(beforeSend).toHaveBeenCalledOnce();
342
344
  expect(runtime.startRun).not.toHaveBeenCalled();
343
345
  expect(runtime.queueMessage).not.toHaveBeenCalled();
346
+ expect(container.querySelector('[role="status"]')?.textContent).toBe(
347
+ "Thinking",
348
+ );
344
349
  expect(Object.isFrozen(saved)).toBe(true);
345
350
  expect(Object.isFrozen(saved!.contextItems![0])).toBe(true);
346
351
  expect(Object.isFrozen(saved!.references[0].metadata)).toBe(true);
@@ -349,7 +354,9 @@ describe("AgentKit composer context submission", () => {
349
354
  await act(async () => {
350
355
  release();
351
356
  await pending;
357
+ capture.props!.onSubmissionPendingChange?.(false);
352
358
  });
359
+ expect(container.querySelector('[role="status"]')).toBeNull();
353
360
  const input =
354
361
  intent === "queued"
355
362
  ? runtime.queueMessage.mock.calls[0][0]
@@ -796,11 +796,27 @@ describe("AgentKitAssistantChat host behavior", () => {
796
796
 
797
797
  expect(chatMocks.composerProps.disabled).toBe(false);
798
798
  expect(chatMocks.composerProps.submissionDisabled).toBe(true);
799
+ expect(chatMocks.composerProps.announcePendingSubmission).toBe(false);
799
800
  expect(container.querySelector('[role="status"]')?.textContent).toBe(
800
801
  "agentChat.status.thinking",
801
802
  );
802
803
  });
803
804
 
805
+ it("keeps the pending announcement when the transcript has no visible user message", async () => {
806
+ chatMocks.history = { isSubmissionInFlight: true };
807
+ chatMocks.thread.messages = [
808
+ {
809
+ id: "assistant-last",
810
+ role: "assistant",
811
+ parts: [{ type: "text", text: "Earlier response" }],
812
+ },
813
+ ];
814
+ await mount(baseProps());
815
+
816
+ expect(chatMocks.composerProps.announcePendingSubmission).toBe(true);
817
+ expect(container.querySelector('[role="status"]')).toBeNull();
818
+ });
819
+
804
820
  it("gives JS callers a migration error for the removed createAdapter prop", async () => {
805
821
  const errors: Error[] = [];
806
822
  container = document.createElement("div");
@@ -957,7 +973,6 @@ describe("AgentKitAssistantChat host behavior", () => {
957
973
  .querySelector<HTMLElement>("[contenteditable]")
958
974
  ?.getAttribute("contenteditable"),
959
975
  ).toBe("true");
960
-
961
976
  await act(async () => composer.setText("Next draft"));
962
977
  await act(async () => started.resolve({ runId: "run-latency" }));
963
978
 
@@ -1007,6 +1022,28 @@ describe("AgentKitAssistantChat host behavior", () => {
1007
1022
  },
1008
1023
  );
1009
1024
 
1025
+ it("disables host suggestions while async submission is pending", async () => {
1026
+ const suggestion = seedFollowup();
1027
+ await mount(baseProps());
1028
+ const chips = () =>
1029
+ [...container.querySelectorAll("button")].filter(
1030
+ (button) => button.textContent === suggestion.label,
1031
+ );
1032
+ expect(chips().length).toBeGreaterThan(0);
1033
+ expect(chips().some((button) => button.disabled)).toBe(false);
1034
+
1035
+ await act(async () => {
1036
+ chatMocks.composerProps.onSubmissionPendingChange(true);
1037
+ });
1038
+
1039
+ expect(chips().every((button) => button.disabled)).toBe(true);
1040
+
1041
+ await act(async () => {
1042
+ chatMocks.composerProps.onSubmissionPendingChange(false);
1043
+ });
1044
+ expect(chips().every((button) => !button.disabled)).toBe(true);
1045
+ });
1046
+
1010
1047
  it.each([undefined, "context-chips", "after-composer", "hidden"] as const)(
1011
1048
  "preserves initial starters with the real composer (placement: %s)",
1012
1049
  async (suggestionPlacement) => {
@@ -511,6 +511,9 @@ interface AgentKitSurfaceContextValue {
511
511
  setupBouncePulse: number;
512
512
  bounceSetupCard: () => void;
513
513
  isSubmissionInFlight: boolean;
514
+ composerSubmissionPending: boolean;
515
+ onComposerSubmissionPendingChange: (pending: boolean) => void;
516
+ isThinkingVisibleInTranscript: boolean;
514
517
  contextItems: AgentChatContextItem[];
515
518
  suggestions: AgentSuggestionInput[];
516
519
  showSuggestions: boolean;
@@ -1149,6 +1152,8 @@ const AgentKitAssistantChatBody = forwardRef<
1149
1152
  const isRestoring =
1150
1153
  history?.isRestoring === true || props.threadRestore.status === "loading";
1151
1154
  const isSubmissionInFlight = history?.isSubmissionInFlight === true;
1155
+ const [composerSubmissionPending, setComposerSubmissionPending] =
1156
+ useState(false);
1152
1157
  const [authError, setAuthError] = useState<{
1153
1158
  sessionExpired?: boolean;
1154
1159
  } | null>(null);
@@ -1295,6 +1300,11 @@ const AgentKitAssistantChatBody = forwardRef<
1295
1300
  [thread.messages],
1296
1301
  );
1297
1302
  const isRunning = hasActiveAgentRuns(thread);
1303
+ const lastMessage = thread.messages.at(-1);
1304
+ const isThinkingVisibleInTranscript =
1305
+ (isRunning || isSubmissionInFlight) &&
1306
+ lastMessage?.role === "user" &&
1307
+ lastMessage.metadata?.hideUserMessage !== true;
1298
1308
  const isThreadRunning = useCallback(
1299
1309
  () => hasActiveAgentRuns(controller.getThread(threadId)),
1300
1310
  [controller, threadId],
@@ -2597,13 +2607,14 @@ const AgentKitAssistantChatBody = forwardRef<
2597
2607
  );
2598
2608
  const submitSuggestion = useCallback(
2599
2609
  (suggestion: AgentSuggestionInput) => {
2610
+ if (composerSubmissionPending) return;
2600
2611
  const handler = suggestionSubmitRef.current;
2601
2612
  if (!handler || handler.threadId !== threadId) return;
2602
2613
  void handler.submit(suggestion).catch((error) => {
2603
2614
  dispatchSetupRequiredEvent(error, props.tabId, threadId);
2604
2615
  });
2605
2616
  },
2606
- [props.tabId, threadId],
2617
+ [composerSubmissionPending, props.tabId, threadId],
2607
2618
  );
2608
2619
  const retryDeferredSubmission = useCallback(async () => {
2609
2620
  if (!deferredProviderSubmissionFailureId) return;
@@ -2783,6 +2794,9 @@ const AgentKitAssistantChatBody = forwardRef<
2783
2794
  setupBouncePulse,
2784
2795
  bounceSetupCard,
2785
2796
  isSubmissionInFlight,
2797
+ composerSubmissionPending,
2798
+ onComposerSubmissionPendingChange: setComposerSubmissionPending,
2799
+ isThinkingVisibleInTranscript,
2786
2800
  contextItems,
2787
2801
  voiceTranscriptMessages,
2788
2802
  selectionLength,
@@ -2967,7 +2981,8 @@ function AgentKitEmptyState({ threadId }: { threadId: string }) {
2967
2981
  disabled={
2968
2982
  !surface.canChat ||
2969
2983
  surface.props.composerDisabled ||
2970
- surface.props.composerSubmissionDisabled
2984
+ surface.props.composerSubmissionDisabled ||
2985
+ surface.composerSubmissionPending
2971
2986
  }
2972
2987
  onClick={() => surface.submitSuggestion(suggestion)}
2973
2988
  className="w-full rounded-xl border border-border/70 bg-card/60 px-3 py-2.5 text-left text-[13px] text-muted-foreground shadow-sm transition-colors hover:border-border hover:bg-card hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
@@ -3065,11 +3080,6 @@ function AgentKitTranscript({ children, threadId }: AgentKitRegionRenderProps) {
3065
3080
  const threadMessageIds = new Set(
3066
3081
  thread.messages.map((message) => message.id),
3067
3082
  );
3068
- const lastMessage = thread.messages.at(-1);
3069
- const showThinking =
3070
- (surface.isRunning || surface.isSubmissionInFlight) &&
3071
- lastMessage?.role === "user" &&
3072
- lastMessage.metadata?.hideUserMessage !== true;
3073
3083
  const pendingVoiceMessages = surface.voiceTranscriptMessages.filter(
3074
3084
  (message) => !threadMessageIds.has(message.id),
3075
3085
  );
@@ -3090,7 +3100,8 @@ function AgentKitTranscript({ children, threadId }: AgentKitRegionRenderProps) {
3090
3100
  !surface.canChat ||
3091
3101
  surface.props.composerDisabled ||
3092
3102
  surface.props.composerSubmissionDisabled ||
3093
- surface.isSubmissionInFlight
3103
+ surface.isSubmissionInFlight ||
3104
+ surface.composerSubmissionPending
3094
3105
  }
3095
3106
  onSelect={surface.submitSuggestion}
3096
3107
  className="agentkit-host-suggestions"
@@ -3246,7 +3257,7 @@ function AgentKitTranscript({ children, threadId }: AgentKitRegionRenderProps) {
3246
3257
  threadId={threadId}
3247
3258
  />
3248
3259
  ))}
3249
- {showThinking ? (
3260
+ {surface.isThinkingVisibleInTranscript ? (
3250
3261
  <div
3251
3262
  role="status"
3252
3263
  aria-live="polite"
@@ -3395,6 +3406,9 @@ function AgentKitComposerSurface({
3395
3406
  isRunning,
3396
3407
  isRestoring,
3397
3408
  isSubmissionInFlight,
3409
+ composerSubmissionPending,
3410
+ onComposerSubmissionPendingChange,
3411
+ isThinkingVisibleInTranscript,
3398
3412
  hasRenderedMessages,
3399
3413
  threadRestore,
3400
3414
  suggestions,
@@ -3430,6 +3444,9 @@ function AgentKitComposerSurface({
3430
3444
  isRunning: boolean;
3431
3445
  isRestoring: boolean;
3432
3446
  isSubmissionInFlight: boolean;
3447
+ composerSubmissionPending: boolean;
3448
+ onComposerSubmissionPendingChange: (pending: boolean) => void;
3449
+ isThinkingVisibleInTranscript: boolean;
3433
3450
  hasRenderedMessages: boolean;
3434
3451
  setupBouncePulse: number;
3435
3452
  bounceSetupCard: () => void;
@@ -3675,7 +3692,8 @@ function AgentKitComposerSurface({
3675
3692
  !canChat ||
3676
3693
  props.composerDisabled ||
3677
3694
  props.composerSubmissionDisabled ||
3678
- isSubmissionInFlight
3695
+ isSubmissionInFlight ||
3696
+ composerSubmissionPending
3679
3697
  }
3680
3698
  onSelect={submitSuggestion}
3681
3699
  className="agentkit-home-suggestions"
@@ -3749,6 +3767,8 @@ function AgentKitComposerSurface({
3749
3767
  requireAgentEngine={false}
3750
3768
  onTextChange={onTextChange}
3751
3769
  onBeforeSubmit={onBeforeSubmit}
3770
+ announcePendingSubmission={!isThinkingVisibleInTranscript}
3771
+ onSubmissionPendingChange={onComposerSubmissionPendingChange}
3752
3772
  contextItems={visibleContextItems}
3753
3773
  contextMenuItems={composerContext?.menuItems}
3754
3774
  onRemoveContextItem={(key) => {
@@ -3892,7 +3912,8 @@ function AgentKitComposerSurface({
3892
3912
  !canChat ||
3893
3913
  props.composerDisabled ||
3894
3914
  props.composerSubmissionDisabled ||
3895
- isSubmissionInFlight
3915
+ isSubmissionInFlight ||
3916
+ composerSubmissionPending
3896
3917
  }
3897
3918
  onSelect={submitSuggestion}
3898
3919
  className="agentkit-home-suggestions"
@@ -106,6 +106,7 @@ export interface PromptComposerProps {
106
106
  ) => void | Promise<void>;
107
107
  /** Return false to stop a submit before it reaches the host runtime. */
108
108
  onBeforeSubmit?: () => boolean | Promise<boolean>;
109
+ onSubmissionPendingChange?: (pending: boolean) => void;
109
110
  /** Scope where a failed submission should be recovered after the host forks. */
110
111
  getSubmitFailureDraftScope?: () => string | null;
111
112
  /** Handle file paste/drop errors in the host chat surface. */
@@ -118,6 +119,8 @@ export interface PromptComposerProps {
118
119
  disabled?: boolean;
119
120
  /** Block all submission paths while allowing draft, file, and context staging. */
120
121
  submissionDisabled?: boolean;
122
+ /** Disable only the send control while the submission is being accepted. */
123
+ sendButtonDisabled?: boolean;
121
124
  /** Prevent submission while preserving editor focus and draft entry. */
122
125
  submitting?: boolean;
123
126
  /** Present the primary action as queueing instead of immediate send. */
@@ -608,6 +611,7 @@ function PromptComposerInner({
608
611
  ariaLabel,
609
612
  disabled,
610
613
  submissionDisabled,
614
+ sendButtonDisabled,
611
615
  submitting,
612
616
  willQueue = false,
613
617
  onDisabledClick,
@@ -680,6 +684,7 @@ function PromptComposerInner({
680
684
  imageModelMenu,
681
685
  composerRef,
682
686
  onBeforeSubmit,
687
+ onSubmissionPendingChange,
683
688
  getSubmitFailureDraftScope,
684
689
  onAttachmentError,
685
690
  interceptBuildRequestsForBuilder,
@@ -890,6 +895,7 @@ function PromptComposerInner({
890
895
  disabled={disabled}
891
896
  contextControlsDisabled={engineSubmissionBlocked}
892
897
  submissionDisabled={submissionDisabled || engineSubmissionBlocked}
898
+ sendButtonDisabled={sendButtonDisabled}
893
899
  submitting={submitting}
894
900
  willQueue={willQueue}
895
901
  maxDocumentAttachmentBytes={maxDocumentAttachmentBytes}
@@ -899,6 +905,7 @@ function PromptComposerInner({
899
905
  initialTextKey={initialTextKey}
900
906
  onSubmit={handleSubmit}
901
907
  onBeforeSubmit={onBeforeSubmit}
908
+ onSubmissionPendingChange={onSubmissionPendingChange}
902
909
  getSubmitFailureDraftScope={getSubmitFailureDraftScope}
903
910
  onAttachmentError={onAttachmentError}
904
911
  interceptBuildRequestsForBuilder={interceptBuildRequestsForBuilder}
@@ -2166,7 +2166,13 @@ describe("TiptapComposer slash commands", () => {
2166
2166
  resolveSubmit = resolve;
2167
2167
  });
2168
2168
  const onBeforeSubmit = vi.fn(() => preflight);
2169
- const onSubmit = vi.fn(() => submission);
2169
+ const onSubmissionPendingChange = vi.fn();
2170
+ const onSubmit = vi.fn<NonNullable<TiptapComposerProps["onSubmit"]>>(
2171
+ async (_text, _references, _attachments, options) => {
2172
+ await submission;
2173
+ options?.onLocalSubmit?.();
2174
+ },
2175
+ );
2170
2176
  const focusRef = React.createRef<TiptapComposerHandle>();
2171
2177
 
2172
2178
  function Harness() {
@@ -2180,6 +2186,7 @@ describe("TiptapComposer slash commands", () => {
2180
2186
  React.createElement(TiptapComposer, {
2181
2187
  focusRef,
2182
2188
  onBeforeSubmit,
2189
+ onSubmissionPendingChange,
2183
2190
  onSubmit,
2184
2191
  clearOnSubmitImmediately: true,
2185
2192
  includeDefaultSlashSkills: false,
@@ -2209,6 +2216,7 @@ describe("TiptapComposer slash commands", () => {
2209
2216
 
2210
2217
  expect(editor.textContent).toBe("");
2211
2218
  expect(onSubmit).not.toHaveBeenCalled();
2219
+ expect(onSubmissionPendingChange).toHaveBeenLastCalledWith(true);
2212
2220
  act(() => focusRef.current?.setText("follow-up prompt"));
2213
2221
 
2214
2222
  await act(async () => {
@@ -2225,6 +2233,8 @@ describe("TiptapComposer slash commands", () => {
2225
2233
  );
2226
2234
  expect(editor.textContent).toBe("follow-up prompt");
2227
2235
  await act(async () => resolveSubmit());
2236
+ expect(editor.textContent).toBe("follow-up prompt");
2237
+ expect(onSubmissionPendingChange).toHaveBeenLastCalledWith(false);
2228
2238
  });
2229
2239
 
2230
2240
  it("restores a prompt when async preflight declines it", async () => {
@@ -2337,10 +2347,11 @@ describe("TiptapComposer slash commands", () => {
2337
2347
 
2338
2348
  it("clears immediately while submitting and restores the draft on failure", async () => {
2339
2349
  let rejectSubmit!: (error: Error) => void;
2340
- const onSubmit = vi.fn(
2341
- () =>
2350
+ const onSubmit = vi.fn<NonNullable<TiptapComposerProps["onSubmit"]>>(
2351
+ (_text, _references, _attachments, options) =>
2342
2352
  new Promise<void>((_resolve, reject) => {
2343
2353
  rejectSubmit = reject;
2354
+ options?.onLocalSubmit?.();
2344
2355
  }),
2345
2356
  );
2346
2357
  const focusRef = React.createRef<TiptapComposerHandle>();
@@ -2384,6 +2395,7 @@ describe("TiptapComposer slash commands", () => {
2384
2395
 
2385
2396
  expect(onSubmit).toHaveBeenCalledOnce();
2386
2397
  expect(editor.textContent).toBe("");
2398
+ act(() => focusRef.current?.setText("follow-up prompt"));
2387
2399
 
2388
2400
  await act(async () => {
2389
2401
  rejectSubmit(new Error("network unavailable"));
@@ -2391,7 +2403,17 @@ describe("TiptapComposer slash commands", () => {
2391
2403
  await Promise.resolve();
2392
2404
  });
2393
2405
 
2394
- expect(editor.textContent).toBe("send this once");
2406
+ expect(
2407
+ [...editor.querySelectorAll("p")].map(
2408
+ (paragraph) => paragraph.textContent,
2409
+ ),
2410
+ ).toEqual(["send this once", "follow-up prompt"]);
2411
+ expect(localStorage.getItem(getComposerDraftKey())).toContain(
2412
+ "send this once",
2413
+ );
2414
+ expect(localStorage.getItem(getComposerDraftKey())).toContain(
2415
+ "follow-up prompt",
2416
+ );
2395
2417
  });
2396
2418
 
2397
2419
  it("restores the persisted draft when an immediate submit fails after unmount", async () => {