@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.
- package/attachment/AttachmentChipList.js +16 -6
- package/attachment/AttachmentChipList.js.map +1 -1
- package/attachment/index.d.ts +1 -1
- package/attachment/index.d.ts.map +1 -1
- package/attachment/index.js.map +1 -1
- package/attachment/useAttachments.d.ts +51 -8
- package/attachment/useAttachments.d.ts.map +1 -1
- package/attachment/useAttachments.js +74 -3
- package/attachment/useAttachments.js.map +1 -1
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +17 -10
- package/composer/SessionComposer.js.map +1 -1
- package/conversation/ConversationComposer.d.ts +19 -3
- package/conversation/ConversationComposer.d.ts.map +1 -1
- package/conversation/ConversationComposer.js +4 -4
- package/conversation/ConversationComposer.js.map +1 -1
- package/conversation/ConversationTemplatePickerDialog.d.ts +63 -0
- package/conversation/ConversationTemplatePickerDialog.d.ts.map +1 -0
- package/conversation/ConversationTemplatePickerDialog.js +151 -0
- package/conversation/ConversationTemplatePickerDialog.js.map +1 -0
- package/conversation/ConversationsWorkbench.d.ts.map +1 -1
- package/conversation/ConversationsWorkbench.js +16 -5
- package/conversation/ConversationsWorkbench.js.map +1 -1
- package/conversation/index.d.ts +3 -1
- package/conversation/index.d.ts.map +1 -1
- package/conversation/index.js +1 -0
- package/conversation/index.js.map +1 -1
- package/conversation/useConversationParticipation.d.ts +41 -4
- package/conversation/useConversationParticipation.d.ts.map +1 -1
- package/conversation/useConversationParticipation.js +13 -2
- package/conversation/useConversationParticipation.js.map +1 -1
- package/identity-account/AccountPreferencesPanel.d.ts +35 -0
- package/identity-account/AccountPreferencesPanel.d.ts.map +1 -0
- package/identity-account/AccountPreferencesPanel.js +187 -0
- package/identity-account/AccountPreferencesPanel.js.map +1 -0
- package/identity-account/index.d.ts +8 -0
- package/identity-account/index.d.ts.map +1 -1
- package/identity-account/index.js +4 -0
- package/identity-account/index.js.map +1 -1
- package/identity-account/useAccountExecutionDefaults.d.ts +36 -0
- package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -0
- package/identity-account/useAccountExecutionDefaults.js +46 -0
- package/identity-account/useAccountExecutionDefaults.js.map +1 -0
- package/identity-account/useMyIdentityAccount.d.ts +55 -0
- package/identity-account/useMyIdentityAccount.d.ts.map +1 -0
- package/identity-account/useMyIdentityAccount.js +37 -0
- package/identity-account/useMyIdentityAccount.js.map +1 -0
- package/identity-account/useUpdateIdentityAccount.d.ts +42 -0
- package/identity-account/useUpdateIdentityAccount.d.ts.map +1 -0
- package/identity-account/useUpdateIdentityAccount.js +53 -0
- package/identity-account/useUpdateIdentityAccount.js.map +1 -0
- package/index.d.ts +11 -9
- package/index.d.ts.map +1 -1
- package/index.js +8 -5
- package/index.js.map +1 -1
- package/internal/StandingContextField.d.ts +37 -0
- package/internal/StandingContextField.d.ts.map +1 -0
- package/internal/StandingContextField.js +24 -0
- package/internal/StandingContextField.js.map +1 -0
- package/library/InstanceVisibilitySelector.d.ts.map +1 -1
- package/library/InstanceVisibilitySelector.js +9 -2
- package/library/InstanceVisibilitySelector.js.map +1 -1
- package/library/ResourceVisibilityControl.d.ts +13 -3
- package/library/ResourceVisibilityControl.d.ts.map +1 -1
- package/library/ResourceVisibilityControl.js +33 -5
- package/library/ResourceVisibilityControl.js.map +1 -1
- package/library/VisibilityOptionRow.d.ts +6 -0
- package/library/VisibilityOptionRow.d.ts.map +1 -1
- package/library/VisibilityOptionRow.js +11 -1
- package/library/VisibilityOptionRow.js.map +1 -1
- package/library/VisibilitySelector.d.ts.map +1 -1
- package/library/VisibilitySelector.js +14 -2
- package/library/VisibilitySelector.js.map +1 -1
- package/library/index.d.ts +4 -2
- package/library/index.d.ts.map +1 -1
- package/library/index.js +2 -1
- package/library/index.js.map +1 -1
- package/library/useCanSetPublicVisibility.d.ts +30 -0
- package/library/useCanSetPublicVisibility.d.ts.map +1 -0
- package/library/useCanSetPublicVisibility.js +41 -0
- package/library/useCanSetPublicVisibility.js.map +1 -0
- package/library/visibilityLevels.d.ts +32 -1
- package/library/visibilityLevels.d.ts.map +1 -1
- package/library/visibilityLevels.js +34 -14
- package/library/visibilityLevels.js.map +1 -1
- package/mcp-server/useMcpServerConnect.d.ts +8 -5
- package/mcp-server/useMcpServerConnect.d.ts.map +1 -1
- package/mcp-server/useMcpServerConnect.js +2 -1
- package/mcp-server/useMcpServerConnect.js.map +1 -1
- package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
- package/mcp-server/useMcpServerOAuthConnect.js +7 -2
- package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
- package/oauth-app/CreateOAuthAppForm.d.ts +2 -2
- package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -1
- package/oauth-app/CreateOAuthAppForm.js +13 -4
- package/oauth-app/CreateOAuthAppForm.js.map +1 -1
- package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -1
- package/oauth-app/OAuthAppDetailPanel.js +32 -5
- package/oauth-app/OAuthAppDetailPanel.js.map +1 -1
- package/organization/OrgPreferencesPanel.d.ts +32 -0
- package/organization/OrgPreferencesPanel.d.ts.map +1 -0
- package/organization/OrgPreferencesPanel.js +90 -0
- package/organization/OrgPreferencesPanel.js.map +1 -0
- package/organization/OrgProfilePanel.d.ts.map +1 -1
- package/organization/OrgProfilePanel.js +5 -4
- package/organization/OrgProfilePanel.js.map +1 -1
- package/organization/index.d.ts +2 -0
- package/organization/index.d.ts.map +1 -1
- package/organization/index.js +1 -0
- package/organization/index.js.map +1 -1
- package/package.json +4 -4
- package/session/NewSessionViewer.d.ts +10 -1
- package/session/NewSessionViewer.d.ts.map +1 -1
- package/session/NewSessionViewer.js +2 -1
- package/session/NewSessionViewer.js.map +1 -1
- package/session/useNewSessionFlow.d.ts +27 -1
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +45 -8
- package/session/useNewSessionFlow.js.map +1 -1
- package/settings/AccountPreferencesSection.d.ts +3 -0
- package/settings/AccountPreferencesSection.d.ts.map +1 -0
- package/settings/AccountPreferencesSection.js +10 -0
- package/settings/AccountPreferencesSection.js.map +1 -0
- package/settings/OrgPreferencesSection.d.ts +3 -0
- package/settings/OrgPreferencesSection.d.ts.map +1 -0
- package/settings/OrgPreferencesSection.js +13 -0
- package/settings/OrgPreferencesSection.js.map +1 -0
- package/settings/index.d.ts +2 -0
- package/settings/index.d.ts.map +1 -1
- package/settings/index.js +2 -0
- package/settings/index.js.map +1 -1
- package/settings/settings-nav.d.ts.map +1 -1
- package/settings/settings-nav.js +17 -1
- package/settings/settings-nav.js.map +1 -1
- package/src/attachment/AttachmentChipList.tsx +19 -8
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +12 -0
- package/src/attachment/__tests__/useAttachments.test.ts +293 -0
- package/src/attachment/index.ts +1 -0
- package/src/attachment/useAttachments.ts +143 -11
- package/src/composer/SessionComposer.tsx +23 -14
- package/src/conversation/ConversationComposer.tsx +38 -4
- package/src/conversation/ConversationTemplatePickerDialog.tsx +441 -0
- package/src/conversation/ConversationsWorkbench.tsx +40 -4
- package/src/conversation/__tests__/ConversationComposer.test.tsx +56 -1
- package/src/conversation/__tests__/ConversationTemplatePickerDialog.test.tsx +224 -0
- package/src/conversation/__tests__/useConversationParticipation.test.tsx +45 -4
- package/src/conversation/index.ts +4 -0
- package/src/conversation/useConversationParticipation.ts +58 -6
- package/src/identity-account/AccountPreferencesPanel.tsx +446 -0
- package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +233 -0
- package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +116 -0
- package/src/identity-account/__tests__/useMyIdentityAccount.test.tsx +121 -0
- package/src/identity-account/index.ts +11 -0
- package/src/identity-account/useAccountExecutionDefaults.ts +63 -0
- package/src/identity-account/useMyIdentityAccount.ts +75 -0
- package/src/identity-account/useUpdateIdentityAccount.ts +73 -0
- package/src/index.ts +24 -2
- package/src/internal/StandingContextField.tsx +92 -0
- package/src/library/InstanceVisibilitySelector.tsx +9 -2
- package/src/library/ResourceVisibilityControl.tsx +46 -13
- package/src/library/VisibilityOptionRow.tsx +28 -6
- package/src/library/VisibilitySelector.tsx +14 -2
- package/src/library/__tests__/VisibilitySelector.test.tsx +48 -1
- package/src/library/__tests__/visibilityLevels.test.ts +67 -1
- package/src/library/index.ts +6 -1
- package/src/library/useCanSetPublicVisibility.ts +56 -0
- package/src/library/visibilityLevels.ts +65 -14
- package/src/mcp-server/__tests__/useMcpServerConnect.asyncLane.test.tsx +117 -0
- package/src/mcp-server/useMcpServerConnect.ts +9 -6
- package/src/mcp-server/useMcpServerOAuthConnect.ts +7 -2
- package/src/oauth-app/CreateOAuthAppForm.tsx +58 -3
- package/src/oauth-app/OAuthAppDetailPanel.tsx +84 -3
- package/src/organization/OrgPreferencesPanel.tsx +221 -0
- package/src/organization/OrgProfilePanel.tsx +5 -4
- package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +145 -0
- package/src/organization/__tests__/OrgProfilePanel.test.tsx +108 -0
- package/src/organization/index.ts +2 -0
- package/src/session/NewSessionViewer.tsx +12 -0
- package/src/session/__tests__/useNewSessionFlow.test.tsx +151 -0
- package/src/session/useNewSessionFlow.ts +76 -9
- package/src/settings/AccountPreferencesSection.tsx +25 -0
- package/src/settings/OrgPreferencesSection.tsx +35 -0
- package/src/settings/__tests__/settings-nav.test.ts +27 -0
- package/src/settings/index.ts +2 -0
- package/src/settings/settings-nav.ts +18 -0
- package/src/workflow/ApprovalFormBuilder.tsx +5 -27
- package/styles.css +1 -1
- package/workflow/ApprovalFormBuilder.d.ts.map +1 -1
- package/workflow/ApprovalFormBuilder.js +8 -5
- 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
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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.
|
|
967
|
-
//
|
|
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
|
-
|
|
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
|
|
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);
|
|
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>
|
|
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
|
-
/**
|
|
16
|
-
|
|
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 {
|
|
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 (
|
|
223
|
-
const output = await participation.reply(
|
|
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>
|