@stigmer/react 3.7.0 → 3.9.0

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 (219) hide show
  1. package/attachment/AttachmentChipList.d.ts.map +1 -1
  2. package/attachment/AttachmentChipList.js +30 -1
  3. package/attachment/AttachmentChipList.js.map +1 -1
  4. package/attachment/attachment-utils.d.ts +11 -0
  5. package/attachment/attachment-utils.d.ts.map +1 -1
  6. package/attachment/attachment-utils.js +23 -0
  7. package/attachment/attachment-utils.js.map +1 -1
  8. package/attachment/clipboard.d.ts +51 -0
  9. package/attachment/clipboard.d.ts.map +1 -0
  10. package/attachment/clipboard.js +89 -0
  11. package/attachment/clipboard.js.map +1 -0
  12. package/attachment/index.d.ts +6 -1
  13. package/attachment/index.d.ts.map +1 -1
  14. package/attachment/index.js +4 -1
  15. package/attachment/index.js.map +1 -1
  16. package/attachment/prepare-image.d.ts +43 -0
  17. package/attachment/prepare-image.d.ts.map +1 -0
  18. package/attachment/prepare-image.js +162 -0
  19. package/attachment/prepare-image.js.map +1 -0
  20. package/attachment/useAttachments.d.ts.map +1 -1
  21. package/attachment/useAttachments.js +23 -3
  22. package/attachment/useAttachments.js.map +1 -1
  23. package/attachment/vision-fit.d.ts +50 -0
  24. package/attachment/vision-fit.d.ts.map +1 -0
  25. package/attachment/vision-fit.js +76 -0
  26. package/attachment/vision-fit.js.map +1 -0
  27. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  28. package/channel/AgentChannelsPanel.js +21 -2
  29. package/channel/AgentChannelsPanel.js.map +1 -1
  30. package/channel/ChannelConversationsDialog.d.ts +12 -4
  31. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  32. package/channel/ChannelConversationsDialog.js +18 -24
  33. package/channel/ChannelConversationsDialog.js.map +1 -1
  34. package/channel/providers.d.ts +23 -0
  35. package/channel/providers.d.ts.map +1 -1
  36. package/channel/providers.js +4 -0
  37. package/channel/providers.js.map +1 -1
  38. package/channel/useSaveAgentChannel.d.ts +5 -4
  39. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  40. package/channel/useSaveAgentChannel.js +18 -4
  41. package/channel/useSaveAgentChannel.js.map +1 -1
  42. package/composer/ComposerToolbar.d.ts +1 -1
  43. package/composer/ComposerToolbar.d.ts.map +1 -1
  44. package/composer/SessionComposer.d.ts +4 -2
  45. package/composer/SessionComposer.d.ts.map +1 -1
  46. package/composer/SessionComposer.js +51 -5
  47. package/composer/SessionComposer.js.map +1 -1
  48. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  49. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  50. package/conversation/ConversationAttentionBanner.js +25 -0
  51. package/conversation/ConversationAttentionBanner.js.map +1 -0
  52. package/conversation/ConversationComposer.d.ts +41 -0
  53. package/conversation/ConversationComposer.d.ts.map +1 -0
  54. package/conversation/ConversationComposer.js +57 -0
  55. package/conversation/ConversationComposer.js.map +1 -0
  56. package/conversation/ConversationControlBanner.d.ts +52 -0
  57. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  58. package/conversation/ConversationControlBanner.js +59 -0
  59. package/conversation/ConversationControlBanner.js.map +1 -0
  60. package/conversation/ConversationListPane.d.ts +68 -0
  61. package/conversation/ConversationListPane.d.ts.map +1 -0
  62. package/conversation/ConversationListPane.js +120 -0
  63. package/conversation/ConversationListPane.js.map +1 -0
  64. package/conversation/ConversationTimelineView.d.ts +46 -0
  65. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  66. package/conversation/ConversationTimelineView.js +166 -0
  67. package/conversation/ConversationTimelineView.js.map +1 -0
  68. package/conversation/ConversationsWorkbench.d.ts +63 -0
  69. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  70. package/conversation/ConversationsWorkbench.js +165 -0
  71. package/conversation/ConversationsWorkbench.js.map +1 -0
  72. package/conversation/conversationPresentation.d.ts +137 -0
  73. package/conversation/conversationPresentation.d.ts.map +1 -0
  74. package/conversation/conversationPresentation.js +230 -0
  75. package/conversation/conversationPresentation.js.map +1 -0
  76. package/conversation/index.d.ts +26 -0
  77. package/conversation/index.d.ts.map +1 -0
  78. package/conversation/index.js +14 -0
  79. package/conversation/index.js.map +1 -0
  80. package/conversation/polling.d.ts +39 -0
  81. package/conversation/polling.d.ts.map +1 -0
  82. package/conversation/polling.js +39 -0
  83. package/conversation/polling.js.map +1 -0
  84. package/conversation/useConversation.d.ts +61 -0
  85. package/conversation/useConversation.d.ts.map +1 -0
  86. package/conversation/useConversation.js +153 -0
  87. package/conversation/useConversation.js.map +1 -0
  88. package/conversation/useConversationList.d.ts +98 -0
  89. package/conversation/useConversationList.d.ts.map +1 -0
  90. package/conversation/useConversationList.js +200 -0
  91. package/conversation/useConversationList.js.map +1 -0
  92. package/conversation/useConversationParticipation.d.ts +63 -0
  93. package/conversation/useConversationParticipation.d.ts.map +1 -0
  94. package/conversation/useConversationParticipation.js +84 -0
  95. package/conversation/useConversationParticipation.js.map +1 -0
  96. package/conversation/useConversationTimeline.d.ts +71 -0
  97. package/conversation/useConversationTimeline.d.ts.map +1 -0
  98. package/conversation/useConversationTimeline.js +152 -0
  99. package/conversation/useConversationTimeline.js.map +1 -0
  100. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  101. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  102. package/conversation/useConversationsWantsHumanCount.js +40 -0
  103. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  104. package/index.d.ts +6 -2
  105. package/index.d.ts.map +1 -1
  106. package/index.js +11 -2
  107. package/index.js.map +1 -1
  108. package/internal/tooltip.d.ts.map +1 -1
  109. package/internal/tooltip.js +7 -1
  110. package/internal/tooltip.js.map +1 -1
  111. package/internal/useAutoScroll.d.ts +25 -4
  112. package/internal/useAutoScroll.d.ts.map +1 -1
  113. package/internal/useAutoScroll.js +55 -15
  114. package/internal/useAutoScroll.js.map +1 -1
  115. package/internal/useFetch.d.ts.map +1 -1
  116. package/internal/useFetch.js +14 -4
  117. package/internal/useFetch.js.map +1 -1
  118. package/models/ModelSelector.d.ts +24 -5
  119. package/models/ModelSelector.d.ts.map +1 -1
  120. package/models/ModelSelector.js +31 -9
  121. package/models/ModelSelector.js.map +1 -1
  122. package/models/registry.d.ts +3 -2
  123. package/models/registry.d.ts.map +1 -1
  124. package/models/registry.js.map +1 -1
  125. package/models/service-tier.d.ts +1 -1
  126. package/models/service-tier.d.ts.map +1 -1
  127. package/models/service-tier.js +1 -1
  128. package/models/service-tier.js.map +1 -1
  129. package/package.json +4 -4
  130. package/schedule/ScheduleDetailView.js +1 -1
  131. package/schedule/ScheduleDetailView.js.map +1 -1
  132. package/schedule/ScheduleForm.d.ts.map +1 -1
  133. package/schedule/ScheduleForm.js +7 -6
  134. package/schedule/ScheduleForm.js.map +1 -1
  135. package/sharing/useSaveAgentShare.d.ts +10 -1
  136. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  137. package/sharing/useSaveAgentShare.js +9 -0
  138. package/sharing/useSaveAgentShare.js.map +1 -1
  139. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  140. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  141. package/sidebar/WorkspaceSidebar.js +19 -7
  142. package/sidebar/WorkspaceSidebar.js.map +1 -1
  143. package/src/attachment/AttachmentChipList.tsx +45 -1
  144. package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
  145. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  146. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  147. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  148. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  149. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  150. package/src/attachment/attachment-utils.ts +27 -0
  151. package/src/attachment/clipboard.ts +102 -0
  152. package/src/attachment/index.ts +13 -0
  153. package/src/attachment/prepare-image.ts +179 -0
  154. package/src/attachment/useAttachments.ts +26 -2
  155. package/src/attachment/vision-fit.ts +90 -0
  156. package/src/channel/AgentChannelsPanel.tsx +33 -2
  157. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  158. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  159. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  160. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  161. package/src/channel/providers.ts +27 -0
  162. package/src/channel/useSaveAgentChannel.ts +18 -4
  163. package/src/composer/ComposerToolbar.tsx +1 -1
  164. package/src/composer/SessionComposer.tsx +75 -6
  165. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  166. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  167. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  168. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  169. package/src/conversation/ConversationComposer.tsx +156 -0
  170. package/src/conversation/ConversationControlBanner.tsx +193 -0
  171. package/src/conversation/ConversationListPane.tsx +459 -0
  172. package/src/conversation/ConversationTimelineView.tsx +435 -0
  173. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  174. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  175. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  176. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  177. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  178. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  179. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  180. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  181. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  182. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  183. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  184. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  185. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  186. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  187. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  188. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  189. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  190. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  191. package/src/conversation/conversationPresentation.ts +305 -0
  192. package/src/conversation/index.ts +80 -0
  193. package/src/conversation/polling.ts +41 -0
  194. package/src/conversation/useConversation.ts +221 -0
  195. package/src/conversation/useConversationList.ts +328 -0
  196. package/src/conversation/useConversationParticipation.ts +178 -0
  197. package/src/conversation/useConversationTimeline.ts +244 -0
  198. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  199. package/src/index.ts +78 -1
  200. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  201. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  202. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  203. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  204. package/src/internal/tooltip.tsx +6 -1
  205. package/src/internal/useAutoScroll.ts +66 -20
  206. package/src/internal/useFetch.ts +15 -4
  207. package/src/models/ModelSelector.tsx +81 -49
  208. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  209. package/src/models/registry.ts +3 -2
  210. package/src/models/service-tier.ts +1 -1
  211. package/src/schedule/ScheduleDetailView.tsx +1 -1
  212. package/src/schedule/ScheduleForm.tsx +7 -6
  213. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  214. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  215. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  216. package/src/sharing/useSaveAgentShare.ts +19 -1
  217. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  218. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  219. package/styles.css +1 -1
@@ -0,0 +1,378 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { MessagesSquare } from "lucide-react";
5
+ import { cn } from "@stigmer/theme";
6
+ import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
7
+ import {
8
+ ChannelConversationListFilter,
9
+ type ChannelConversation,
10
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
11
+ import { ChannelSendOutcome } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
12
+ import { channelProviderOf } from "../channel/providers.js";
13
+ import { useOrgAgentChannelList } from "../channel/useOrgAgentChannelList.js";
14
+ import { EmptyState } from "../empty-state/EmptyState.js";
15
+ import { ConversationAttentionBanner } from "./ConversationAttentionBanner.js";
16
+ import { ConversationComposer } from "./ConversationComposer.js";
17
+ import { ConversationControlBanner } from "./ConversationControlBanner.js";
18
+ import { ConversationListPane, type ConversationIdentity } from "./ConversationListPane.js";
19
+ import { ConversationTimelineView } from "./ConversationTimelineView.js";
20
+ import {
21
+ conversationContactOf,
22
+ conversationLabelOf,
23
+ outboundItemIdOf,
24
+ serviceWindowOf,
25
+ } from "./conversationPresentation.js";
26
+ import { useConversation } from "./useConversation.js";
27
+ import { useConversationList } from "./useConversationList.js";
28
+ import { useConversationParticipation } from "./useConversationParticipation.js";
29
+ import { useConversationTimeline } from "./useConversationTimeline.js";
30
+
31
+ /**
32
+ * Context handed to a function-form
33
+ * {@link ConversationsWorkbenchProps.headerAccessory}.
34
+ */
35
+ export interface ConversationHeaderContext {
36
+ /**
37
+ * The open conversation's channel, once the org's channel list
38
+ * answers — `null` while it loads. Carries the channel's name (for
39
+ * scope-explicit accessories like the "Channel access" trigger) and
40
+ * `spec.agent_ref` (for navigation), so hosts never re-fetch what the
41
+ * workbench already holds.
42
+ */
43
+ readonly channel: AgentChannel | null;
44
+ }
45
+
46
+ /** Props for {@link ConversationsWorkbench}. */
47
+ export interface ConversationsWorkbenchProps {
48
+ /** Organization whose conversations to show. */
49
+ readonly org: string;
50
+ /**
51
+ * The open conversation. Controlled by the host so selection can live
52
+ * in the URL (deep links, reloads, back button).
53
+ */
54
+ readonly selected: ConversationIdentity | null;
55
+ /** Called when the user opens or leaves a conversation. */
56
+ readonly onSelectionChange: (selection: ConversationIdentity | null) => void;
57
+ /**
58
+ * The signed-in staff member's identity account id, when the host
59
+ * knows it — used only for "You have this conversation" copy.
60
+ */
61
+ readonly currentIdentityAccountId?: string;
62
+ /**
63
+ * Rendered in the open conversation's header (the host's seam for
64
+ * page-level actions — e.g. the channel access panel trigger). The
65
+ * function form receives {@link ConversationHeaderContext} so the
66
+ * accessory can name the channel it acts on.
67
+ */
68
+ readonly headerAccessory?:
69
+ | React.ReactNode
70
+ | ((context: ConversationHeaderContext) => React.ReactNode);
71
+ /**
72
+ * Maps the open conversation's channel to the host's channel surface
73
+ * — the owning agent's Channels tab (channels have no standalone
74
+ * page). When provided, the header's channel name renders as a link;
75
+ * `null` for a specific channel keeps it plain text. The SDK never
76
+ * assumes a routing scheme (DD-004).
77
+ */
78
+ readonly channelHref?: (channel: AgentChannel) => string | null;
79
+ /** Frozen instant for deterministic hosts (tests, documentation tours). */
80
+ readonly now?: Date;
81
+ /** Additional classes for the workbench container. */
82
+ readonly className?: string;
83
+ }
84
+
85
+ /**
86
+ * The Conversations console surface (channel-conversations T04), fully
87
+ * composed: the org-wide inbox on the left; the open conversation's
88
+ * timeline, participation banners, and staff composer on the right.
89
+ *
90
+ * Everything under it is independently importable — hosts that need a
91
+ * different layout compose `ConversationListPane`,
92
+ * `ConversationTimelineView`, the banners, and the hooks directly.
93
+ */
94
+ export function ConversationsWorkbench({
95
+ org,
96
+ selected,
97
+ onSelectionChange,
98
+ currentIdentityAccountId,
99
+ headerAccessory,
100
+ channelHref,
101
+ now,
102
+ className,
103
+ }: ConversationsWorkbenchProps) {
104
+ const [channelFilter, setChannelFilter] = useState("");
105
+ const [listFilter, setListFilter] = useState(
106
+ ChannelConversationListFilter.channel_conversation_list_filter_unspecified,
107
+ );
108
+
109
+ const { channels } = useOrgAgentChannelList(org || null);
110
+ const list = useConversationList({
111
+ org: org || null,
112
+ agentChannelId: channelFilter,
113
+ filter: listFilter,
114
+ });
115
+
116
+ const detail = useConversation(
117
+ selected?.agentChannelId ?? "",
118
+ selected?.conversationKey ?? "",
119
+ );
120
+ const timeline = useConversationTimeline(
121
+ selected?.agentChannelId ?? "",
122
+ selected?.conversationKey ?? "",
123
+ );
124
+ // DD-012 D-a: a command answer is fresher than any in-flight poll on
125
+ // BOTH surfaces rendering this conversation — fan it out to the detail
126
+ // seam (banners, composer state) and the list seam (the inbox row), so
127
+ // your own takeover never lags the inbox (F-06).
128
+ const detailApply = detail.applyServerState;
129
+ const listApply = list.applyServerState;
130
+ const adoptConversation = useCallback(
131
+ (fresh: ChannelConversation) => {
132
+ detailApply(fresh);
133
+ listApply(fresh);
134
+ },
135
+ [detailApply, listApply],
136
+ );
137
+ const participation = useConversationParticipation({
138
+ agentChannelId: selected?.agentChannelId ?? "",
139
+ conversationKey: selected?.conversationKey ?? "",
140
+ onConversation: adoptConversation,
141
+ });
142
+
143
+ const selectedChannel = useMemo(
144
+ () =>
145
+ selected
146
+ ? channels.find((c) => c.metadata?.id === selected.agentChannelId) ?? null
147
+ : null,
148
+ [channels, selected],
149
+ );
150
+ const descriptor = channelProviderOf(selectedChannel?.spec?.providerConfig?.case);
151
+
152
+ // DD-007 D-e: the handback guard arms on the row's server-derived
153
+ // awaiting_reply fact — bounce-aware since T08 (a staff reply the
154
+ // provider later failed does NOT count as an answer), and platform
155
+ // acknowledgments never stamp it. Never re-derive this from timeline
156
+ // authorship: that derivation read a bounced staff reply as "answered"
157
+ // and went silent in exactly the scenario the guard exists for (F-28)
158
+ // — the client-side twin of the re-derivation DD-011 A-1 removed
159
+ // server-side. No loading gap: the Hand back button renders from this
160
+ // same row, so the guard and the control it protects appear together.
161
+ const unansweredCustomer = detail.conversation?.awaitingReply ?? false;
162
+
163
+ const composerDisabledReason =
164
+ descriptor && !descriptor.supportsStaffReplies
165
+ ? `Staff replies aren't available on ${descriptor.label} channels yet — the agent keeps serving this conversation.`
166
+ : detail.awaitingCustomer
167
+ ? "The customer hasn't written yet — staff replies unlock with their first message."
168
+ : null;
169
+
170
+ // DD-014 D-b/D-e: the closed-window forecast, computed fresh on EVERY
171
+ // render — deliberately NOT memoized on [detail.conversation]: the
172
+ // row's reference is intentionally stable across identical polls
173
+ // (DD-010) while the window is wall-clock-anchored, so a memo keyed on
174
+ // the row would freeze this warning forever on a quiet conversation.
175
+ // The 5s detail poll's re-render is the refresh cadence; the workbench
176
+ // test "the 5s detail poll alone carries the advisory over the
177
+ // boundary" is the guard. The advisory names the rule (DD-014 D-c's
178
+ // one home for it); the failed tick relays the provider's verdict.
179
+ const serviceWindow = detail.conversation
180
+ ? serviceWindowOf(detail.conversation, descriptor?.id ?? null, now ?? new Date())
181
+ : null;
182
+ const composerAdvisory =
183
+ 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.`
185
+ : null;
186
+
187
+ const handleSelect = useCallback(
188
+ (conversation: ChannelConversation) =>
189
+ onSelectionChange({
190
+ agentChannelId: conversation.agentChannelId,
191
+ conversationKey: conversation.conversationKey,
192
+ }),
193
+ [onSelectionChange],
194
+ );
195
+
196
+ // F-05: an accepted reply's ledger item is only visible once the next
197
+ // timeline read answers, so the composer's busy state must span
198
+ // click-to-visible, not end at the RPC. The settling record names the
199
+ // item the reply output promised (`ob:<outbound_message_id>`) and the
200
+ // selection it belongs to — carrying the selection makes stale state
201
+ // from a previously open conversation structurally inert (the derived
202
+ // id is null there) instead of needing a reset effect.
203
+ const [replySettling, setReplySettling] = useState<{
204
+ readonly selectionKey: string;
205
+ readonly itemId: string;
206
+ } | null>(null);
207
+ const selectionKey = selected
208
+ ? `${selected.agentChannelId}:${selected.conversationKey}`
209
+ : "";
210
+ const settlingItemId =
211
+ replySettling !== null && replySettling.selectionKey === selectionKey
212
+ ? replySettling.itemId
213
+ : null;
214
+ useEffect(() => {
215
+ if (settlingItemId === null) return;
216
+ if (timeline.items.some((item) => item.itemId === settlingItemId)) {
217
+ setReplySettling(null);
218
+ }
219
+ }, [timeline.items, settlingItemId]);
220
+
221
+ const handleSend = useCallback(
222
+ async (text: string) => {
223
+ const output = await participation.reply(text);
224
+ // The ledger row is committed before reply answers, so one head
225
+ // refresh renders the REAL item (with its true status) — the SDK
226
+ // never fabricates an optimistic item that might misstate what
227
+ // the provider did. Until that item lands, the composer stays
228
+ // busy; even if this refetch fails, the head poll delivers the
229
+ // item and clears the hold. A refusal never settles: it restores
230
+ // the draft for editing, and holding the composer busy would
231
+ // fight the correction its notice asks for.
232
+ if (
233
+ output.outcome !== ChannelSendOutcome.refused &&
234
+ output.outboundMessageId !== ""
235
+ ) {
236
+ setReplySettling({
237
+ selectionKey,
238
+ itemId: outboundItemIdOf(output.outboundMessageId),
239
+ });
240
+ }
241
+ timeline.refetch();
242
+ return output;
243
+ },
244
+ [participation.reply, timeline.refetch, selectionKey],
245
+ );
246
+
247
+ const detailLabel = detail.conversation
248
+ ? conversationLabelOf(detail.conversation, descriptor?.id ?? null)
249
+ : selected?.conversationKey ?? "";
250
+ // The customer's reachable address (e.g. the WhatsApp number a display
251
+ // name hides) — the call-back path belongs beside the channel name.
252
+ const detailContact = detail.conversation
253
+ ? conversationContactOf(detail.conversation, descriptor?.id ?? null)
254
+ : null;
255
+ const channelName = selectedChannel
256
+ ? selectedChannel.metadata?.name || selectedChannel.metadata?.slug
257
+ : null;
258
+ const selectedChannelHref =
259
+ selectedChannel && channelHref ? channelHref(selectedChannel) : null;
260
+ const headerAccessoryNode =
261
+ typeof headerAccessory === "function"
262
+ ? headerAccessory({ channel: selectedChannel })
263
+ : headerAccessory;
264
+
265
+ return (
266
+ <div
267
+ aria-label="Conversations workbench"
268
+ className={cn("flex min-h-0 flex-1", className)}
269
+ >
270
+ <ConversationListPane
271
+ className="w-80 shrink-0 border-r border-border"
272
+ conversations={list.conversations}
273
+ isLoading={list.isLoading}
274
+ error={list.error}
275
+ hasMore={list.hasMore}
276
+ loadMore={list.loadMore}
277
+ isLoadingMore={list.isLoadingMore}
278
+ channels={channels}
279
+ channelFilter={channelFilter}
280
+ onChannelFilterChange={setChannelFilter}
281
+ filter={listFilter}
282
+ onFilterChange={setListFilter}
283
+ selected={selected}
284
+ onSelect={handleSelect}
285
+ now={now}
286
+ />
287
+
288
+ {selected === null ? (
289
+ <div className="flex min-w-0 flex-1 items-center justify-center p-6">
290
+ <EmptyState
291
+ variant="first-use"
292
+ icon={<MessagesSquare className="size-8" />}
293
+ title="Select a conversation"
294
+ description="Pick a conversation to read its thread, reply as your business, or take over from the agent."
295
+ />
296
+ </div>
297
+ ) : (
298
+ // Keyed by conversation identity (DD-014: `key` remounts are the
299
+ // clean-reset pattern): every open starts a fresh detail column, so
300
+ // auto-scroll opens following at the newest message and the
301
+ // composer's draft and notice can never travel from one customer's
302
+ // conversation into another's (F-22). The data hooks live above
303
+ // this column, so the remount refetches nothing; the inbox pane
304
+ // stays outside it so its scroll position and filter survive.
305
+ // Channel ids never contain ":", so the joined key is unambiguous.
306
+ <div
307
+ key={`${selected.agentChannelId}:${selected.conversationKey}`}
308
+ className="flex min-w-0 flex-1 flex-col"
309
+ >
310
+ <div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
311
+ <div className="min-w-0">
312
+ <h2 className="truncate text-sm font-semibold text-foreground">
313
+ {detailLabel}
314
+ </h2>
315
+ {(detailContact !== null || channelName) && (
316
+ <p className="truncate text-xs text-muted-foreground">
317
+ {detailContact}
318
+ {detailContact !== null && channelName && " · "}
319
+ {channelName &&
320
+ // The path from a conversation to its channel (F-11):
321
+ // the channel name links to the host's channel surface
322
+ // when a route exists.
323
+ (selectedChannelHref ? (
324
+ <a
325
+ href={selectedChannelHref}
326
+ className="hover:text-foreground hover:underline"
327
+ >
328
+ {channelName}
329
+ </a>
330
+ ) : (
331
+ channelName
332
+ ))}
333
+ </p>
334
+ )}
335
+ </div>
336
+ {headerAccessoryNode}
337
+ </div>
338
+
339
+ {detail.conversation && (
340
+ <ConversationAttentionBanner
341
+ conversation={detail.conversation}
342
+ participation={participation}
343
+ />
344
+ )}
345
+ {detail.conversation && (
346
+ <ConversationControlBanner
347
+ conversation={detail.conversation}
348
+ participation={participation}
349
+ unansweredCustomer={unansweredCustomer}
350
+ supportsStaffReplies={descriptor?.supportsStaffReplies ?? true}
351
+ currentIdentityAccountId={currentIdentityAccountId}
352
+ />
353
+ )}
354
+
355
+ <ConversationTimelineView
356
+ items={timeline.items}
357
+ isLoading={timeline.isLoading}
358
+ error={timeline.error}
359
+ hasOlder={timeline.hasOlder}
360
+ loadOlder={timeline.loadOlder}
361
+ isLoadingOlder={timeline.isLoadingOlder}
362
+ provider={descriptor?.id ?? null}
363
+ now={now}
364
+ />
365
+
366
+ <ConversationComposer
367
+ onSend={handleSend}
368
+ isSending={
369
+ participation.pendingCommands.has("reply") || settlingItemId !== null
370
+ }
371
+ disabledReason={composerDisabledReason}
372
+ advisory={composerAdvisory}
373
+ />
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { ChannelConversationSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
6
+ import { ConversationAttentionBanner } from "../ConversationAttentionBanner";
7
+ import type {
8
+ ConversationCommand,
9
+ UseConversationParticipationReturn,
10
+ } from "../useConversationParticipation";
11
+
12
+ function conversation(overrides: Record<string, unknown> = {}) {
13
+ return create(ChannelConversationSchema, {
14
+ agentChannelId: "ach_1",
15
+ conversationKey: "15550001111",
16
+ org: "acme",
17
+ ...overrides,
18
+ });
19
+ }
20
+
21
+ function participation(
22
+ overrides: Partial<UseConversationParticipationReturn> = {},
23
+ ): UseConversationParticipationReturn {
24
+ return {
25
+ reply: vi.fn(),
26
+ takeOver: vi.fn(),
27
+ handBack: vi.fn(),
28
+ clearAttention: vi.fn().mockResolvedValue(conversation()),
29
+ pendingCommands: new Set<ConversationCommand>(),
30
+ commandErrors: new Map<ConversationCommand, Error>(),
31
+ clearErrors: vi.fn(),
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("ConversationAttentionBanner", () => {
37
+ afterEach(() => cleanup());
38
+
39
+ it("renders nothing while the conversation is unflagged", () => {
40
+ const { container } = render(
41
+ <ConversationAttentionBanner
42
+ conversation={conversation()}
43
+ participation={participation()}
44
+ />,
45
+ );
46
+ expect(container.innerHTML).toBe("");
47
+ });
48
+
49
+ it("shows the escalating agent's reason verbatim and dismisses in place", async () => {
50
+ const user = userEvent.setup();
51
+ const p = participation();
52
+ render(
53
+ <ConversationAttentionBanner
54
+ conversation={conversation({
55
+ needsAttention: true,
56
+ attentionReason: "the member is asking about a refund I cannot process",
57
+ })}
58
+ participation={p}
59
+ />,
60
+ );
61
+
62
+ expect(
63
+ screen.getByText(/the member is asking about a refund I cannot process/),
64
+ ).toBeDefined();
65
+ await user.click(screen.getByRole("button", { name: "Dismiss" }));
66
+ expect(p.clearAttention).toHaveBeenCalled();
67
+ });
68
+
69
+ it("renders a dismissal failure verbatim", () => {
70
+ render(
71
+ <ConversationAttentionBanner
72
+ conversation={conversation({ needsAttention: true })}
73
+ participation={participation({
74
+ commandErrors: new Map([
75
+ ["clearAttention", new Error("agent channel ach_1 not found")],
76
+ ]),
77
+ })}
78
+ />,
79
+ );
80
+ expect(screen.getByText("agent channel ach_1 not found")).toBeDefined();
81
+ });
82
+ });
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import {
6
+ ChannelSendOutcome,
7
+ SendChannelMessageOutputSchema,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
9
+ import { ConversationComposer } from "../ConversationComposer";
10
+
11
+ function output(outcome: ChannelSendOutcome, detail = "") {
12
+ return create(SendChannelMessageOutputSchema, {
13
+ outcome,
14
+ outboundMessageId: outcome === ChannelSendOutcome.refused && !detail ? "" : "obm_1",
15
+ detail,
16
+ });
17
+ }
18
+
19
+ describe("ConversationComposer", () => {
20
+ afterEach(() => cleanup());
21
+
22
+ it("renders the reason instead of an input when replying is unavailable", () => {
23
+ render(
24
+ <ConversationComposer
25
+ onSend={vi.fn()}
26
+ isSending={false}
27
+ disabledReason="Staff replies aren't available on Slack channels yet — the agent keeps serving this conversation."
28
+ />,
29
+ );
30
+
31
+ expect(screen.queryByLabelText("Reply to the customer")).toBeNull();
32
+ expect(screen.getByText(/aren't available on Slack channels yet/)).toBeDefined();
33
+ });
34
+
35
+ it("sends the trimmed draft and clears it when the provider accepts", async () => {
36
+ const user = userEvent.setup();
37
+ const onSend = vi.fn().mockResolvedValue(output(ChannelSendOutcome.accepted));
38
+ render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
39
+
40
+ const input = screen.getByLabelText("Reply to the customer");
41
+ await user.type(input, "on my way{Enter}");
42
+
43
+ expect(onSend).toHaveBeenCalledWith("on my way");
44
+ await waitFor(() => expect((input as HTMLTextAreaElement).value).toBe(""));
45
+ });
46
+
47
+ it("keeps the draft and shows the refusal detail verbatim when the send is refused", async () => {
48
+ const user = userEvent.setup();
49
+ const onSend = vi
50
+ .fn()
51
+ .mockResolvedValue(
52
+ output(ChannelSendOutcome.refused, "the 24-hour service window is closed"),
53
+ );
54
+ render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
55
+
56
+ const input = screen.getByLabelText("Reply to the customer");
57
+ await user.type(input, "did this arrive?{Enter}");
58
+
59
+ await waitFor(() =>
60
+ expect(screen.getByText("the 24-hour service window is closed")).toBeDefined(),
61
+ );
62
+ // The words never left the platform; the user should not retype them.
63
+ expect((input as HTMLTextAreaElement).value).toBe("did this arrive?");
64
+ });
65
+
66
+ it("reports a queued send as retrying, with the draft cleared", async () => {
67
+ const user = userEvent.setup();
68
+ const onSend = vi.fn().mockResolvedValue(output(ChannelSendOutcome.queued));
69
+ render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
70
+
71
+ const input = screen.getByLabelText("Reply to the customer");
72
+ await user.type(input, "hello{Enter}");
73
+
74
+ await waitFor(() =>
75
+ expect(screen.getByText(/the platform is retrying in the background/)).toBeDefined(),
76
+ );
77
+ expect((input as HTMLTextAreaElement).value).toBe("");
78
+ });
79
+
80
+ it("shows the spinner and blocks input while a reply is in flight (F-05)", () => {
81
+ render(<ConversationComposer onSend={vi.fn()} isSending={true} disabledReason={null} />);
82
+
83
+ const button = screen.getByRole("button", { name: "Send reply" });
84
+ expect((button as HTMLButtonElement).disabled).toBe(true);
85
+ expect(button.textContent).toContain("Sending…");
86
+ // The house in-flight glyph (the session composer's pattern): the
87
+ // static Send icon must not render while the reply is in flight.
88
+ expect(button.querySelector("svg.animate-spin")).not.toBeNull();
89
+ expect(
90
+ (screen.getByLabelText("Reply to the customer") as HTMLTextAreaElement).disabled,
91
+ ).toBe(true);
92
+ });
93
+
94
+ it("annotates an ENABLED input with the advisory, wired as the input's description (DD-014 D-e)", () => {
95
+ render(
96
+ <ConversationComposer
97
+ onSend={vi.fn()}
98
+ isSending={false}
99
+ disabledReason={null}
100
+ advisory="WhatsApp's 24-hour reply window has closed — a reply sent now will probably fail. It reopens when the customer writes again."
101
+ />,
102
+ );
103
+
104
+ const input = screen.getByLabelText("Reply to the customer") as HTMLTextAreaElement;
105
+ // A forecast, not a block: the input stays usable.
106
+ expect(input.disabled).toBe(false);
107
+
108
+ // R-2 (Sitting 2 gate ruling): the advisory is the input's
109
+ // aria-describedby target — read at the moment of action — never a
110
+ // live region (it is state a reader meets on open, not an event).
111
+ const describedBy = input.getAttribute("aria-describedby");
112
+ expect(describedBy).toBeTruthy();
113
+ const advisoryEl = document.getElementById(describedBy as string);
114
+ expect(advisoryEl?.textContent).toContain("24-hour reply window has closed");
115
+ expect(advisoryEl?.getAttribute("role")).toBeNull();
116
+
117
+ // Zero new tab stops (the F-18 discipline).
118
+ expect(advisoryEl?.querySelector("button, a, [tabindex]")).toBeNull();
119
+ });
120
+
121
+ it("renders no advisory and no dangling description when there is no claim", () => {
122
+ render(
123
+ <ConversationComposer onSend={vi.fn()} isSending={false} disabledReason={null} advisory={null} />,
124
+ );
125
+ const input = screen.getByLabelText("Reply to the customer");
126
+ expect(input.getAttribute("aria-describedby")).toBeNull();
127
+ expect(screen.queryByText(/reply window/)).toBeNull();
128
+ });
129
+
130
+ it("never shows the advisory in the disabled branch — the two states are structurally exclusive", () => {
131
+ render(
132
+ <ConversationComposer
133
+ onSend={vi.fn()}
134
+ isSending={false}
135
+ disabledReason="The customer hasn't written yet — staff replies unlock with their first message."
136
+ advisory="WhatsApp's 24-hour reply window has closed."
137
+ />,
138
+ );
139
+ expect(screen.getByText(/staff replies unlock/)).toBeDefined();
140
+ expect(screen.queryByText(/reply window/)).toBeNull();
141
+ });
142
+
143
+ it("keeps the draft and renders a thrown failure", async () => {
144
+ const user = userEvent.setup();
145
+ const onSend = vi
146
+ .fn()
147
+ .mockRejectedValue(new Error("no conversation with this key exists on channel ach_1"));
148
+ render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
149
+
150
+ const input = screen.getByLabelText("Reply to the customer");
151
+ await user.type(input, "hello?{Enter}");
152
+
153
+ await waitFor(() =>
154
+ expect(
155
+ screen.getByText(/no conversation with this key exists on channel ach_1/),
156
+ ).toBeDefined(),
157
+ );
158
+ expect((input as HTMLTextAreaElement).value).toBe("hello?");
159
+ });
160
+ });