@agent-native/toolkit 0.198.3 → 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.
Files changed (157) hide show
  1. package/dist/app/agentkit/react/components.d.ts +8 -3
  2. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  3. package/dist/app/agentkit/react/components.js +24 -10
  4. package/dist/app/agentkit/react/components.js.map +1 -1
  5. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  6. package/dist/app/chat/AgentKitAssistantChat.js +65 -33
  7. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  8. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  9. package/dist/app/chat/AgentSidebar.js +2 -0
  10. package/dist/app/chat/AgentSidebar.js.map +1 -1
  11. package/dist/app/chat/FileStorageSetupPopover.js +2 -2
  12. package/dist/app/chat/FileStorageSetupPopover.js.map +1 -1
  13. package/dist/app/chat/MultiTabAssistantChat.js +1 -1
  14. package/dist/app/chat/agentkit-chat/connections.d.ts +1 -4
  15. package/dist/app/chat/agentkit-chat/connections.d.ts.map +1 -1
  16. package/dist/app/chat/agentkit-chat/connections.js +1 -26
  17. package/dist/app/chat/agentkit-chat/connections.js.map +1 -1
  18. package/dist/app/chat/agentkit-chat/history.d.ts +4 -1
  19. package/dist/app/chat/agentkit-chat/history.d.ts.map +1 -1
  20. package/dist/app/chat/agentkit-chat/history.js +17 -5
  21. package/dist/app/chat/agentkit-chat/history.js.map +1 -1
  22. package/dist/app/chat/chat/history-types.d.ts +1 -1
  23. package/dist/app/chat/chat/history-types.d.ts.map +1 -1
  24. package/dist/app/chat/chat-first/copy.d.ts +1 -2
  25. package/dist/app/chat/chat-first/copy.d.ts.map +1 -1
  26. package/dist/app/chat/chat-first/copy.js +1 -121
  27. package/dist/app/chat/chat-first/copy.js.map +1 -1
  28. package/dist/app/chat/chat-first-copy.d.ts +3 -0
  29. package/dist/app/chat/chat-first-copy.d.ts.map +1 -0
  30. package/dist/app/chat/chat-first-copy.js +122 -0
  31. package/dist/app/chat/chat-first-copy.js.map +1 -0
  32. package/dist/app/feature-flags/FeatureFlagsPanel.d.ts.map +1 -1
  33. package/dist/app/feature-flags/FeatureFlagsPanel.js +1 -0
  34. package/dist/app/feature-flags/FeatureFlagsPanel.js.map +1 -1
  35. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/ar-SA.js +2 -1
  37. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  38. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/de-DE.js +2 -1
  40. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  41. package/dist/app/i18n/catalogs/en-US.d.ts +2 -1
  42. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  43. package/dist/app/i18n/catalogs/en-US.js +2 -1
  44. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  45. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  46. package/dist/app/i18n/catalogs/es-ES.js +2 -1
  47. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  48. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  49. package/dist/app/i18n/catalogs/fr-FR.js +2 -1
  50. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  51. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  52. package/dist/app/i18n/catalogs/hi-IN.js +2 -1
  53. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  54. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  55. package/dist/app/i18n/catalogs/ja-JP.js +2 -1
  56. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  57. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  58. package/dist/app/i18n/catalogs/ko-KR.js +2 -1
  59. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  60. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  61. package/dist/app/i18n/catalogs/pt-BR.js +2 -1
  62. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  63. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  64. package/dist/app/i18n/catalogs/zh-CN.js +2 -1
  65. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  66. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  67. package/dist/app/i18n/catalogs/zh-TW.js +2 -1
  68. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  69. package/dist/app/labs/LabsSettings.d.ts.map +1 -1
  70. package/dist/app/labs/LabsSettings.js +7 -3
  71. package/dist/app/labs/LabsSettings.js.map +1 -1
  72. package/dist/app/org/OrgGeneralSection.d.ts.map +1 -1
  73. package/dist/app/org/OrgGeneralSection.js +21 -18
  74. package/dist/app/org/OrgGeneralSection.js.map +1 -1
  75. package/dist/app/org/OrgSwitcher.d.ts.map +1 -1
  76. package/dist/app/org/OrgSwitcher.js +3 -2
  77. package/dist/app/org/OrgSwitcher.js.map +1 -1
  78. package/dist/app/org/workspace-app-links.d.ts +1 -0
  79. package/dist/app/org/workspace-app-links.d.ts.map +1 -1
  80. package/dist/app/org/workspace-app-links.js +3 -0
  81. package/dist/app/org/workspace-app-links.js.map +1 -1
  82. package/dist/app/resources/McpAgentKitConnectionRequest.d.ts +7 -2
  83. package/dist/app/resources/McpAgentKitConnectionRequest.d.ts.map +1 -1
  84. package/dist/app/resources/McpAgentKitConnectionRequest.js +132 -13
  85. package/dist/app/resources/McpAgentKitConnectionRequest.js.map +1 -1
  86. package/dist/composer/PromptComposer.d.ts +3 -0
  87. package/dist/composer/PromptComposer.d.ts.map +1 -1
  88. package/dist/composer/PromptComposer.js +2 -2
  89. package/dist/composer/PromptComposer.js.map +1 -1
  90. package/dist/composer/TiptapComposer.d.ts +4 -1
  91. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  92. package/dist/composer/TiptapComposer.js +8 -4
  93. package/dist/composer/TiptapComposer.js.map +1 -1
  94. package/dist/icons/ResourceIconPicker.d.ts +6 -1
  95. package/dist/icons/ResourceIconPicker.d.ts.map +1 -1
  96. package/dist/icons/ResourceIconPicker.js +45 -6
  97. package/dist/icons/ResourceIconPicker.js.map +1 -1
  98. package/package.json +6 -4
  99. package/src/app/agentkit/react/components.interaction.spec.tsx +93 -0
  100. package/src/app/agentkit/react/components.spec.tsx +4 -5
  101. package/src/app/agentkit/react/components.tsx +51 -8
  102. package/src/app/agentkit/react/composer-context.spec.tsx +7 -0
  103. package/src/app/agentkit/react/lifecycle.spec.tsx +42 -0
  104. package/src/app/chat/AgentKitAssistantChat.spec.tsx +151 -11
  105. package/src/app/chat/AgentKitAssistantChat.tsx +110 -28
  106. package/src/app/chat/AgentPanel.header.spec.ts +59 -22
  107. package/src/app/chat/AgentSidebar.tsx +2 -0
  108. package/src/app/chat/FileStorageSetupPopover.tsx +2 -2
  109. package/src/app/chat/MultiTabAssistantChat.messageCount.spec.tsx +3 -1
  110. package/src/app/chat/MultiTabAssistantChat.spec.tsx +8 -4
  111. package/src/app/chat/MultiTabAssistantChat.tsx +1 -1
  112. package/src/app/chat/agentkit-chat/action-widget.spec.tsx +5 -17
  113. package/src/app/chat/agentkit-chat/connections.spec.tsx +137 -0
  114. package/src/app/chat/agentkit-chat/connections.ts +2 -37
  115. package/src/app/chat/agentkit-chat/history.tsx +23 -8
  116. package/src/app/chat/chat/history-types.ts +1 -1
  117. package/src/app/chat/chat/tool-call-display.spec.tsx +2 -2
  118. package/src/app/chat/chat/widgets/record-change-widget.spec.tsx +20 -29
  119. package/src/app/chat/chat-first/copy.ts +1 -133
  120. package/src/app/chat/chat-first-copy.ts +133 -0
  121. package/src/app/chat/composer/use-agentkit-capabilities.hardening.spec.tsx +3 -2
  122. package/src/app/feature-flags/FeatureFlagsPanel.tsx +1 -0
  123. package/src/app/i18n/catalogs/ar-SA.ts +2 -1
  124. package/src/app/i18n/catalogs/de-DE.ts +3 -1
  125. package/src/app/i18n/catalogs/en-US.ts +2 -1
  126. package/src/app/i18n/catalogs/es-ES.ts +2 -1
  127. package/src/app/i18n/catalogs/fr-FR.ts +3 -1
  128. package/src/app/i18n/catalogs/hi-IN.ts +2 -1
  129. package/src/app/i18n/catalogs/ja-JP.ts +2 -1
  130. package/src/app/i18n/catalogs/ko-KR.ts +2 -1
  131. package/src/app/i18n/catalogs/pt-BR.ts +3 -1
  132. package/src/app/i18n/catalogs/zh-CN.ts +2 -1
  133. package/src/app/i18n/catalogs/zh-TW.ts +2 -1
  134. package/src/app/integrations/IntegrationDetailPage.spec.tsx +1 -1
  135. package/src/app/labs/LabsSettings.tsx +46 -10
  136. package/src/app/observability/ThumbsFeedback.spec.tsx +15 -0
  137. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +4 -0
  138. package/src/app/org/OrgGeneralSection.tsx +32 -22
  139. package/src/app/org/OrgSwitcher.tsx +3 -2
  140. package/src/app/org/workspace-app-links.spec.ts +4 -0
  141. package/src/app/org/workspace-app-links.ts +8 -1
  142. package/src/app/resources/McpAgentKitConnectionRequest.spec.tsx +371 -2
  143. package/src/app/resources/McpAgentKitConnectionRequest.tsx +171 -11
  144. package/src/app/settings/SettingsTabsPage.spec.tsx +4 -2
  145. package/src/app/settings/shell/SettingsShell.spec.tsx +14 -8
  146. package/src/app/settings/shell/pages/app-group-pages.spec.tsx +35 -5
  147. package/src/app/settings/shell/pages/resource-pages.spec.tsx +3 -0
  148. package/src/app/settings/usage/UsagePage.spec.tsx +3 -3
  149. package/src/app/shared/lazy-chunk-error-boundary.spec.tsx +2 -0
  150. package/src/app-shell/sidebar-overflow.spec.tsx +4 -1
  151. package/src/composer/PromptComposer.tsx +7 -0
  152. package/src/composer/TiptapComposer.spec.ts +26 -4
  153. package/src/composer/TiptapComposer.tsx +12 -3
  154. package/src/design-tweaks/visual-style-controls.spec.tsx +38 -0
  155. package/src/editor/useCollabReconcile.concurrent.spec.ts +2 -1
  156. package/src/icons/ResourceIconPicker.spec.tsx +270 -0
  157. package/src/icons/ResourceIconPicker.tsx +72 -4
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
+ import type { LabStates } from "@agent-native/core/client/labs/use-lab";
3
4
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
5
  import React, { act, Suspense } from "react";
5
6
  import { createRoot, type Root } from "react-dom/client";
@@ -32,17 +33,17 @@ vi.mock("@agent-native/core/client/app-model-default", () => ({
32
33
  }));
33
34
 
34
35
  const labsActions = vi.hoisted(() => ({
35
- query: { data: undefined as Record<string, boolean> | undefined },
36
+ query: { data: undefined as LabStates | undefined },
36
37
  isError: false,
37
38
  mutate: vi.fn(),
38
39
  }));
39
40
  vi.mock("@agent-native/core/client/hooks", () => ({
40
- useChangeVersions: () => 0,
41
41
  useDemoModeStatus: () => ({
42
42
  enabled: false,
43
43
  forced: false,
44
44
  isLoading: false,
45
45
  }),
46
+ useChangeVersions: () => 0,
46
47
  }));
47
48
  vi.mock("@agent-native/core/client/use-action", () => ({
48
49
  useActionQuery: () => ({
@@ -422,9 +423,13 @@ describe("app group pages", () => {
422
423
 
423
424
  it("hides labs the server didn't register once it answers", async () => {
424
425
  labsActions.query.data = {
425
- "chatgpt-subscription": false,
426
- "clips.wisprflow": false,
427
- "clips.meetings": true,
426
+ "chatgpt-subscription": {
427
+ enabled: false,
428
+ source: "default",
429
+ mixed: false,
430
+ },
431
+ "clips.wisprflow": { enabled: false, source: "choice", mixed: false },
432
+ "clips.meetings": { enabled: true, source: "choice", mixed: false },
428
433
  };
429
434
  await renderPage(LabsSettingsPage, { input: { labs } });
430
435
  expect(switchFor("Retired lab")).toBeNull();
@@ -436,6 +441,31 @@ describe("app group pages", () => {
436
441
  ).toBe("true");
437
442
  expect(switchFor("Voice dictation")?.disabled).toBe(false);
438
443
  });
444
+
445
+ it("offers explicit choices for inherited mixed settings", async () => {
446
+ labsActions.query.data = {
447
+ "clips.meetings": {
448
+ enabled: false,
449
+ source: "legacy",
450
+ mixed: true,
451
+ legacyValues: { capture: false, recovery: true },
452
+ },
453
+ };
454
+ await renderPage(LabsSettingsPage, { input: { labs } });
455
+ expect(switchFor("Meetings and transcription")).toBeNull();
456
+ const on = container.querySelector<HTMLButtonElement>(
457
+ '[aria-label="Meetings and transcription: On"]',
458
+ );
459
+ expect(on?.disabled).toBe(false);
460
+ act(() => on?.click());
461
+ expect(labsActions.mutate).toHaveBeenCalledWith(
462
+ { key: "clips.meetings", enabled: true },
463
+ expect.any(Object),
464
+ );
465
+ expect(
466
+ switchFor("Meetings and transcription")?.getAttribute("aria-checked"),
467
+ ).toBe("true");
468
+ });
439
469
  });
440
470
 
441
471
  describe("Automations", () => {
@@ -10,6 +10,9 @@ const orgState = vi.hoisted(() => ({
10
10
  value: { orgId: "org-1", orgName: "Acme", role: "member" as string },
11
11
  }));
12
12
 
13
+ vi.mock("@agent-native/core/client/org/hooks", () => ({
14
+ useOrg: () => ({ data: orgState.value, isLoading: false }),
15
+ }));
13
16
  vi.mock("@agent-native/core/client/org/hooks", () => ({
14
17
  useOrg: () => ({ data: orgState.value, isLoading: false }),
15
18
  }));
@@ -54,9 +54,9 @@ vi.mock("@agent-native/core/client/hooks", () => ({
54
54
  }),
55
55
  }));
56
56
 
57
- vi.mock("@agent-native/core/client/use-action", () => ({
58
- useActionQuery: () => ({ data: null, isError: false, isLoading: false }),
59
- }));
57
+ vi.mock("@agent-native/core/client/use-action", async () =>
58
+ vi.importMock("@agent-native/core/client/hooks"),
59
+ );
60
60
 
61
61
  vi.mock("@agent-native/core/client/i18n", () => ({
62
62
  useT:
@@ -12,6 +12,7 @@ vi.mock("@agent-native/core/client/route-chunk-recovery", () => ({
12
12
 
13
13
  import { AgentNativeI18nProvider } from "@agent-native/core/client/i18n";
14
14
 
15
+ import { createToolkitI18nCatalog } from "../i18n.js";
15
16
  import { LazyChunkErrorBoundary } from "./LazyChunkErrorBoundary.js";
16
17
  import { LazyChunkRetryFallback } from "./LazyChunkRetryFallback.js";
17
18
 
@@ -42,6 +43,7 @@ describe("LazyChunkErrorBoundary", () => {
42
43
  await act(async () => {
43
44
  root.render(
44
45
  <AgentNativeI18nProvider
46
+ catalog={createToolkitI18nCatalog({ messages: {} })}
45
47
  initialLocale="en-US"
46
48
  initialPreference="en-US"
47
49
  persistPreference={false}
@@ -32,7 +32,10 @@ describe("AppSidebar overflow affordances", () => {
32
32
  root = createRoot(container);
33
33
  });
34
34
 
35
- afterEach(() => {
35
+ afterEach(async () => {
36
+ await act(async () => {
37
+ await new Promise((resolve) => setTimeout(resolve, 20));
38
+ });
36
39
  act(() => root.unmount());
37
40
  container.remove();
38
41
  vi.unstubAllGlobals();
@@ -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 () => {
@@ -888,6 +888,8 @@ export interface TiptapComposerProps {
888
888
  contextControlsDisabled?: boolean;
889
889
  /** Prevent submission without making the editable surface lose focus. */
890
890
  submissionDisabled?: boolean;
891
+ /** Disable only the send control while the submission is being accepted. */
892
+ sendButtonDisabled?: boolean;
891
893
  /** Prevent submission while a host request is in flight. */
892
894
  submitting?: boolean;
893
895
  /** Override the generic document attachment cap for a multipart host. */
@@ -916,6 +918,7 @@ export interface TiptapComposerProps {
916
918
  ) => void | Promise<void>;
917
919
  /** Return false to stop a submit before it enters the chat runtime. */
918
920
  onBeforeSubmit?: () => boolean | Promise<boolean>;
921
+ onSubmissionPendingChange?: (pending: boolean) => void;
919
922
  /** Scope where a failed submission should be recovered after the host forks. */
920
923
  getSubmitFailureDraftScope?: () => string | null;
921
924
  /**
@@ -2547,6 +2550,7 @@ export function TiptapComposer({
2547
2550
  disabled = false,
2548
2551
  contextControlsDisabled = false,
2549
2552
  submissionDisabled = false,
2553
+ sendButtonDisabled = false,
2550
2554
  submitting = false,
2551
2555
  maxDocumentAttachmentBytes = MAX_DOCUMENT_ATTACHMENT_BYTES,
2552
2556
  documentAttachmentLimitLabel = "PDFs",
@@ -2558,6 +2562,7 @@ export function TiptapComposer({
2558
2562
  initialTextKey,
2559
2563
  onSubmit,
2560
2564
  onBeforeSubmit,
2565
+ onSubmissionPendingChange,
2561
2566
  getSubmitFailureDraftScope,
2562
2567
  clearOnSubmit = true,
2563
2568
  clearOnSubmitImmediately = false,
@@ -4243,7 +4248,6 @@ export function TiptapComposer({
4243
4248
  slotReferencesRef.current.length > 0);
4244
4249
  const canPreserveFollowUp =
4245
4250
  preserveFollowUp &&
4246
- clearedBeforePreflight &&
4247
4251
  !isForkRecovery &&
4248
4252
  hasFollowUp &&
4249
4253
  (currentDraft === null ||
@@ -4330,6 +4334,7 @@ export function TiptapComposer({
4330
4334
 
4331
4335
  if (onBeforeSubmit) {
4332
4336
  submitInFlightRef.current = true;
4337
+ onSubmissionPendingChange?.(true);
4333
4338
  try {
4334
4339
  const shouldSubmit = await onBeforeSubmit();
4335
4340
  if (!shouldSubmit) {
@@ -4351,6 +4356,7 @@ export function TiptapComposer({
4351
4356
  return false;
4352
4357
  } finally {
4353
4358
  submitInFlightRef.current = false;
4359
+ onSubmissionPendingChange?.(false);
4354
4360
  }
4355
4361
  }
4356
4362
  if (
@@ -4498,6 +4504,7 @@ export function TiptapComposer({
4498
4504
  submitInFlightRef.current = true;
4499
4505
  let locallySubmitted = false;
4500
4506
  let settled = false;
4507
+ onSubmissionPendingChange?.(true);
4501
4508
  const clearSubmittedComposer = () => {
4502
4509
  if (clearOnSubmit) {
4503
4510
  clearComposerDraft(submittingDraftKey, submittingDraftSnapshot);
@@ -4575,7 +4582,7 @@ export function TiptapComposer({
4575
4582
  );
4576
4583
  } catch (error) {
4577
4584
  if (locallySubmitted) {
4578
- restoreSubmittedDraft();
4585
+ restoreSubmittedDraft(true);
4579
4586
  return true;
4580
4587
  }
4581
4588
  restoreSubmittedDraft(true);
@@ -4593,6 +4600,7 @@ export function TiptapComposer({
4593
4600
  } finally {
4594
4601
  settled = true;
4595
4602
  submitInFlightRef.current = false;
4603
+ onSubmissionPendingChange?.(false);
4596
4604
  }
4597
4605
 
4598
4606
  if (!isCurrentDraftScope()) {
@@ -4685,6 +4693,7 @@ export function TiptapComposer({
4685
4693
  clearOnSubmitImmediately,
4686
4694
  getSubmitFailureDraftScope,
4687
4695
  onBeforeSubmit,
4696
+ onSubmissionPendingChange,
4688
4697
  extractComposerPayload,
4689
4698
  syncComposerState,
4690
4699
  updateSlotReferences,
@@ -5391,7 +5400,7 @@ export function TiptapComposer({
5391
5400
  <button
5392
5401
  type="button"
5393
5402
  onClick={() => void submitComposer("immediate")}
5394
- disabled={!canSend}
5403
+ disabled={!canSend || sendButtonDisabled}
5395
5404
  aria-label={
5396
5405
  submitting ? t("common.loading") : sendButtonTooltip
5397
5406
  }
@@ -378,6 +378,44 @@ describe("visual style controls", () => {
378
378
  expect(input.getAttribute("data-design-history-hotkeys")).toBe("true");
379
379
  });
380
380
 
381
+ it("commits a zero pixel value with leading zeros", () => {
382
+ const onChange = vi.fn();
383
+ act(() => {
384
+ root.render(
385
+ <VisualScrubInput
386
+ label="Border width"
387
+ value={1}
388
+ min={0}
389
+ unit="px"
390
+ onChange={onChange}
391
+ />,
392
+ );
393
+ });
394
+
395
+ const input = container.querySelector<HTMLInputElement>("input")!;
396
+ act(() => input.focus());
397
+ act(() => {
398
+ const setter = Object.getOwnPropertyDescriptor(
399
+ HTMLInputElement.prototype,
400
+ "value",
401
+ )!.set!;
402
+ setter.call(input, "00px");
403
+ input.dispatchEvent(new Event("input", { bubbles: true }));
404
+ });
405
+ act(() => {
406
+ input.dispatchEvent(
407
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
408
+ );
409
+ });
410
+
411
+ expect(onChange).toHaveBeenCalledWith(
412
+ 0,
413
+ expect.objectContaining({ source: "commit", expression: "00px" }),
414
+ );
415
+ expect(onChange).toHaveBeenCalledTimes(1);
416
+ expect(input.value).toBe("0px");
417
+ });
418
+
381
419
  it("releases focus on Enter when the consumer opts in", () => {
382
420
  const onChange = vi.fn();
383
421
  act(() => {
@@ -1786,6 +1786,7 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
1786
1786
  });
1787
1787
 
1788
1788
  it("still clears a stale collab value when there is no local emission", async () => {
1789
+ vi.useFakeTimers();
1789
1790
  const { captured, Harness } = makeCollabSeedHarness("stale persisted body");
1790
1791
  act(() =>
1791
1792
  root.render(
@@ -1799,7 +1800,7 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
1799
1800
  ),
1800
1801
  );
1801
1802
  expect(getEditorMarkdown(captured.editor!)).toBe("stale persisted body");
1802
- await act(async () => new Promise((resolve) => setTimeout(resolve, 30)));
1803
+ await act(async () => vi.advanceTimersByTimeAsync(30));
1803
1804
 
1804
1805
  expect(getEditorMarkdown(captured.editor!)).toBe("canonical SQL body");
1805
1806
  expect(captured.emitted).toEqual([]);
@@ -0,0 +1,270 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import {
8
+ ResourceIconPicker,
9
+ type ResourceIconPickerLabels,
10
+ } from "./ResourceIconPicker.js";
11
+ import type { ResourceIconImage, ResourceIconValue } from "./types.js";
12
+
13
+ vi.mock("./catalog.js", () => ({ loadTablerCatalog: async () => [] }));
14
+ vi.mock("./emoji-catalog.js", () => ({
15
+ EMOJI_CATEGORIES: [],
16
+ loadEmojiCatalog: async () => [],
17
+ }));
18
+
19
+ const labels: ResourceIconPickerLabels = {
20
+ trigger: "Choose icon",
21
+ iconsTab: "Icons",
22
+ emojiTab: "Emoji",
23
+ uploadTab: "Upload",
24
+ search: "Search",
25
+ noResults: "No results",
26
+ recents: "Recent",
27
+ colors: "Colors",
28
+ defaultColor: "Default",
29
+ remove: "Remove",
30
+ upload: "Choose image",
31
+ uploading: "Uploading",
32
+ saveError: "Could not save icon.",
33
+ uploadFailed: "Could not upload icon image. Try again.",
34
+ uploadTooLarge: "Icon image must be 5 MiB or smaller.",
35
+ uploadUnsupportedType: "Only PNG, JPEG, WebP, or SVG images can be uploaded.",
36
+ loadError: "Could not load uploaded icons.",
37
+ retry: "Retry",
38
+ };
39
+ const previousIcon: ResourceIconValue = {
40
+ version: 1,
41
+ kind: "emoji",
42
+ emoji: "🌿",
43
+ };
44
+ const uploadedIcon: ResourceIconImage = {
45
+ version: 1,
46
+ kind: "image",
47
+ authority: "url",
48
+ assetId: "/uploaded.svg",
49
+ };
50
+
51
+ describe("ResourceIconPicker uploads", () => {
52
+ let container: HTMLDivElement;
53
+ let root: Root;
54
+
55
+ beforeEach(() => {
56
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
57
+ vi.stubGlobal(
58
+ "ResizeObserver",
59
+ class {
60
+ observe() {}
61
+ unobserve() {}
62
+ disconnect() {}
63
+ },
64
+ );
65
+ container = document.createElement("div");
66
+ document.body.append(container);
67
+ root = createRoot(container);
68
+ });
69
+
70
+ afterEach(() => {
71
+ act(() => root.unmount());
72
+ container.remove();
73
+ vi.unstubAllGlobals();
74
+ });
75
+
76
+ async function renderPicker(
77
+ onUpload: (file: File) => Promise<ResourceIconImage>,
78
+ onValueChange = vi.fn(),
79
+ formatUploadError?: (error: unknown) => string,
80
+ ) {
81
+ await act(async () => {
82
+ root.render(
83
+ <ResourceIconPicker
84
+ value={previousIcon}
85
+ onValueChange={onValueChange}
86
+ onUpload={onUpload}
87
+ formatUploadError={formatUploadError}
88
+ labels={labels}
89
+ open
90
+ portalled={false}
91
+ />,
92
+ );
93
+ });
94
+ const uploadTab = Array.from(container.querySelectorAll("[role=tab]")).find(
95
+ (tab) => tab.textContent === labels.uploadTab,
96
+ );
97
+ expect(uploadTab).toBeDefined();
98
+ await act(async () => {
99
+ uploadTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
100
+ uploadTab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
101
+ });
102
+ }
103
+
104
+ async function choose(file: File) {
105
+ const input =
106
+ container.querySelector<HTMLInputElement>('input[type="file"]')!;
107
+ Object.defineProperty(input, "files", {
108
+ configurable: true,
109
+ value: [file],
110
+ });
111
+ await act(async () => {
112
+ input.dispatchEvent(new Event("change", { bubbles: true }));
113
+ });
114
+ }
115
+
116
+ async function drop(file: File) {
117
+ const target = Array.from(container.querySelectorAll("button")).find(
118
+ (button) => button.textContent?.includes(labels.upload),
119
+ )!;
120
+ const event = new Event("drop", { bubbles: true, cancelable: true });
121
+ Object.defineProperty(event, "dataTransfer", { value: { files: [file] } });
122
+ await act(async () => target.dispatchEvent(event));
123
+ }
124
+
125
+ function alertText() {
126
+ return container.querySelector('[role="alert"]')?.textContent;
127
+ }
128
+
129
+ it("rejects unsupported types from input and drop without replacing the icon", async () => {
130
+ const onUpload = vi.fn().mockResolvedValue(uploadedIcon);
131
+ const onValueChange = vi.fn();
132
+ await renderPicker(onUpload, onValueChange);
133
+
134
+ await choose(new File(["gif"], "animation.gif", { type: "image/gif" }));
135
+ expect(alertText()).toBe(labels.uploadUnsupportedType);
136
+
137
+ await drop(new File(["text"], "notes.txt", { type: "text/plain" }));
138
+ expect(alertText()).toBe(labels.uploadUnsupportedType);
139
+ expect(onUpload).not.toHaveBeenCalled();
140
+ expect(onValueChange).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("shows the size limit for a supported file from either path", async () => {
144
+ const onUpload = vi.fn().mockResolvedValue(uploadedIcon);
145
+ await renderPicker(onUpload);
146
+
147
+ const large = new File([new Uint8Array(5 * 1024 * 1024 + 1)], "large.png", {
148
+ type: "image/png",
149
+ });
150
+ await choose(large);
151
+ expect(alertText()).toBe(labels.uploadTooLarge);
152
+ await drop(large);
153
+ expect(alertText()).toBe(labels.uploadTooLarge);
154
+ await drop(
155
+ new File([new Uint8Array(5 * 1024 * 1024 + 1)], "large.txt", {
156
+ type: "text/plain",
157
+ }),
158
+ );
159
+ expect(alertText()).toBe(labels.uploadTooLarge);
160
+ expect(onUpload).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it("keeps a mapped failure visible and clears it after a successful retry", async () => {
164
+ const onUpload = vi
165
+ .fn()
166
+ .mockRejectedValueOnce(new Error("private server detail"))
167
+ .mockResolvedValueOnce(uploadedIcon);
168
+ const onValueChange = vi.fn();
169
+ await renderPicker(
170
+ onUpload,
171
+ onValueChange,
172
+ () => "Storage is unavailable.",
173
+ );
174
+
175
+ await choose(new File(["first"], "first.png", { type: "image/png" }));
176
+ expect(alertText()).toBe("Storage is unavailable.");
177
+ expect(container.textContent).not.toContain("private server detail");
178
+ expect(onValueChange).not.toHaveBeenCalled();
179
+
180
+ await drop(new File(["<svg />"], "retry.SVG"));
181
+ expect(onUpload).toHaveBeenCalledTimes(2);
182
+ expect(onUpload.mock.calls[1]?.[0].type).toBe("image/svg+xml");
183
+ expect(onValueChange).toHaveBeenCalledWith(uploadedIcon);
184
+ expect(alertText()).toBeUndefined();
185
+ });
186
+
187
+ it("uses the localized upload failure when the caller has no safe detail", async () => {
188
+ const onUpload = vi
189
+ .fn()
190
+ .mockRejectedValue(new Error("private server detail"));
191
+ await renderPicker(onUpload);
192
+
193
+ await choose(new File(["first"], "first.webp", { type: "image/webp" }));
194
+ expect(alertText()).toBe(labels.uploadFailed);
195
+ expect(container.textContent).not.toContain("private server detail");
196
+ });
197
+
198
+ it("makes an unavailable upload library visible and retryable", async () => {
199
+ const onRetry = vi.fn();
200
+ await act(async () => {
201
+ root.render(
202
+ <ResourceIconPicker
203
+ value={previousIcon}
204
+ onValueChange={vi.fn()}
205
+ onUpload={vi.fn().mockResolvedValue(uploadedIcon)}
206
+ uploadedImagesError
207
+ onUploadedImagesRetry={onRetry}
208
+ labels={labels}
209
+ open
210
+ portalled={false}
211
+ />,
212
+ );
213
+ });
214
+ const uploadTab = Array.from(container.querySelectorAll("[role=tab]")).find(
215
+ (tab) => tab.textContent === labels.uploadTab,
216
+ );
217
+ await act(async () => {
218
+ uploadTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
219
+ uploadTab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
220
+ });
221
+
222
+ expect(alertText()).toContain(labels.loadError);
223
+ const retryButton = Array.from(container.querySelectorAll("button")).find(
224
+ (button) => button.textContent === labels.retry,
225
+ );
226
+ await act(async () => retryButton!.click());
227
+ expect(onRetry).toHaveBeenCalledOnce();
228
+ });
229
+
230
+ it("does not offer private recent images outside the current library", async () => {
231
+ const personal: ResourceIconImage = {
232
+ version: 1,
233
+ kind: "image",
234
+ authority: "private-icon",
235
+ assetId: "personal-asset",
236
+ alt: "Personal logo",
237
+ };
238
+ const workspace: ResourceIconImage = {
239
+ ...personal,
240
+ assetId: "workspace-asset",
241
+ alt: "Workspace logo",
242
+ };
243
+ await act(async () => {
244
+ root.render(
245
+ <ResourceIconPicker
246
+ value={previousIcon}
247
+ onValueChange={vi.fn()}
248
+ onUpload={vi.fn()}
249
+ uploadedImages={[workspace]}
250
+ recentValues={[personal, workspace]}
251
+ labels={labels}
252
+ open
253
+ portalled={false}
254
+ />,
255
+ );
256
+ });
257
+ const uploadTab = Array.from(container.querySelectorAll("[role=tab]")).find(
258
+ (tab) => tab.textContent === labels.uploadTab,
259
+ );
260
+ await act(async () => {
261
+ uploadTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
262
+ uploadTab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
263
+ });
264
+
265
+ expect(
266
+ container.querySelector('[aria-label="Workspace logo"]'),
267
+ ).not.toBeNull();
268
+ expect(container.querySelector('[aria-label="Personal logo"]')).toBeNull();
269
+ });
270
+ });