@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
@@ -31,7 +31,6 @@ import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection.js
31
31
  import { useAttachments } from "../attachment/useAttachments.js";
32
32
  import { AttachmentChipList } from "../attachment/AttachmentChipList.js";
33
33
  import { extractClipboardFiles } from "../attachment/clipboard.js";
34
- import { prepareImageForVision } from "../attachment/prepare-image.js";
35
34
  import { useFileReferences } from "../file-reference/useFileReferences.js";
36
35
  import { FileReferenceChipList } from "../file-reference/FileReferenceChipList.js";
37
36
  import { FILE_REF_MIME } from "../internal/file-tree/index.js";
@@ -855,11 +854,17 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
855
854
  const visionNotice = useMemo(() => {
856
855
  if (!enableAttachments || attachments.entries.length === 0) return null;
857
856
  const preflight = assessVisionPreflight(
858
- attachments.entries.map((e) => ({
859
- name: e.file.name,
860
- sizeBytes: e.file.size,
861
- contentType: e.contentType,
862
- })),
857
+ attachments.entries
858
+ // A `preparing` entry still carries pre-downscale bytes — judging
859
+ // those would flash a false "too large" that vanishes when
860
+ // preparation lands. Same tri-state discipline as the module
861
+ // itself: silent while the real size is unknown.
862
+ .filter((e) => e.phase !== "preparing")
863
+ .map((e) => ({
864
+ name: e.file.name,
865
+ sizeBytes: e.file.size,
866
+ contentType: e.contentType,
867
+ })),
863
868
  { model: effective.model, limits: visionLimits },
864
869
  );
865
870
  return visionPreflightMessage(preflight, {
@@ -963,12 +968,11 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
963
968
  e.preventDefault();
964
969
 
965
970
  // Pasted images (and only pasted — see prepare-image.ts) are bounded
966
- // to provider resolution before upload. prepareImageForVision never
967
- // throws; every failure path yields the original file.
971
+ // to provider resolution before upload. The hook owns the whole
972
+ // pipeline: chips appear immediately in the `preparing` phase
973
+ // (stigmer/stigmer#369), then upload.
968
974
  const addFiles = attachments.addFiles;
969
- void Promise.all(files.map(prepareImageForVision)).then((prepared) => {
970
- addFiles(prepared);
971
- });
975
+ addFiles(files, { prepareImages: true });
972
976
  },
973
977
  [enableAttachments, isDisabled, attachments.addFiles],
974
978
  );
@@ -1376,11 +1380,12 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1376
1380
  // ---------------------------------------------------------------------------
1377
1381
 
1378
1382
  const mcpBlocked = showMcp && !mcpSetup.allReady;
1379
- // Send waits for in-flight uploads: toAttachmentInputs() carries only
1383
+ // Send waits for in-flight work: toAttachmentInputs() carries only
1380
1384
  // "ready" entries, so a send racing an upload would silently drop the
1381
1385
  // file the user just pasted — the worst failure for "what's wrong in
1382
1386
  // this screenshot?". Errored entries never gate (their chip offers
1383
- // retry/remove); only the uploading phase blocks.
1387
+ // retry/remove); isUploading covers both the preparing and uploading
1388
+ // phases (see its doc — same drop class).
1384
1389
  const uploadsBlocked = enableAttachments && attachments.isUploading;
1385
1390
  const canSend = composer.canSubmit && !mcpBlocked && !uploadsBlocked;
1386
1391
 
@@ -1758,7 +1763,11 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1758
1763
  className="stg:mx-3 stg:mb-2 stg:flex stg:items-center stg:gap-2 stg:rounded-md stg:bg-muted stg:px-2.5 stg:py-1.5 stg:text-xs stg:text-muted-foreground"
1759
1764
  >
1760
1765
  <ChipSpinner />
1761
- <span>Waiting for attachments to finish uploading…</span>
1766
+ <span>
1767
+ {attachments.isPreparing
1768
+ ? "Waiting for attachments to finish preparing…"
1769
+ : "Waiting for attachments to finish uploading…"}
1770
+ </span>
1762
1771
  </div>
1763
1772
  )}
1764
1773
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useId, useState } from "react";
4
- import { Send, TriangleAlert } from "lucide-react";
4
+ import { LayoutTemplate, Send, TriangleAlert } from "lucide-react";
5
5
  import { cn } from "@stigmer/theme";
6
6
  import { getUserMessage } from "@stigmer/sdk";
7
7
  import { ChannelSendOutcome } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
@@ -9,13 +9,29 @@ import type { SendChannelMessageOutput } from "@stigmer/protos/ai/stigmer/agenti
9
9
  import { Button } from "../button/Button.js";
10
10
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
11
11
  import { useComposer } from "../composer/useComposer.js";
12
+ import type { ConversationReplyPayload } from "./useConversationParticipation.js";
12
13
 
13
14
  /** Props for {@link ConversationComposer}. */
14
15
  export interface ConversationComposerProps {
15
- /** Send a staff reply (from `useConversationParticipation().reply`). */
16
- readonly onSend: (text: string) => Promise<SendChannelMessageOutput>;
16
+ /**
17
+ * Send a staff reply (from `useConversationParticipation().reply`).
18
+ * The composer's own textarea always submits the `text` lane; hosts
19
+ * wiring a template picker send the `template` lane through the same
20
+ * seam — one command, one busy state, one outcome contract.
21
+ */
22
+ readonly onSend: (payload: ConversationReplyPayload) => Promise<SendChannelMessageOutput>;
17
23
  /** `true` while a reply is in flight. */
18
24
  readonly isSending: boolean;
25
+ /**
26
+ * Opens the host's template picker. When present, a persistent
27
+ * template affordance renders beside Send — templates are the one
28
+ * lane WhatsApp offers for a closed window, so the surface must
29
+ * offer the door, not just the advisory's warning (hosts should
30
+ * point at the button in the advisory copy; the advisory itself
31
+ * stays interaction-free — it is the input's description, F-18).
32
+ * Omit on channels whose provider has no template registry.
33
+ */
34
+ readonly onOpenTemplatePicker?: () => void;
19
35
  /**
20
36
  * Why replying is unavailable, when it is — rendered in place of the
21
37
  * input (a senderless provider, or a conversation the customer has
@@ -52,6 +68,7 @@ export interface ConversationComposerProps {
52
68
  export function ConversationComposer({
53
69
  onSend,
54
70
  isSending,
71
+ onOpenTemplatePicker,
55
72
  disabledReason,
56
73
  advisory,
57
74
  className,
@@ -66,7 +83,7 @@ export function ConversationComposer({
66
83
  const composer = useComposer({
67
84
  onSubmit: (message) => {
68
85
  setNotice(null);
69
- onSend(message).then(
86
+ onSend({ kind: "text", body: message }).then(
70
87
  (output) => {
71
88
  if (output.outcome === ChannelSendOutcome.refused) {
72
89
  // The words never left; keep them so the user can adjust.
@@ -110,6 +127,12 @@ export function ConversationComposer({
110
127
  className="stg:mb-2 stg:flex stg:items-start stg:gap-1.5 stg:rounded-md stg:bg-status-degraded-subtle stg:px-2.5 stg:py-1.5 stg:text-xs stg:text-status-degraded"
111
128
  >
112
129
  <TriangleAlert aria-hidden="true" className="stg:mt-0.5 stg:size-3.5 stg:shrink-0" />
130
+ {/* Interactive content is deliberately NOT allowed in here:
131
+ this element is the input's aria-describedby target with
132
+ zero tab stops (F-18). The way forward the advisory names
133
+ (the template lane) lives on the persistent template
134
+ button beside Send — hosts point at it in the advisory
135
+ COPY, never with an embedded control. */}
113
136
  <span className="stg:min-w-0">{advisory}</span>
114
137
  </p>
115
138
  )}
@@ -137,6 +160,17 @@ export function ConversationComposer({
137
160
  "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
138
161
  )}
139
162
  />
163
+ {onOpenTemplatePicker && (
164
+ <Button
165
+ variant="outline"
166
+ size="sm"
167
+ onClick={onOpenTemplatePicker}
168
+ disabled={isSending}
169
+ aria-label="Send a template reply"
170
+ >
171
+ <LayoutTemplate aria-hidden="true" className="stg:size-4" />
172
+ </Button>
173
+ )}
140
174
  <Button
141
175
  variant="primary"
142
176
  size="sm"
@@ -0,0 +1,441 @@
1
+ "use client";
2
+
3
+ import { useId, useMemo, useState } from "react";
4
+ import { ArrowLeft, LayoutTemplate, Send, X } from "lucide-react";
5
+ import { cn } from "@stigmer/theme";
6
+ import { getUserMessage } from "@stigmer/sdk";
7
+ import { ChannelSendOutcome } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
8
+ import type {
9
+ ChannelTemplate,
10
+ SendChannelMessageOutput,
11
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
12
+ import { Button } from "../button/Button.js";
13
+ import { EmptyState } from "../empty-state/EmptyState.js";
14
+ import { DialogShell } from "../internal/DialogShell.js";
15
+ import { SpinnerIcon } from "../internal/SpinnerIcon.js";
16
+ import { UNSTYLED_LIST } from "../internal/element-resets.js";
17
+ import { TruncatedText } from "../internal/truncated-text.js";
18
+ import { splitTemplateBody } from "../channel/templatePresentation.js";
19
+ import { useChannelTemplateList } from "../channel/useChannelTemplateList.js";
20
+ import type { ConversationReplyPayload } from "./useConversationParticipation.js";
21
+
22
+ /** Props for {@link ConversationTemplatePickerDialog}. */
23
+ export interface ConversationTemplatePickerDialogProps {
24
+ /** Whether the dialog is open. */
25
+ readonly open: boolean;
26
+ /** Called when the dialog requests an open-state change. */
27
+ readonly onOpenChange: (open: boolean) => void;
28
+ /**
29
+ * The conversation's channel, addressed as the template registry
30
+ * addresses it: `metadata.slug` + `metadata.org` (the registry is
31
+ * derived from the channel's phone number, not stored on a resource).
32
+ */
33
+ readonly channelSlug: string;
34
+ readonly org: string;
35
+ /**
36
+ * Send the chosen template as a staff reply — the SAME seam the
37
+ * composer's textarea uses (`useConversationParticipation().reply`
38
+ * or the host's wrapper around it), so a template send takes over,
39
+ * settles, and reports outcomes exactly like a text send.
40
+ */
41
+ readonly onSend: (payload: ConversationReplyPayload) => Promise<SendChannelMessageOutput>;
42
+ /** `true` while a reply is in flight. */
43
+ readonly isSending: boolean;
44
+ /**
45
+ * When `false`, renders as an in-flow open dialog instead of a
46
+ * top-layer modal — for embedding in constrained surfaces
47
+ * (documentation demos, visual tests). Interactive hosts keep the
48
+ * default.
49
+ * @default true
50
+ */
51
+ readonly modal?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Pick a provider-approved template, fill its variables, and send it as
56
+ * a staff reply — the one lane WhatsApp offers a business once the
57
+ * customer's 24-hour service window has closed (cloud#260).
58
+ *
59
+ * Where {@link ChannelTemplatesDialog} is the channel surface's
60
+ * read-only registry *viewer* (every status, diagnosis copy), this is
61
+ * the conversation surface's *picker*: it lists only approved
62
+ * templates, marks the ones this platform version cannot send (an
63
+ * `unsupported_reason` from the server, or an IMAGE header — the send
64
+ * payload needs a public HTTPS asset no console surface can supply
65
+ * yet), and walks the two steps of a send: choose, then fill the
66
+ * `{{...}}` variables with a live preview of the exact body the
67
+ * customer will receive.
68
+ *
69
+ * Outcome honesty mirrors the composer's contract: a `refused` outcome
70
+ * keeps the dialog open with every filled value intact and renders the
71
+ * server's corrective detail verbatim (the pre-check names missing
72
+ * parameters, wrong languages, paused templates); a thrown failure does
73
+ * the same with the transport error. `accepted` and `queued` close the
74
+ * dialog — the ledger row exists, so the timeline shows the real item
75
+ * with its true delivery status (the SDK never fabricates one).
76
+ *
77
+ * The body mounts only while open, so each opening fetches the registry
78
+ * fresh (approval statuses change on the provider's side at any time)
79
+ * and a closed dialog holds no draft — the same clean-reset rule that
80
+ * keys the workbench's detail column (F-22).
81
+ */
82
+ export function ConversationTemplatePickerDialog({
83
+ open,
84
+ onOpenChange,
85
+ channelSlug,
86
+ org,
87
+ onSend,
88
+ isSending,
89
+ modal = true,
90
+ }: ConversationTemplatePickerDialogProps) {
91
+ const titleId = useId();
92
+
93
+ return (
94
+ <DialogShell
95
+ open={open}
96
+ onOpenChange={onOpenChange}
97
+ modal={modal}
98
+ // Template bodies are the content — the templates-dialog width.
99
+ width="2xl"
100
+ aria-labelledby={titleId}
101
+ >
102
+ {open && (
103
+ <PickerBody
104
+ titleId={titleId}
105
+ channelSlug={channelSlug}
106
+ org={org}
107
+ onSend={onSend}
108
+ isSending={isSending}
109
+ onClose={() => onOpenChange(false)}
110
+ />
111
+ )}
112
+ </DialogShell>
113
+ );
114
+ }
115
+
116
+ /** Why a listed template cannot be sent from here, or null when it can. */
117
+ function unsendableReasonOf(template: ChannelTemplate): string | null {
118
+ if (template.unsupportedReason) {
119
+ return `Not sendable: ${template.unsupportedReason}`;
120
+ }
121
+ if (template.headerFormat === "IMAGE") {
122
+ // The wire's header_image_link needs a public HTTPS asset at send
123
+ // time; no console surface can supply one yet (v1 scope cut,
124
+ // recorded on cloud#260).
125
+ return "Not sendable from the console yet: this template's image header needs a hosted image at send time.";
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /** The verbatim `{{ key }}` placeholder's inner key. */
131
+ function placeholderKeyOf(placeholder: string): string {
132
+ return placeholder.replace(/^\{\{\s*|\s*\}\}$/g, "");
133
+ }
134
+
135
+ function PickerBody({
136
+ titleId,
137
+ channelSlug,
138
+ org,
139
+ onSend,
140
+ isSending,
141
+ onClose,
142
+ }: {
143
+ readonly titleId: string;
144
+ readonly channelSlug: string;
145
+ readonly org: string;
146
+ readonly onSend: (payload: ConversationReplyPayload) => Promise<SendChannelMessageOutput>;
147
+ readonly isSending: boolean;
148
+ readonly onClose: () => void;
149
+ }) {
150
+ // Approved-only is the wire filter; unsupported/image-header entries
151
+ // still list (marked unsendable) so "why is my template missing"
152
+ // never needs a second surface.
153
+ const { templates, isLoading, error } = useChannelTemplateList(channelSlug, org, {
154
+ approvedOnly: true,
155
+ });
156
+ const [selected, setSelected] = useState<ChannelTemplate | null>(null);
157
+
158
+ return (
159
+ <div className="stg:flex stg:max-h-[75vh] stg:flex-col">
160
+ <div className="stg:flex stg:items-start stg:justify-between stg:gap-3 stg:border-b stg:border-border stg:px-5 stg:py-4">
161
+ <div className="stg:flex stg:min-w-0 stg:items-center stg:gap-2">
162
+ {selected !== null && (
163
+ <Button
164
+ variant="ghost"
165
+ size="xs"
166
+ onClick={() => setSelected(null)}
167
+ disabled={isSending}
168
+ aria-label="Back to the template list"
169
+ >
170
+ <ArrowLeft className="stg:size-4" />
171
+ </Button>
172
+ )}
173
+ <div className="stg:min-w-0">
174
+ <h2
175
+ id={titleId}
176
+ className="stg:text-sm stg:font-semibold stg:text-popover-foreground"
177
+ >
178
+ {selected === null ? "Send a template" : selected.name}
179
+ </h2>
180
+ <p className="stg:mt-0.5 stg:text-xs stg:text-muted-foreground">
181
+ {selected === null
182
+ ? "Approved templates deliver even after the 24-hour reply window closes."
183
+ : "Fill the template's variables — the preview is the exact message the customer receives."}
184
+ </p>
185
+ </div>
186
+ </div>
187
+ <Button variant="ghost" size="xs" onClick={onClose} aria-label="Close template picker">
188
+ <X className="stg:size-4" />
189
+ </Button>
190
+ </div>
191
+
192
+ <div className="stg:min-h-0 stg:flex-1 stg:overflow-y-auto stg:p-4">
193
+ {selected === null ? (
194
+ <TemplateChoiceList
195
+ templates={templates}
196
+ isLoading={isLoading}
197
+ error={error}
198
+ onChoose={setSelected}
199
+ />
200
+ ) : (
201
+ <TemplateFillForm
202
+ template={selected}
203
+ onSend={onSend}
204
+ isSending={isSending}
205
+ onClose={onClose}
206
+ />
207
+ )}
208
+ </div>
209
+ </div>
210
+ );
211
+ }
212
+
213
+ function TemplateChoiceList({
214
+ templates,
215
+ isLoading,
216
+ error,
217
+ onChoose,
218
+ }: {
219
+ readonly templates: readonly ChannelTemplate[];
220
+ readonly isLoading: boolean;
221
+ readonly error: Error | null;
222
+ readonly onChoose: (template: ChannelTemplate) => void;
223
+ }) {
224
+ if (isLoading) {
225
+ return (
226
+ <div className="stg:space-y-2 stg:p-1">
227
+ {[1, 2, 3].map((i) => (
228
+ <div key={i} className="stg:h-16 stg:animate-pulse stg:rounded-lg stg:bg-muted-faint" />
229
+ ))}
230
+ </div>
231
+ );
232
+ }
233
+ if (error) {
234
+ // Past the fetch, the server's copy is the teaching state — its
235
+ // refusals name the fix verbatim (the templates-dialog posture).
236
+ return (
237
+ <p className="stg:px-2 stg:py-6 stg:text-center stg:text-sm stg:text-destructive">
238
+ {getUserMessage(error)}
239
+ </p>
240
+ );
241
+ }
242
+ if (templates.length === 0) {
243
+ // "found", never "you have none": an empty success is
244
+ // indistinguishable from a filtered or truncated one on the wire.
245
+ return (
246
+ <EmptyState
247
+ variant="first-use"
248
+ icon={<LayoutTemplate className="stg:size-8" />}
249
+ title="No approved templates found"
250
+ description={
251
+ "Templates are authored and approved in WhatsApp Manager — " +
252
+ "Stigmer keeps no copy. Approved templates appear here " +
253
+ "automatically; the channel card's Templates dialog shows every " +
254
+ "status, including pending and rejected ones."
255
+ }
256
+ />
257
+ );
258
+ }
259
+
260
+ return (
261
+ <ul className={cn(UNSTYLED_LIST, "stg:space-y-2")}>
262
+ {templates.map((template) => {
263
+ const unsendable = unsendableReasonOf(template);
264
+ return (
265
+ <li key={`${template.name}\u0000${template.language}`}>
266
+ <button
267
+ type="button"
268
+ onClick={() => onChoose(template)}
269
+ disabled={unsendable !== null}
270
+ className={cn(
271
+ "stg:w-full stg:rounded-lg stg:border stg:border-border stg:p-3 stg:text-left",
272
+ "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
273
+ unsendable === null
274
+ ? "stg:hover:border-ring stg:hover:bg-muted-faint"
275
+ : "stg:opacity-60",
276
+ )}
277
+ >
278
+ <div className="stg:flex stg:min-w-0 stg:flex-wrap stg:items-center stg:gap-x-2 stg:gap-y-1">
279
+ <TruncatedText
280
+ text={template.name}
281
+ className="stg:text-sm stg:font-medium stg:text-foreground"
282
+ />
283
+ {/* (name, language) is the template's identity — each
284
+ language is approved independently. */}
285
+ <span className="stg:shrink-0 stg:text-xs stg:text-muted-foreground">
286
+ {template.language}
287
+ </span>
288
+ {template.headerFormat && (
289
+ <span className="stg:shrink-0 stg:rounded stg:bg-muted-subtle stg:px-1.5 stg:py-0.5 stg:text-xs stg:text-muted-foreground">
290
+ {template.headerFormat} header
291
+ </span>
292
+ )}
293
+ </div>
294
+ {template.bodyText && (
295
+ <p className="stg:mt-1.5 stg:line-clamp-2 stg:whitespace-pre-wrap stg:text-xs stg:leading-relaxed stg:text-muted-foreground">
296
+ {template.bodyText}
297
+ </p>
298
+ )}
299
+ {unsendable !== null && (
300
+ <p className="stg:mt-1.5 stg:text-xs stg:text-warning">{unsendable}</p>
301
+ )}
302
+ </button>
303
+ </li>
304
+ );
305
+ })}
306
+ </ul>
307
+ );
308
+ }
309
+
310
+ function TemplateFillForm({
311
+ template,
312
+ onSend,
313
+ isSending,
314
+ onClose,
315
+ }: {
316
+ readonly template: ChannelTemplate;
317
+ readonly onSend: (payload: ConversationReplyPayload) => Promise<SendChannelMessageOutput>;
318
+ readonly isSending: boolean;
319
+ readonly onClose: () => void;
320
+ }) {
321
+ const fieldIdPrefix = useId();
322
+ const [values, setValues] = useState<Readonly<Record<string, string>>>({});
323
+ const [notice, setNotice] = useState<{
324
+ readonly kind: "refused" | "error";
325
+ readonly text: string;
326
+ } | null>(null);
327
+
328
+ const segments = useMemo(() => splitTemplateBody(template.bodyText), [template.bodyText]);
329
+ // The declared parameters are the contract: names for NAMED
330
+ // templates, positions ("1", "2", …) for POSITIONAL — the pre-check
331
+ // refuses anything missing or extra, so the form asks for exactly
332
+ // this list.
333
+ const parameterKeys = template.parameterNames;
334
+ const allFilled = parameterKeys.every((key) => (values[key] ?? "").trim() !== "");
335
+
336
+ const handleSend = () => {
337
+ setNotice(null);
338
+ const parameters = Object.fromEntries(
339
+ parameterKeys.map((key) => [key, (values[key] ?? "").trim()]),
340
+ );
341
+ onSend({
342
+ kind: "template",
343
+ name: template.name,
344
+ // Always the chosen entry's language: (name, language) is the
345
+ // identity we listed, and sending it structurally avoids the
346
+ // wire's ambiguous-language refusal.
347
+ language: template.language,
348
+ parameters,
349
+ }).then(
350
+ (output) => {
351
+ if (output.outcome === ChannelSendOutcome.refused) {
352
+ // The words never left; every filled value stays for the
353
+ // correction the detail asks for (the composer's contract).
354
+ setNotice({
355
+ kind: "refused",
356
+ text: output.detail || "The provider refused this template.",
357
+ });
358
+ } else {
359
+ // accepted or queued: the ledger row exists — the timeline
360
+ // shows the real item with its true delivery status.
361
+ onClose();
362
+ }
363
+ },
364
+ (err) => {
365
+ setNotice({ kind: "error", text: getUserMessage(err) });
366
+ },
367
+ );
368
+ };
369
+
370
+ return (
371
+ <div className="stg:space-y-4">
372
+ {notice && (
373
+ <p className="stg:text-xs stg:text-destructive" role="status">
374
+ {notice.text}
375
+ </p>
376
+ )}
377
+
378
+ {parameterKeys.length > 0 && (
379
+ <div className="stg:space-y-3">
380
+ {parameterKeys.map((key) => (
381
+ <div key={key}>
382
+ <label
383
+ htmlFor={`${fieldIdPrefix}-${key}`}
384
+ className="stg:mb-1 stg:block stg:text-xs stg:font-medium stg:text-foreground"
385
+ >
386
+ {key}
387
+ </label>
388
+ <input
389
+ id={`${fieldIdPrefix}-${key}`}
390
+ type="text"
391
+ value={values[key] ?? ""}
392
+ onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
393
+ disabled={isSending}
394
+ className={cn(
395
+ "stg:w-full stg:rounded-md stg:border stg:border-border stg:bg-background",
396
+ "stg:px-3 stg:py-2 stg:text-sm stg:text-foreground stg:placeholder:text-muted-foreground-faint",
397
+ "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
398
+ )}
399
+ />
400
+ </div>
401
+ ))}
402
+ </div>
403
+ )}
404
+
405
+ <div>
406
+ <p className="stg:mb-1 stg:text-xs stg:font-medium stg:text-foreground">Preview</p>
407
+ <p className="stg:whitespace-pre-wrap stg:rounded-md stg:border stg:border-border stg:bg-muted-faint stg:p-3 stg:text-sm stg:leading-relaxed stg:text-foreground">
408
+ {segments.map((segment, i) => {
409
+ if (segment.kind === "text") {
410
+ return <span key={i}>{segment.value}</span>;
411
+ }
412
+ const value = (values[placeholderKeyOf(segment.value)] ?? "").trim();
413
+ return value !== "" ? (
414
+ <span key={i}>{value}</span>
415
+ ) : (
416
+ <code
417
+ key={i}
418
+ className="stg:rounded stg:bg-muted-subtle stg:px-1 stg:font-mono stg:text-muted-foreground"
419
+ >
420
+ {segment.value}
421
+ </code>
422
+ );
423
+ })}
424
+ </p>
425
+ </div>
426
+
427
+ <div className="stg:flex stg:justify-end">
428
+ <Button
429
+ variant="primary"
430
+ size="sm"
431
+ onClick={handleSend}
432
+ disabled={!allFilled || isSending}
433
+ aria-label="Send template"
434
+ >
435
+ {isSending ? <SpinnerIcon /> : <Send aria-hidden="true" className="stg:size-4" />}
436
+ {isSending ? "Sending…" : "Send template"}
437
+ </Button>
438
+ </div>
439
+ </div>
440
+ );
441
+ }
@@ -16,6 +16,7 @@ import { ConversationAttentionBanner } from "./ConversationAttentionBanner.js";
16
16
  import { ConversationComposer } from "./ConversationComposer.js";
17
17
  import { ConversationControlBanner } from "./ConversationControlBanner.js";
18
18
  import { ConversationListPane, type ConversationIdentity } from "./ConversationListPane.js";
19
+ import { ConversationTemplatePickerDialog } from "./ConversationTemplatePickerDialog.js";
19
20
  import { ConversationTimelineView } from "./ConversationTimelineView.js";
20
21
  import {
21
22
  conversationContactOf,
@@ -25,7 +26,10 @@ import {
25
26
  } from "./conversationPresentation.js";
26
27
  import { useConversation } from "./useConversation.js";
27
28
  import { useConversationList } from "./useConversationList.js";
28
- import { useConversationParticipation } from "./useConversationParticipation.js";
29
+ import {
30
+ useConversationParticipation,
31
+ type ConversationReplyPayload,
32
+ } from "./useConversationParticipation.js";
29
33
  import { useConversationTimeline } from "./useConversationTimeline.js";
30
34
 
31
35
  /**
@@ -179,9 +183,20 @@ export function ConversationsWorkbench({
179
183
  const serviceWindow = detail.conversation
180
184
  ? serviceWindowOf(detail.conversation, descriptor?.id ?? null, now ?? new Date())
181
185
  : null;
186
+ // cloud#260: templates are the lane that SUCCEEDS on a closed window,
187
+ // offered from the persistent affordance beside Send — the advisory
188
+ // points at it in copy only (it is the input's description with zero
189
+ // tab stops, F-18; a control inside it would be unreachable context).
190
+ const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
191
+ const templatesAvailable =
192
+ (descriptor?.supportsMessageTemplates ?? false) && selectedChannel !== null;
182
193
  const composerAdvisory =
183
194
  serviceWindow === "closed" && descriptor
184
- ? `${descriptor.label} closes free-form replies 24 hours after the customer's last message — a reply sent now will probably fail. The window reopens when the customer writes again.`
195
+ ? `${descriptor.label} closes free-form replies 24 hours after the customer's last message — a reply sent now will probably fail. The window reopens when the customer writes again.${
196
+ templatesAvailable
197
+ ? " Approved templates still deliver — use the template button beside Send."
198
+ : ""
199
+ }`
185
200
  : null;
186
201
 
187
202
  const handleSelect = useCallback(
@@ -219,8 +234,8 @@ export function ConversationsWorkbench({
219
234
  }, [timeline.items, settlingItemId]);
220
235
 
221
236
  const handleSend = useCallback(
222
- async (text: string) => {
223
- const output = await participation.reply(text);
237
+ async (payload: ConversationReplyPayload) => {
238
+ const output = await participation.reply(payload);
224
239
  // The ledger row is committed before reply answers, so one head
225
240
  // refresh renders the REAL item (with its true status) — the SDK
226
241
  // never fabricates an optimistic item that might misstate what
@@ -371,9 +386,30 @@ export function ConversationsWorkbench({
371
386
  isSending={
372
387
  participation.pendingCommands.has("reply") || settlingItemId !== null
373
388
  }
389
+ onOpenTemplatePicker={
390
+ templatesAvailable && composerDisabledReason === null
391
+ ? () => setTemplatePickerOpen(true)
392
+ : undefined
393
+ }
374
394
  disabledReason={composerDisabledReason}
375
395
  advisory={composerAdvisory}
376
396
  />
397
+
398
+ {/* Inside the keyed column on purpose: switching conversations
399
+ remounts and closes it, so a half-filled template can never
400
+ travel into another customer's conversation (F-22). */}
401
+ {templatesAvailable && (
402
+ <ConversationTemplatePickerDialog
403
+ open={templatePickerOpen}
404
+ onOpenChange={setTemplatePickerOpen}
405
+ channelSlug={selectedChannel?.metadata?.slug ?? ""}
406
+ org={selectedChannel?.metadata?.org ?? ""}
407
+ onSend={handleSend}
408
+ isSending={
409
+ participation.pendingCommands.has("reply") || settlingItemId !== null
410
+ }
411
+ />
412
+ )}
377
413
  </div>
378
414
  )}
379
415
  </div>