@stigmer/react 3.12.4 → 3.12.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 (190) hide show
  1. package/attachment/AttachmentChipList.js +16 -6
  2. package/attachment/AttachmentChipList.js.map +1 -1
  3. package/attachment/index.d.ts +1 -1
  4. package/attachment/index.d.ts.map +1 -1
  5. package/attachment/index.js.map +1 -1
  6. package/attachment/useAttachments.d.ts +51 -8
  7. package/attachment/useAttachments.d.ts.map +1 -1
  8. package/attachment/useAttachments.js +74 -3
  9. package/attachment/useAttachments.js.map +1 -1
  10. package/composer/SessionComposer.d.ts.map +1 -1
  11. package/composer/SessionComposer.js +17 -10
  12. package/composer/SessionComposer.js.map +1 -1
  13. package/conversation/ConversationComposer.d.ts +19 -3
  14. package/conversation/ConversationComposer.d.ts.map +1 -1
  15. package/conversation/ConversationComposer.js +4 -4
  16. package/conversation/ConversationComposer.js.map +1 -1
  17. package/conversation/ConversationTemplatePickerDialog.d.ts +63 -0
  18. package/conversation/ConversationTemplatePickerDialog.d.ts.map +1 -0
  19. package/conversation/ConversationTemplatePickerDialog.js +151 -0
  20. package/conversation/ConversationTemplatePickerDialog.js.map +1 -0
  21. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  22. package/conversation/ConversationsWorkbench.js +16 -5
  23. package/conversation/ConversationsWorkbench.js.map +1 -1
  24. package/conversation/index.d.ts +3 -1
  25. package/conversation/index.d.ts.map +1 -1
  26. package/conversation/index.js +1 -0
  27. package/conversation/index.js.map +1 -1
  28. package/conversation/useConversationParticipation.d.ts +41 -4
  29. package/conversation/useConversationParticipation.d.ts.map +1 -1
  30. package/conversation/useConversationParticipation.js +13 -2
  31. package/conversation/useConversationParticipation.js.map +1 -1
  32. package/identity-account/AccountPreferencesPanel.d.ts +35 -0
  33. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -0
  34. package/identity-account/AccountPreferencesPanel.js +187 -0
  35. package/identity-account/AccountPreferencesPanel.js.map +1 -0
  36. package/identity-account/index.d.ts +8 -0
  37. package/identity-account/index.d.ts.map +1 -1
  38. package/identity-account/index.js +4 -0
  39. package/identity-account/index.js.map +1 -1
  40. package/identity-account/useAccountExecutionDefaults.d.ts +36 -0
  41. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -0
  42. package/identity-account/useAccountExecutionDefaults.js +46 -0
  43. package/identity-account/useAccountExecutionDefaults.js.map +1 -0
  44. package/identity-account/useMyIdentityAccount.d.ts +55 -0
  45. package/identity-account/useMyIdentityAccount.d.ts.map +1 -0
  46. package/identity-account/useMyIdentityAccount.js +37 -0
  47. package/identity-account/useMyIdentityAccount.js.map +1 -0
  48. package/identity-account/useUpdateIdentityAccount.d.ts +42 -0
  49. package/identity-account/useUpdateIdentityAccount.d.ts.map +1 -0
  50. package/identity-account/useUpdateIdentityAccount.js +53 -0
  51. package/identity-account/useUpdateIdentityAccount.js.map +1 -0
  52. package/index.d.ts +11 -9
  53. package/index.d.ts.map +1 -1
  54. package/index.js +8 -5
  55. package/index.js.map +1 -1
  56. package/internal/StandingContextField.d.ts +37 -0
  57. package/internal/StandingContextField.d.ts.map +1 -0
  58. package/internal/StandingContextField.js +24 -0
  59. package/internal/StandingContextField.js.map +1 -0
  60. package/library/InstanceVisibilitySelector.d.ts.map +1 -1
  61. package/library/InstanceVisibilitySelector.js +9 -2
  62. package/library/InstanceVisibilitySelector.js.map +1 -1
  63. package/library/ResourceVisibilityControl.d.ts +13 -3
  64. package/library/ResourceVisibilityControl.d.ts.map +1 -1
  65. package/library/ResourceVisibilityControl.js +33 -5
  66. package/library/ResourceVisibilityControl.js.map +1 -1
  67. package/library/VisibilityOptionRow.d.ts +6 -0
  68. package/library/VisibilityOptionRow.d.ts.map +1 -1
  69. package/library/VisibilityOptionRow.js +11 -1
  70. package/library/VisibilityOptionRow.js.map +1 -1
  71. package/library/VisibilitySelector.d.ts.map +1 -1
  72. package/library/VisibilitySelector.js +14 -2
  73. package/library/VisibilitySelector.js.map +1 -1
  74. package/library/index.d.ts +4 -2
  75. package/library/index.d.ts.map +1 -1
  76. package/library/index.js +2 -1
  77. package/library/index.js.map +1 -1
  78. package/library/useCanSetPublicVisibility.d.ts +30 -0
  79. package/library/useCanSetPublicVisibility.d.ts.map +1 -0
  80. package/library/useCanSetPublicVisibility.js +41 -0
  81. package/library/useCanSetPublicVisibility.js.map +1 -0
  82. package/library/visibilityLevels.d.ts +32 -1
  83. package/library/visibilityLevels.d.ts.map +1 -1
  84. package/library/visibilityLevels.js +34 -14
  85. package/library/visibilityLevels.js.map +1 -1
  86. package/mcp-server/useMcpServerConnect.d.ts +8 -5
  87. package/mcp-server/useMcpServerConnect.d.ts.map +1 -1
  88. package/mcp-server/useMcpServerConnect.js +2 -1
  89. package/mcp-server/useMcpServerConnect.js.map +1 -1
  90. package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
  91. package/mcp-server/useMcpServerOAuthConnect.js +7 -2
  92. package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
  93. package/oauth-app/CreateOAuthAppForm.d.ts +2 -2
  94. package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -1
  95. package/oauth-app/CreateOAuthAppForm.js +13 -4
  96. package/oauth-app/CreateOAuthAppForm.js.map +1 -1
  97. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -1
  98. package/oauth-app/OAuthAppDetailPanel.js +32 -5
  99. package/oauth-app/OAuthAppDetailPanel.js.map +1 -1
  100. package/organization/OrgPreferencesPanel.d.ts +32 -0
  101. package/organization/OrgPreferencesPanel.d.ts.map +1 -0
  102. package/organization/OrgPreferencesPanel.js +90 -0
  103. package/organization/OrgPreferencesPanel.js.map +1 -0
  104. package/organization/OrgProfilePanel.d.ts.map +1 -1
  105. package/organization/OrgProfilePanel.js +5 -4
  106. package/organization/OrgProfilePanel.js.map +1 -1
  107. package/organization/index.d.ts +2 -0
  108. package/organization/index.d.ts.map +1 -1
  109. package/organization/index.js +1 -0
  110. package/organization/index.js.map +1 -1
  111. package/package.json +4 -4
  112. package/session/NewSessionViewer.d.ts +10 -1
  113. package/session/NewSessionViewer.d.ts.map +1 -1
  114. package/session/NewSessionViewer.js +2 -1
  115. package/session/NewSessionViewer.js.map +1 -1
  116. package/session/useNewSessionFlow.d.ts +27 -1
  117. package/session/useNewSessionFlow.d.ts.map +1 -1
  118. package/session/useNewSessionFlow.js +45 -8
  119. package/session/useNewSessionFlow.js.map +1 -1
  120. package/settings/AccountPreferencesSection.d.ts +3 -0
  121. package/settings/AccountPreferencesSection.d.ts.map +1 -0
  122. package/settings/AccountPreferencesSection.js +10 -0
  123. package/settings/AccountPreferencesSection.js.map +1 -0
  124. package/settings/OrgPreferencesSection.d.ts +3 -0
  125. package/settings/OrgPreferencesSection.d.ts.map +1 -0
  126. package/settings/OrgPreferencesSection.js +13 -0
  127. package/settings/OrgPreferencesSection.js.map +1 -0
  128. package/settings/index.d.ts +2 -0
  129. package/settings/index.d.ts.map +1 -1
  130. package/settings/index.js +2 -0
  131. package/settings/index.js.map +1 -1
  132. package/settings/settings-nav.d.ts.map +1 -1
  133. package/settings/settings-nav.js +17 -1
  134. package/settings/settings-nav.js.map +1 -1
  135. package/src/attachment/AttachmentChipList.tsx +19 -8
  136. package/src/attachment/__tests__/AttachmentChipList.test.tsx +12 -0
  137. package/src/attachment/__tests__/useAttachments.test.ts +293 -0
  138. package/src/attachment/index.ts +1 -0
  139. package/src/attachment/useAttachments.ts +143 -11
  140. package/src/composer/SessionComposer.tsx +23 -14
  141. package/src/conversation/ConversationComposer.tsx +38 -4
  142. package/src/conversation/ConversationTemplatePickerDialog.tsx +441 -0
  143. package/src/conversation/ConversationsWorkbench.tsx +40 -4
  144. package/src/conversation/__tests__/ConversationComposer.test.tsx +56 -1
  145. package/src/conversation/__tests__/ConversationTemplatePickerDialog.test.tsx +224 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +45 -4
  147. package/src/conversation/index.ts +4 -0
  148. package/src/conversation/useConversationParticipation.ts +58 -6
  149. package/src/identity-account/AccountPreferencesPanel.tsx +446 -0
  150. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +233 -0
  151. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +116 -0
  152. package/src/identity-account/__tests__/useMyIdentityAccount.test.tsx +121 -0
  153. package/src/identity-account/index.ts +11 -0
  154. package/src/identity-account/useAccountExecutionDefaults.ts +63 -0
  155. package/src/identity-account/useMyIdentityAccount.ts +75 -0
  156. package/src/identity-account/useUpdateIdentityAccount.ts +73 -0
  157. package/src/index.ts +24 -2
  158. package/src/internal/StandingContextField.tsx +92 -0
  159. package/src/library/InstanceVisibilitySelector.tsx +9 -2
  160. package/src/library/ResourceVisibilityControl.tsx +46 -13
  161. package/src/library/VisibilityOptionRow.tsx +28 -6
  162. package/src/library/VisibilitySelector.tsx +14 -2
  163. package/src/library/__tests__/VisibilitySelector.test.tsx +48 -1
  164. package/src/library/__tests__/visibilityLevels.test.ts +67 -1
  165. package/src/library/index.ts +6 -1
  166. package/src/library/useCanSetPublicVisibility.ts +56 -0
  167. package/src/library/visibilityLevels.ts +65 -14
  168. package/src/mcp-server/__tests__/useMcpServerConnect.asyncLane.test.tsx +117 -0
  169. package/src/mcp-server/useMcpServerConnect.ts +9 -6
  170. package/src/mcp-server/useMcpServerOAuthConnect.ts +7 -2
  171. package/src/oauth-app/CreateOAuthAppForm.tsx +58 -3
  172. package/src/oauth-app/OAuthAppDetailPanel.tsx +84 -3
  173. package/src/organization/OrgPreferencesPanel.tsx +221 -0
  174. package/src/organization/OrgProfilePanel.tsx +5 -4
  175. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +145 -0
  176. package/src/organization/__tests__/OrgProfilePanel.test.tsx +108 -0
  177. package/src/organization/index.ts +2 -0
  178. package/src/session/NewSessionViewer.tsx +12 -0
  179. package/src/session/__tests__/useNewSessionFlow.test.tsx +151 -0
  180. package/src/session/useNewSessionFlow.ts +76 -9
  181. package/src/settings/AccountPreferencesSection.tsx +25 -0
  182. package/src/settings/OrgPreferencesSection.tsx +35 -0
  183. package/src/settings/__tests__/settings-nav.test.ts +27 -0
  184. package/src/settings/index.ts +2 -0
  185. package/src/settings/settings-nav.ts +18 -0
  186. package/src/workflow/ApprovalFormBuilder.tsx +5 -27
  187. package/styles.css +1 -1
  188. package/workflow/ApprovalFormBuilder.d.ts.map +1 -1
  189. package/workflow/ApprovalFormBuilder.js +8 -5
  190. package/workflow/ApprovalFormBuilder.js.map +1 -1
@@ -1,4 +1,4 @@
1
- import { Activity, AppWindow, BarChart3, Box, Building2, CreditCard, KeyRound, Link, MessageSquare, MousePointerClick, Plug, Scale, ShieldCheck, Users, } from "lucide-react";
1
+ import { Activity, AppWindow, BarChart3, Box, Building2, CreditCard, KeyRound, Link, MessageSquare, MousePointerClick, Plug, Scale, ShieldCheck, SlidersHorizontal, UserCog, Users, } from "lucide-react";
2
2
  /** Canonical settings sidebar/grouped navigation model. */
3
3
  export const SETTINGS_NAV_GROUPS = [
4
4
  {
@@ -6,6 +6,11 @@ export const SETTINGS_NAV_GROUPS = [
6
6
  description: "Manage your team, organization identity, and identity providers.",
7
7
  items: [
8
8
  { href: "/settings/org-profile", label: "Org Profile", icon: Building2 },
9
+ {
10
+ href: "/settings/org-preferences",
11
+ label: "Preferences",
12
+ icon: SlidersHorizontal,
13
+ },
9
14
  { href: "/settings/members", label: "Members", icon: Users },
10
15
  { href: "/settings/invitations", label: "Invitations", icon: Link },
11
16
  {
@@ -42,6 +47,17 @@ export const SETTINGS_NAV_GROUPS = [
42
47
  { href: "/settings/usage", label: "Usage", icon: BarChart3 },
43
48
  ],
44
49
  },
50
+ {
51
+ label: "Account",
52
+ description: "Personal settings that apply to you across the platform.",
53
+ items: [
54
+ {
55
+ href: "/settings/account-preferences",
56
+ label: "Preferences",
57
+ icon: UserCog,
58
+ },
59
+ ],
60
+ },
45
61
  ];
46
62
  /**
47
63
  * Stigmer-internal platform-operator navigation group.
@@ -1 +1 @@
1
- {"version":3,"file":"settings-nav.js","sourceRoot":"","sources":["../../src/settings/settings-nav.ts"],"names":[],"mappings":"AACA,OAAO,EACL,QAAQ,EACR,SAAS,EACT,SAAS,EACT,GAAG,EACH,SAAS,EACT,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,aAAa,EACb,iBAAiB,EACjB,IAAI,EACJ,KAAK,EACL,WAAW,EACX,KAAK,GACN,MAAM,cAAc,CAAC;AAiCtB,2DAA2D;AAC3D,MAAM,CAAC,MAAM,mBAAmB,GAAgC;IAC9D;QACE,KAAK,EAAE,cAAc;QACrB,WAAW,EACT,kEAAkE;QACpE,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,uBAAuB,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,EAAE;YACxE,EAAE,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE;YAC5D,EAAE,IAAI,EAAE,uBAAuB,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE;YACnE;gBACE,IAAI,EAAE,8BAA8B;gBACpC,KAAK,EAAE,oBAAoB;gBAC3B,IAAI,EAAE,WAAW;aAClB;SACF;KACF;IACD;QACE,KAAK,EAAE,eAAe;QACtB,WAAW,EACT,mFAAmF;QACrF,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,oBAAoB,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE;YACjE;gBACE,IAAI,EAAE,4BAA4B;gBAClC,KAAK,EAAE,kBAAkB;gBACzB,IAAI,EAAE,IAAI;aACX;YACD,EAAE,IAAI,EAAE,wBAAwB,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,GAAG,EAAE;YACpE,EAAE,IAAI,EAAE,sBAAsB,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE;YACtE;gBACE,IAAI,EAAE,wBAAwB;gBAC9B,KAAK,EAAE,cAAc;gBACrB,IAAI,EAAE,aAAa;aACpB;SACF;KACF;IACD;QACE,KAAK,EAAE,iBAAiB;QACxB,WAAW,EAAE,sCAAsC;QACnD,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE;YACjE,EAAE,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;SAC7D;KACF;CACF,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAqB;IAC3D,KAAK,EAAE,UAAU;IACjB,WAAW,EACT,kEAAkE;IACpE,KAAK,EAAE;QACL;YACE,IAAI,EAAE,8BAA8B;YACpC,KAAK,EAAE,oBAAoB;YAC3B,IAAI,EAAE,KAAK;YACX,kBAAkB,EAAE,0BAA0B;SAC/C;QACD;YACE,IAAI,EAAE,2BAA2B;YACjC,KAAK,EAAE,iBAAiB;YACxB,IAAI,EAAE,iBAAiB;YACvB,kBAAkB,EAAE,4BAA4B;SACjD;QACD;YACE,IAAI,EAAE,6BAA6B;YACnC,KAAK,EAAE,mBAAmB;YAC1B,IAAI,EAAE,QAAQ;YACd,kBAAkB,EAAE,4BAA4B;SACjD;KACF;CACF,CAAC"}
1
+ {"version":3,"file":"settings-nav.js","sourceRoot":"","sources":["../../src/settings/settings-nav.ts"],"names":[],"mappings":"AACA,OAAO,EACL,QAAQ,EACR,SAAS,EACT,SAAS,EACT,GAAG,EACH,SAAS,EACT,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,aAAa,EACb,iBAAiB,EACjB,IAAI,EACJ,KAAK,EACL,WAAW,EACX,iBAAiB,EACjB,OAAO,EACP,KAAK,GACN,MAAM,cAAc,CAAC;AAiCtB,2DAA2D;AAC3D,MAAM,CAAC,MAAM,mBAAmB,GAAgC;IAC9D;QACE,KAAK,EAAE,cAAc;QACrB,WAAW,EACT,kEAAkE;QACpE,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,uBAAuB,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,EAAE;YACxE;gBACE,IAAI,EAAE,2BAA2B;gBACjC,KAAK,EAAE,aAAa;gBACpB,IAAI,EAAE,iBAAiB;aACxB;YACD,EAAE,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE;YAC5D,EAAE,IAAI,EAAE,uBAAuB,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE;YACnE;gBACE,IAAI,EAAE,8BAA8B;gBACpC,KAAK,EAAE,oBAAoB;gBAC3B,IAAI,EAAE,WAAW;aAClB;SACF;KACF;IACD;QACE,KAAK,EAAE,eAAe;QACtB,WAAW,EACT,mFAAmF;QACrF,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,oBAAoB,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE;YACjE;gBACE,IAAI,EAAE,4BAA4B;gBAClC,KAAK,EAAE,kBAAkB;gBACzB,IAAI,EAAE,IAAI;aACX;YACD,EAAE,IAAI,EAAE,wBAAwB,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,GAAG,EAAE;YACpE,EAAE,IAAI,EAAE,sBAAsB,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE;YACtE;gBACE,IAAI,EAAE,wBAAwB;gBAC9B,KAAK,EAAE,cAAc;gBACrB,IAAI,EAAE,aAAa;aACpB;SACF;KACF;IACD;QACE,KAAK,EAAE,iBAAiB;QACxB,WAAW,EAAE,sCAAsC;QACnD,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE;YACjE,EAAE,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;SAC7D;KACF;IACD;QACE,KAAK,EAAE,SAAS;QAChB,WAAW,EAAE,0DAA0D;QACvE,KAAK,EAAE;YACL;gBACE,IAAI,EAAE,+BAA+B;gBACrC,KAAK,EAAE,aAAa;gBACpB,IAAI,EAAE,OAAO;aACd;SACF;KACF;CACF,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAqB;IAC3D,KAAK,EAAE,UAAU;IACjB,WAAW,EACT,kEAAkE;IACpE,KAAK,EAAE;QACL;YACE,IAAI,EAAE,8BAA8B;YACpC,KAAK,EAAE,oBAAoB;YAC3B,IAAI,EAAE,KAAK;YACX,kBAAkB,EAAE,0BAA0B;SAC/C;QACD;YACE,IAAI,EAAE,2BAA2B;YACjC,KAAK,EAAE,iBAAiB;YACxB,IAAI,EAAE,iBAAiB;YACvB,kBAAkB,EAAE,4BAA4B;SACjD;QACD;YACE,IAAI,EAAE,6BAA6B;YACnC,KAAK,EAAE,mBAAmB;YAC1B,IAAI,EAAE,QAAQ;YACd,kBAAkB,EAAE,4BAA4B;SACjD;KACF;CACF,CAAC"}
@@ -154,9 +154,20 @@ function AttachmentChip({
154
154
 
155
155
  /** Shared accessible name: filename, size, and upload status. */
156
156
  function chipAriaLabel(entry: AttachmentEntry): string {
157
+ const isPreparing = entry.phase === "preparing";
157
158
  const isUploading = entry.phase === "uploading";
158
159
  const isError = entry.phase === "error";
159
- return `${entry.file.name}, ${formatFileSize(entry.file.size)}${isUploading ? ", uploading" : ""}${isError ? ", upload failed" : ""}`;
160
+ return `${entry.file.name}, ${formatFileSize(entry.file.size)}${isPreparing ? ", preparing" : ""}${isUploading ? ", uploading" : ""}${isError ? ", upload failed" : ""}`;
161
+ }
162
+
163
+ /**
164
+ * True while the entry has work in flight (`preparing` or `uploading`) —
165
+ * the chips render both identically (dimmed + spinner): to the user each
166
+ * means "your file is on its way", and the split is audible in the
167
+ * accessible label where it actually informs.
168
+ */
169
+ function isChipBusy(entry: AttachmentEntry): boolean {
170
+ return entry.phase === "preparing" || entry.phase === "uploading";
160
171
  }
161
172
 
162
173
  // ---------------------------------------------------------------------------
@@ -212,7 +223,7 @@ function ImageAttachmentChip({
212
223
  }
213
224
 
214
225
  const isError = entry.phase === "error";
215
- const isUploading = entry.phase === "uploading";
226
+ const isBusy = isChipBusy(entry);
216
227
 
217
228
  return (
218
229
  // The remove badge and retry pill are SIBLINGS of the preview button,
@@ -250,7 +261,7 @@ function ImageAttachmentChip({
250
261
  onError={() => setLoadFailed(true)}
251
262
  className={cn(
252
263
  "stg:h-full stg:w-full stg:object-cover",
253
- (isUploading || isError) && "stg:opacity-50",
264
+ (isBusy || isError) && "stg:opacity-50",
254
265
  )}
255
266
  />
256
267
  ) : (
@@ -261,7 +272,7 @@ function ImageAttachmentChip({
261
272
  aria-hidden="true"
262
273
  />
263
274
  )}
264
- {isUploading && (
275
+ {isBusy && (
265
276
  <span className="stg:absolute stg:inset-0 stg:flex stg:items-center stg:justify-center">
266
277
  <ChipSpinner size={16} />
267
278
  </span>
@@ -322,7 +333,7 @@ function FileAttachmentChip({
322
333
  readonly disabled?: boolean;
323
334
  }) {
324
335
  const isError = entry.phase === "error";
325
- const isUploading = entry.phase === "uploading";
336
+ const isBusy = isChipBusy(entry);
326
337
 
327
338
  return (
328
339
  <span
@@ -333,12 +344,12 @@ function FileAttachmentChip({
333
344
  isError
334
345
  ? "stg:border stg:border-destructive/30 stg:bg-destructive-subtle stg:text-destructive"
335
346
  : "stg:bg-muted-subtle stg:text-foreground",
336
- isUploading && "stg:opacity-70",
347
+ isBusy && "stg:opacity-70",
337
348
  )}
338
349
  >
339
- {isUploading && <ChipSpinner />}
350
+ {isBusy && <ChipSpinner />}
340
351
  {isError && <ErrorDot />}
341
- {!isUploading && !isError && <FileIcon />}
352
+ {!isBusy && !isError && <FileIcon />}
342
353
 
343
354
  <span className="stg:truncate">{entry.file.name}</span>
344
355
 
@@ -101,6 +101,18 @@ describe("AttachmentChipList — image miniature", () => {
101
101
  expect(container.querySelector("svg.stg\\:animate-spin")).toBeTruthy();
102
102
  });
103
103
 
104
+ it("treats the preparing phase as busy: spinner over the miniature, 'preparing' in the accessible label (#369)", async () => {
105
+ const preparing = entry({ id: "prep", phase: "preparing", storageKey: null });
106
+
107
+ const { container } = renderChips([preparing]);
108
+
109
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
110
+ expect(container.querySelector("svg.stg\\:animate-spin")).toBeTruthy();
111
+ expect(
112
+ screen.getByRole("listitem", { name: /shot\.png.*preparing/ }),
113
+ ).toBeTruthy();
114
+ });
115
+
104
116
  it("renders image chips preview-only: no visible filename or size text", async () => {
105
117
  const { container } = renderChips([entry()]);
106
118
 
@@ -0,0 +1,293 @@
1
+ /**
2
+ * useAttachments lifecycle tests, centered on the `preparing` phase
3
+ * (stigmer/stigmer#369): entries must exist from the instant a
4
+ * prepare-flagged file is added, transition through preparation to
5
+ * upload, and honor the deferred-validation and cancellation contracts
6
+ * documented on {@link AddFilesOptions.prepareImages}.
7
+ */
8
+ import { describe, it, expect, vi, afterEach } from "vitest";
9
+ import { renderHook, act, waitFor } from "@testing-library/react";
10
+
11
+ vi.mock("../../hooks", () => ({
12
+ useStigmer: vi.fn(),
13
+ }));
14
+ vi.mock("../prepare-image.js", () => ({
15
+ prepareImageForVision: vi.fn(),
16
+ }));
17
+
18
+ import { useStigmer } from "../../hooks";
19
+ import { prepareImageForVision } from "../prepare-image.js";
20
+ import { useAttachments } from "../useAttachments.js";
21
+ import { MAX_ATTACHMENT_BYTES } from "../attachment-utils.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function deferred<T>() {
28
+ let resolve!: (value: T) => void;
29
+ const promise = new Promise<T>((r) => {
30
+ resolve = r;
31
+ });
32
+ return { promise, resolve };
33
+ }
34
+
35
+ function makeFile(name: string, sizeBytes: number, type = "image/png"): File {
36
+ return new File([new Uint8Array(sizeBytes)], name, { type });
37
+ }
38
+
39
+ /** Wires useStigmer to an uploadAttachment mock that resolves immediately. */
40
+ function mockStigmer() {
41
+ const uploadAttachment = vi
42
+ .fn()
43
+ .mockResolvedValue({ storageKey: "attachments/test/key" });
44
+ vi.mocked(useStigmer).mockReturnValue({
45
+ agentExecution: { uploadAttachment },
46
+ } as unknown as ReturnType<typeof useStigmer>);
47
+ return { uploadAttachment };
48
+ }
49
+
50
+ const prepareMock = vi.mocked(prepareImageForVision);
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks();
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // The preparing phase
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe("useAttachments — prepareImages lifecycle", () => {
61
+ it("shows the entry immediately in the preparing phase, gating submit", () => {
62
+ mockStigmer();
63
+ const gap = deferred<File>();
64
+ prepareMock.mockReturnValue(gap.promise);
65
+
66
+ const { result } = renderHook(() => useAttachments());
67
+
68
+ act(() => {
69
+ result.current.addFiles([makeFile("shot.png", 1024)], {
70
+ prepareImages: true,
71
+ });
72
+ });
73
+
74
+ expect(result.current.entries).toHaveLength(1);
75
+ expect(result.current.entries[0].phase).toBe("preparing");
76
+ expect(result.current.entries[0].file.name).toBe("shot.png");
77
+ expect(result.current.isPreparing).toBe(true);
78
+ // preparing gates exactly like uploading — the silent-drop guard.
79
+ expect(result.current.isUploading).toBe(true);
80
+ expect(result.current.allReady).toBe(false);
81
+ });
82
+
83
+ it("swaps to the prepared file (name, type) and proceeds to upload → ready", async () => {
84
+ const { uploadAttachment } = mockStigmer();
85
+ const gap = deferred<File>();
86
+ prepareMock.mockReturnValue(gap.promise);
87
+
88
+ const { result } = renderHook(() => useAttachments());
89
+
90
+ act(() => {
91
+ result.current.addFiles([makeFile("shot.png", 4096)], {
92
+ prepareImages: true,
93
+ });
94
+ });
95
+
96
+ // Preparation re-encoded to JPEG: name and MIME follow.
97
+ act(() => {
98
+ gap.resolve(makeFile("shot.jpg", 2048, "image/jpeg"));
99
+ });
100
+
101
+ await waitFor(() => {
102
+ expect(result.current.entries[0].phase).toBe("ready");
103
+ });
104
+ expect(result.current.entries[0].file.name).toBe("shot.jpg");
105
+ expect(result.current.entries[0].contentType).toBe("image/jpeg");
106
+ expect(uploadAttachment).toHaveBeenCalledTimes(1);
107
+ expect(result.current.toAttachmentInputs()).toEqual([
108
+ {
109
+ filename: "shot.jpg",
110
+ storageKey: "attachments/test/key",
111
+ contentType: "image/jpeg",
112
+ },
113
+ ]);
114
+ });
115
+
116
+ it("accepts a raw file over the size limit when preparation brings it under (the #369 paste contract)", async () => {
117
+ const { uploadAttachment } = mockStigmer();
118
+ const onValidationError = vi.fn();
119
+ const gap = deferred<File>();
120
+ prepareMock.mockReturnValue(gap.promise);
121
+
122
+ const { result } = renderHook(() =>
123
+ useAttachments({ onValidationError }),
124
+ );
125
+
126
+ // 10 MB + 1 raw screenshot — would be rejected by up-front validation.
127
+ act(() => {
128
+ result.current.addFiles(
129
+ [makeFile("huge-screenshot.png", MAX_ATTACHMENT_BYTES + 1)],
130
+ { prepareImages: true },
131
+ );
132
+ });
133
+ expect(result.current.entries[0].phase).toBe("preparing");
134
+
135
+ act(() => {
136
+ gap.resolve(makeFile("huge-screenshot.png", 512 * 1024));
137
+ });
138
+
139
+ await waitFor(() => {
140
+ expect(result.current.entries[0].phase).toBe("ready");
141
+ });
142
+ expect(onValidationError).not.toHaveBeenCalled();
143
+ expect(uploadAttachment).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it("removes the entry and reports via onValidationError when still oversized after preparation", async () => {
147
+ const { uploadAttachment } = mockStigmer();
148
+ const onValidationError = vi.fn();
149
+ const oversized = makeFile("giant.zip", MAX_ATTACHMENT_BYTES + 1, "application/zip");
150
+ // Non-image passthrough: preparation returns the original untouched.
151
+ prepareMock.mockResolvedValue(oversized);
152
+
153
+ const { result } = renderHook(() =>
154
+ useAttachments({ onValidationError }),
155
+ );
156
+
157
+ act(() => {
158
+ result.current.addFiles([oversized], { prepareImages: true });
159
+ });
160
+ expect(result.current.entries).toHaveLength(1);
161
+
162
+ await waitFor(() => {
163
+ expect(result.current.entries).toHaveLength(0);
164
+ });
165
+ expect(onValidationError).toHaveBeenCalledWith(
166
+ expect.stringContaining("giant.zip"),
167
+ );
168
+ expect(uploadAttachment).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it("discards the preparation result when the entry is removed mid-preparation", async () => {
172
+ const { uploadAttachment } = mockStigmer();
173
+ const gap = deferred<File>();
174
+ prepareMock.mockReturnValue(gap.promise);
175
+
176
+ const { result } = renderHook(() => useAttachments());
177
+
178
+ act(() => {
179
+ result.current.addFiles([makeFile("shot.png", 1024)], {
180
+ prepareImages: true,
181
+ });
182
+ });
183
+ const id = result.current.entries[0].id;
184
+
185
+ act(() => {
186
+ result.current.removeEntry(id);
187
+ });
188
+ expect(result.current.entries).toHaveLength(0);
189
+
190
+ // Late completion must not resurrect the entry or start an upload.
191
+ await act(async () => {
192
+ gap.resolve(makeFile("shot.jpg", 512, "image/jpeg"));
193
+ await gap.promise;
194
+ });
195
+ expect(result.current.entries).toHaveLength(0);
196
+ expect(uploadAttachment).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("discards the preparation result on clear()", async () => {
200
+ const { uploadAttachment } = mockStigmer();
201
+ const gap = deferred<File>();
202
+ prepareMock.mockReturnValue(gap.promise);
203
+
204
+ const { result } = renderHook(() => useAttachments());
205
+
206
+ act(() => {
207
+ result.current.addFiles([makeFile("shot.png", 1024)], {
208
+ prepareImages: true,
209
+ });
210
+ });
211
+
212
+ act(() => {
213
+ result.current.clear();
214
+ });
215
+
216
+ await act(async () => {
217
+ gap.resolve(makeFile("shot.jpg", 512, "image/jpeg"));
218
+ await gap.promise;
219
+ });
220
+ expect(result.current.entries).toHaveLength(0);
221
+ expect(uploadAttachment).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it("uniquifies the prepared name against other entries' settled names", async () => {
225
+ mockStigmer();
226
+ const gap = deferred<File>();
227
+ prepareMock.mockReturnValue(gap.promise);
228
+
229
+ const { result } = renderHook(() => useAttachments());
230
+
231
+ // A settled entry already owns "pasted-image.png".
232
+ act(() => {
233
+ result.current.addFiles([makeFile("pasted-image.png", 256)]);
234
+ });
235
+ // The prepared file lands on the same name.
236
+ act(() => {
237
+ result.current.addFiles([makeFile("other.png", 256)], {
238
+ prepareImages: true,
239
+ });
240
+ });
241
+ act(() => {
242
+ gap.resolve(makeFile("pasted-image.png", 128));
243
+ });
244
+
245
+ await waitFor(() => {
246
+ expect(result.current.entries[1].phase).toBe("ready");
247
+ });
248
+ expect(result.current.entries[1].file.name).toBe("pasted-image-2.png");
249
+ });
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // The unflagged path — byte-identical to the pre-#369 behavior
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe("useAttachments — unflagged addFiles", () => {
257
+ it("never enters the preparing phase and never calls prepareImageForVision", async () => {
258
+ mockStigmer();
259
+
260
+ const { result } = renderHook(() => useAttachments());
261
+
262
+ act(() => {
263
+ result.current.addFiles([makeFile("picked.png", 1024)]);
264
+ });
265
+
266
+ expect(result.current.entries[0].phase).toBe("uploading");
267
+ expect(result.current.isPreparing).toBe(false);
268
+ expect(prepareMock).not.toHaveBeenCalled();
269
+
270
+ await waitFor(() => {
271
+ expect(result.current.entries[0].phase).toBe("ready");
272
+ });
273
+ });
274
+
275
+ it("still validates size up front and rejects without creating an entry", () => {
276
+ const { uploadAttachment } = mockStigmer();
277
+ const onValidationError = vi.fn();
278
+
279
+ const { result } = renderHook(() =>
280
+ useAttachments({ onValidationError }),
281
+ );
282
+
283
+ act(() => {
284
+ result.current.addFiles([
285
+ makeFile("too-big.png", MAX_ATTACHMENT_BYTES + 1),
286
+ ]);
287
+ });
288
+
289
+ expect(result.current.entries).toHaveLength(0);
290
+ expect(onValidationError).toHaveBeenCalledTimes(1);
291
+ expect(uploadAttachment).not.toHaveBeenCalled();
292
+ });
293
+ });
@@ -1,5 +1,6 @@
1
1
  export { useAttachments } from "./useAttachments.js";
2
2
  export type {
3
+ AddFilesOptions,
3
4
  AttachmentPhase,
4
5
  AttachmentEntry,
5
6
  UseAttachmentsOptions,
@@ -12,6 +12,7 @@ import {
12
12
  uniquifyFilename,
13
13
  validateAttachmentSize,
14
14
  } from "./attachment-utils.js";
15
+ import { prepareImageForVision } from "./prepare-image.js";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Types
@@ -20,11 +21,16 @@ import {
20
21
  /**
21
22
  * Lifecycle phase of a single attachment managed by {@link useAttachments}.
22
23
  *
24
+ * - `preparing` — client-side image preparation is running (see
25
+ * {@link AddFilesOptions.prepareImages}); the entry exists so the UI can
26
+ * show the file from the instant it was added, but no bytes have been
27
+ * sent yet. Only entries added with `prepareImages` ever visit this
28
+ * phase — everything else starts at `uploading`.
23
29
  * - `uploading` — bytes are being sent to the server
24
30
  * - `ready` — upload succeeded, `storageKey` is available
25
31
  * - `error` — upload failed, `error` describes the failure
26
32
  */
27
- export type AttachmentPhase = "uploading" | "ready" | "error";
33
+ export type AttachmentPhase = "preparing" | "uploading" | "ready" | "error";
28
34
 
29
35
  /**
30
36
  * State for a single file being managed by the hook.
@@ -50,7 +56,12 @@ export type AttachmentPhase = "uploading" | "ready" | "error";
50
56
  export interface AttachmentEntry {
51
57
  /** Unique client-side identifier for this attachment. */
52
58
  readonly id: string;
53
- /** The original `File` object selected by the user. */
59
+ /**
60
+ * The `File` object backing this attachment. For entries added with
61
+ * {@link AddFilesOptions.prepareImages} this starts as the user's
62
+ * original file and swaps to the prepared file (possibly smaller, with
63
+ * a matching extension/MIME change) when the `preparing` phase ends.
64
+ */
54
65
  readonly file: File;
55
66
  /** Current upload lifecycle phase. */
56
67
  readonly phase: AttachmentPhase;
@@ -71,25 +82,59 @@ export interface UseAttachmentsOptions {
71
82
  readonly onValidationError?: (message: string) => void;
72
83
  }
73
84
 
85
+ /** Per-call options for {@link UseAttachmentsReturn.addFiles}. */
86
+ export interface AddFilesOptions {
87
+ /**
88
+ * Bound image files to provider vision resolution before upload (via
89
+ * {@link prepareImageForVision}) — the paste-to-attach treatment
90
+ * (stigmer/stigmer#284). Each file's entry appears immediately in the
91
+ * `preparing` phase, so a large screenshot shows a chip the instant it
92
+ * is pasted instead of after the 150-350 ms decode/downscale/re-encode
93
+ * (stigmer/stigmer#369). Non-image files pass through preparation
94
+ * untouched.
95
+ *
96
+ * Meant for pasted content only: a picked or dragged file may be the
97
+ * subject of the task ("read the EXIF"), and silently re-encoding it
98
+ * would be a regression — see prepare-image.ts for the doctrine.
99
+ *
100
+ * With this flag, size validation runs against the PREPARED bytes (the
101
+ * bytes that actually upload), so an oversized paste that downscales
102
+ * under the limit is accepted — identical to preparing before calling
103
+ * `addFiles`, which is what this option replaces. A file still
104
+ * oversized after preparation is removed and reported via
105
+ * `onValidationError`.
106
+ */
107
+ readonly prepareImages?: boolean;
108
+ }
109
+
74
110
  /** Return value of {@link useAttachments}. */
75
111
  export interface UseAttachmentsReturn {
76
- /** Current attachment entries (uploading, ready, or errored). */
112
+ /** Current attachment entries (preparing, uploading, ready, or errored). */
77
113
  readonly entries: readonly AttachmentEntry[];
78
- /** True when at least one entry is in the `"uploading"` phase. */
114
+ /**
115
+ * True while any entry has work in flight (`preparing` or
116
+ * `uploading`). This is the submit gate: `toAttachmentInputs()` carries
117
+ * only `ready` entries, so a send while this is true would silently
118
+ * drop the file the user just added.
119
+ */
79
120
  readonly isUploading: boolean;
121
+ /** True when at least one entry is in the `"preparing"` phase. */
122
+ readonly isPreparing: boolean;
80
123
  /** Number of entries that are ready for submission. */
81
124
  readonly readyCount: number;
82
- /** True when there are entries and all are ready (none uploading or errored). */
125
+ /** True when there are entries and all are ready (none preparing, uploading, or errored). */
83
126
  readonly allReady: boolean;
84
127
  /** True when there is at least one entry (in any phase). */
85
128
  readonly hasEntries: boolean;
86
129
  /**
87
130
  * Add one or more files. Each file is validated and uploaded
88
131
  * immediately. Invalid files (e.g., too large) are rejected via
89
- * `onValidationError` and not added.
132
+ * `onValidationError` and not added. With
133
+ * {@link AddFilesOptions.prepareImages}, images are bounded to vision
134
+ * resolution first — entries appear at once in the `preparing` phase.
90
135
  */
91
- readonly addFiles: (files: FileList | File[]) => void;
92
- /** Remove an entry by its stable ID. Aborts in-flight uploads. */
136
+ readonly addFiles: (files: FileList | File[], addOptions?: AddFilesOptions) => void;
137
+ /** Remove an entry by its stable ID. Aborts in-flight preparation and uploads. */
93
138
  readonly removeEntry: (id: string) => void;
94
139
  /** Retry a failed upload. */
95
140
  readonly retryEntry: (id: string) => void;
@@ -220,13 +265,92 @@ export function useAttachments(
220
265
  [stigmer],
221
266
  );
222
267
 
268
+ // The `preparing` half of the lifecycle (AddFilesOptions.prepareImages):
269
+ // runs prepareImageForVision on the raw file, then hands the result to
270
+ // the normal validate → uniquify → upload sequence. The entry already
271
+ // exists (created by addFiles in the `preparing` phase), so this
272
+ // transitions it rather than creating it.
273
+ const prepareEntry = useCallback(
274
+ async (id: string, rawFile: File) => {
275
+ // Registered under the same id the upload path uses, so removeEntry
276
+ // and clear() cancel a preparation exactly like an upload. The
277
+ // signal is only ever read, never passed down — prepareImageForVision
278
+ // never throws and cannot be interrupted; cancellation means
279
+ // discarding its result.
280
+ const controller = new AbortController();
281
+ abortControllers.current.set(id, controller);
282
+
283
+ const prepared = await prepareImageForVision(rawFile);
284
+ if (controller.signal.aborted) return;
285
+ // Hand-off point: from here the upload path owns the map slot
286
+ // (uploadFile registers its own controller under this id).
287
+ abortControllers.current.delete(id);
288
+
289
+ // Deferred validation — deliberately on the PREPARED bytes, the
290
+ // bytes that actually upload: a 12 MB screenshot that downscales
291
+ // under the limit must pass, exactly as it did when callers
292
+ // prepared before calling addFiles. Rejection removes the chip the
293
+ // user has been watching, so the toast is the explanation.
294
+ const sizeError = validateAttachmentSize(prepared);
295
+ if (sizeError) {
296
+ setEntries((prev) => prev.filter((e) => e.id !== id));
297
+ options?.onValidationError?.(sizeError);
298
+ return;
299
+ }
300
+
301
+ // Preparation may rename (extension follows the re-encode format),
302
+ // so uniquify now, against every OTHER entry's settled name —
303
+ // same downstream-breakage rule as the un-prepared path.
304
+ const takenNames = new Set(
305
+ entriesRef.current.filter((e) => e.id !== id).map((e) => e.file.name),
306
+ );
307
+ const uniqueName = uniquifyFilename(prepared.name, takenNames);
308
+ const file =
309
+ uniqueName === prepared.name
310
+ ? prepared
311
+ : new File([prepared], uniqueName, {
312
+ type: prepared.type,
313
+ lastModified: prepared.lastModified,
314
+ });
315
+ const contentType = detectContentType(file);
316
+
317
+ setEntries((prev) =>
318
+ prev.map((e) =>
319
+ e.id === id
320
+ ? { ...e, file, contentType, phase: "uploading" as const }
321
+ : e,
322
+ ),
323
+ );
324
+ uploadFile(id, file, contentType);
325
+ },
326
+ [options, uploadFile],
327
+ );
328
+
223
329
  const addFiles = useCallback(
224
- (files: FileList | File[]) => {
330
+ (files: FileList | File[], addOptions?: AddFilesOptions) => {
225
331
  const fileArray = Array.from(files);
226
332
  const validEntries: AttachmentEntry[] = [];
227
333
  const takenNames = new Set(entriesRef.current.map((e) => e.file.name));
228
334
 
229
335
  for (const rawFile of fileArray) {
336
+ if (addOptions?.prepareImages) {
337
+ // The entry must exist THIS tick — the whole point of the
338
+ // preparing phase is feedback during the decode/downscale gap.
339
+ // Size validation and name uniquification move to prepareEntry:
340
+ // both depend on the prepared file (final bytes, final name).
341
+ const id = generateId();
342
+ validEntries.push({
343
+ id,
344
+ file: rawFile,
345
+ phase: "preparing",
346
+ contentType: detectContentType(rawFile),
347
+ storageKey: null,
348
+ error: null,
349
+ });
350
+ void prepareEntry(id, rawFile);
351
+ continue;
352
+ }
353
+
230
354
  const sizeError = validateAttachmentSize(rawFile);
231
355
  if (sizeError) {
232
356
  options?.onValidationError?.(sizeError);
@@ -266,7 +390,7 @@ export function useAttachments(
266
390
  setEntries((prev) => [...prev, ...validEntries]);
267
391
  }
268
392
  },
269
- [options, uploadFile],
393
+ [options, uploadFile, prepareEntry],
270
394
  );
271
395
 
272
396
  const removeEntry = useCallback((id: string) => {
@@ -317,7 +441,14 @@ export function useAttachments(
317
441
  }));
318
442
  }, [entries]);
319
443
 
320
- const isUploading = entries.some((e) => e.phase === "uploading");
444
+ const isPreparing = entries.some((e) => e.phase === "preparing");
445
+ // `preparing` counts as uploading-in-the-gate sense: both phases mean
446
+ // "this file will be lost if the message sends now" (see the
447
+ // isUploading doc). Hosts that gate submit on isUploading inherit the
448
+ // preparing gate without a code change.
449
+ const isUploading = entries.some(
450
+ (e) => e.phase === "preparing" || e.phase === "uploading",
451
+ );
321
452
  const readyCount = entries.filter((e) => e.phase === "ready").length;
322
453
  const hasEntries = entries.length > 0;
323
454
  const allReady = hasEntries && readyCount === entries.length;
@@ -325,6 +456,7 @@ export function useAttachments(
325
456
  return {
326
457
  entries,
327
458
  isUploading,
459
+ isPreparing,
328
460
  readyCount,
329
461
  allReady,
330
462
  hasEntries,