@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,860 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import type { ReactNode } from "react";
5
+ import { create } from "@bufbuild/protobuf";
6
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
7
+ import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
8
+ import {
9
+ ChannelConversationListFilter,
10
+ ChannelConversationSchema,
11
+ ConversationControl,
12
+ ConversationItemAuthor,
13
+ ConversationLane,
14
+ ConversationTimelineItemSchema,
15
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
16
+ import {
17
+ ChannelSendOutcome,
18
+ SendChannelMessageOutputSchema,
19
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
20
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
21
+ import { StigmerContext } from "../../context";
22
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
23
+ import { advanceInSlices } from "../../internal/__tests__/fake-timer-slices";
24
+ import { ConversationsWorkbench } from "../ConversationsWorkbench";
25
+
26
+ const SELECTED = { agentChannelId: "ach_wa", conversationKey: "15550001111" };
27
+
28
+ function whatsappChannel() {
29
+ return create(AgentChannelSchema, {
30
+ metadata: { id: "ach_wa", org: "acme", name: "support-line", slug: "support-line" },
31
+ spec: {
32
+ agentRef: { org: "acme", slug: "support-agent" },
33
+ providerConfig: { case: "whatsapp", value: {} },
34
+ },
35
+ });
36
+ }
37
+
38
+ function slackChannel() {
39
+ return create(AgentChannelSchema, {
40
+ metadata: { id: "ach_sl", org: "acme", name: "eng-help", slug: "eng-help" },
41
+ spec: { providerConfig: { case: "slack", value: {} } },
42
+ });
43
+ }
44
+
45
+ function row(overrides: Record<string, unknown> = {}) {
46
+ return create(ChannelConversationSchema, {
47
+ ...SELECTED,
48
+ org: "acme",
49
+ displayName: "Pat",
50
+ control: ConversationControl.control_agent,
51
+ lastActivityAt: timestampFromDate(new Date("2026-08-07T11:00:00Z")),
52
+ ...overrides,
53
+ });
54
+ }
55
+
56
+ function customerItem(id: string, text: string) {
57
+ return create(ConversationTimelineItemSchema, {
58
+ itemId: id,
59
+ lane: ConversationLane.lane_public,
60
+ author: ConversationItemAuthor.author_customer,
61
+ text,
62
+ at: timestampFromDate(new Date("2026-08-07T10:00:00Z")),
63
+ });
64
+ }
65
+
66
+ function publicItem(
67
+ id: string,
68
+ author: ConversationItemAuthor,
69
+ iso: string,
70
+ text: string,
71
+ ) {
72
+ return create(ConversationTimelineItemSchema, {
73
+ itemId: id,
74
+ lane: ConversationLane.lane_public,
75
+ author,
76
+ text,
77
+ at: timestampFromDate(new Date(iso)),
78
+ });
79
+ }
80
+
81
+ function createMockStigmer(overrides: Record<string, unknown> = {}) {
82
+ return {
83
+ agentChannel: {
84
+ list: vi.fn().mockResolvedValue({ items: [whatsappChannel()], totalCount: 1 }),
85
+ listConversations: vi.fn().mockResolvedValue({ items: [row()], totalCount: 1 }),
86
+ getConversation: vi.fn().mockResolvedValue(row()),
87
+ getTimeline: vi.fn().mockResolvedValue({
88
+ items: [customerItem("wa:1", "where is my order?")],
89
+ nextPageToken: "",
90
+ }),
91
+ reply: vi.fn().mockResolvedValue(
92
+ create(SendChannelMessageOutputSchema, {
93
+ outcome: ChannelSendOutcome.accepted,
94
+ outboundMessageId: "obm_1",
95
+ }),
96
+ ),
97
+ takeOver: vi
98
+ .fn()
99
+ .mockResolvedValue(row({ control: ConversationControl.control_human, controlledBy: "idt_winner" })),
100
+ handBack: vi.fn().mockResolvedValue(row()),
101
+ clearAttention: vi.fn().mockResolvedValue(row()),
102
+ ...overrides,
103
+ },
104
+ } as never;
105
+ }
106
+
107
+ function wrapper(client: unknown) {
108
+ return function Wrapper({ children }: { children: ReactNode }) {
109
+ return (
110
+ <FetchCacheContext.Provider value={null}>
111
+ <StigmerContext.Provider value={client as never}>
112
+ {children}
113
+ </StigmerContext.Provider>
114
+ </FetchCacheContext.Provider>
115
+ );
116
+ };
117
+ }
118
+
119
+ describe("ConversationsWorkbench", () => {
120
+ afterEach(() => cleanup());
121
+
122
+ it("renders the inbox and prompts for a selection", async () => {
123
+ render(
124
+ <ConversationsWorkbench org="acme" selected={null} onSelectionChange={vi.fn()} />,
125
+ { wrapper: wrapper(createMockStigmer()) },
126
+ );
127
+
128
+ await waitFor(() => expect(screen.getByText("Pat")).toBeDefined());
129
+ expect(screen.getByText("Select a conversation")).toBeDefined();
130
+ });
131
+
132
+ it("opens a conversation: row, timeline, banners, and composer wire together", async () => {
133
+ const client = createMockStigmer();
134
+ render(
135
+ <ConversationsWorkbench
136
+ org="acme"
137
+ selected={SELECTED}
138
+ onSelectionChange={vi.fn()}
139
+ />,
140
+ { wrapper: wrapper(client) },
141
+ );
142
+
143
+ await waitFor(() =>
144
+ expect(screen.getByText("where is my order?")).toBeDefined(),
145
+ );
146
+ expect(screen.getByText("The agent is serving this conversation.")).toBeDefined();
147
+ expect(screen.getByLabelText("Reply to the customer")).toBeDefined();
148
+ // The header keeps the call-back path visible: the display name wins
149
+ // the title, so the WhatsApp number renders in the sub-line beside
150
+ // the channel name (F-17).
151
+ expect(screen.getByRole("heading", { name: "Pat" })).toBeDefined();
152
+ // Matched on the sub-line's full text content: the contact and the
153
+ // channel name render as separate nodes (the channel name can be a
154
+ // link, F-11) but read as one line.
155
+ expect(
156
+ screen.getByText(
157
+ (_, el) => el?.tagName === "P" && el.textContent === "15550001111 · support-line",
158
+ ),
159
+ ).toBeDefined();
160
+ const agentChannel = (client as { agentChannel: Record<string, ReturnType<typeof vi.fn>> })
161
+ .agentChannel;
162
+ expect(agentChannel.getConversation).toHaveBeenCalledWith(
163
+ expect.objectContaining(SELECTED),
164
+ );
165
+ });
166
+
167
+ it("adopts the takeover answer immediately — including a lost race's winner", async () => {
168
+ const user = userEvent.setup();
169
+ render(
170
+ <ConversationsWorkbench
171
+ org="acme"
172
+ selected={SELECTED}
173
+ onSelectionChange={vi.fn()}
174
+ currentIdentityAccountId="idt_me"
175
+ />,
176
+ { wrapper: wrapper(createMockStigmer()) },
177
+ );
178
+ await waitFor(() =>
179
+ expect(screen.getByRole("button", { name: "Take over" })).toBeDefined(),
180
+ );
181
+
182
+ await user.click(screen.getByRole("button", { name: "Take over" }));
183
+
184
+ // The server answered with idt_winner holding it — the banner tells
185
+ // that truth instead of assuming the caller's transition won.
186
+ await waitFor(() =>
187
+ expect(screen.getByText(/A teammate has this conversation/)).toBeDefined(),
188
+ );
189
+ });
190
+
191
+ it("reflects a takeover in the inbox row the instant the command answers (DD-012 D-a)", async () => {
192
+ const user = userEvent.setup();
193
+ // The retiring head fetch (applyServerState's round-trip) is PARKED:
194
+ // the row's new state must come from the seam alone — no list answer,
195
+ // no poll tick. This is the F-06 complaint pinned at the composed
196
+ // level: your own takeover may never lag the inbox.
197
+ const listConversations = vi
198
+ .fn()
199
+ .mockResolvedValueOnce({ items: [row()], totalCount: 1 })
200
+ .mockReturnValue(new Promise(() => {}));
201
+ render(
202
+ <ConversationsWorkbench
203
+ org="acme"
204
+ selected={SELECTED}
205
+ onSelectionChange={vi.fn()}
206
+ currentIdentityAccountId="idt_me"
207
+ />,
208
+ { wrapper: wrapper(createMockStigmer({ listConversations })) },
209
+ );
210
+ await waitFor(() =>
211
+ expect(screen.getByRole("button", { name: "Take over" })).toBeDefined(),
212
+ );
213
+ expect(screen.queryByText("Human has the conversation")).toBeNull();
214
+
215
+ await user.click(screen.getByRole("button", { name: "Take over" }));
216
+
217
+ await waitFor(() =>
218
+ expect(screen.getByText("Human has the conversation")).toBeDefined(),
219
+ );
220
+ });
221
+
222
+ it("threads the wants-human filter to the server — never a client-side sieve (DD-011 D-g)", async () => {
223
+ const user = userEvent.setup();
224
+ const client = createMockStigmer();
225
+ render(
226
+ <ConversationsWorkbench org="acme" selected={null} onSelectionChange={vi.fn()} />,
227
+ { wrapper: wrapper(client) },
228
+ );
229
+ await waitFor(() => expect(screen.getByText("Pat")).toBeDefined());
230
+
231
+ await user.click(
232
+ screen.getByRole("radio", {
233
+ name: "Conversations where a human action is wanted",
234
+ }),
235
+ );
236
+
237
+ const agentChannel = (client as {
238
+ agentChannel: Record<string, ReturnType<typeof vi.fn>>;
239
+ }).agentChannel;
240
+ await waitFor(() =>
241
+ expect(agentChannel.listConversations).toHaveBeenLastCalledWith(
242
+ expect.objectContaining({
243
+ filter: ChannelConversationListFilter.filter_wants_human,
244
+ }),
245
+ ),
246
+ );
247
+ });
248
+
249
+ it("refetches the timeline after a reply so the real ledger item renders", async () => {
250
+ const user = userEvent.setup();
251
+ const client = createMockStigmer();
252
+ render(
253
+ <ConversationsWorkbench
254
+ org="acme"
255
+ selected={SELECTED}
256
+ onSelectionChange={vi.fn()}
257
+ />,
258
+ { wrapper: wrapper(client) },
259
+ );
260
+ const input = await screen.findByLabelText("Reply to the customer");
261
+ const agentChannel = (client as { agentChannel: Record<string, ReturnType<typeof vi.fn>> })
262
+ .agentChannel;
263
+ const timelineCallsBefore = agentChannel.getTimeline.mock.calls.length;
264
+
265
+ await user.type(input, "on my way{Enter}");
266
+
267
+ expect(agentChannel.reply).toHaveBeenCalled();
268
+ await waitFor(() =>
269
+ expect(agentChannel.getTimeline.mock.calls.length).toBeGreaterThan(
270
+ timelineCallsBefore,
271
+ ),
272
+ );
273
+ });
274
+
275
+ it("links the header's channel name to the host's channel page (F-11)", async () => {
276
+ render(
277
+ <ConversationsWorkbench
278
+ org="acme"
279
+ selected={SELECTED}
280
+ onSelectionChange={vi.fn()}
281
+ channelHref={(channel) =>
282
+ channel.spec?.agentRef
283
+ ? `/library/agents/${channel.spec.agentRef.org}/${channel.spec.agentRef.slug}?tab=channels`
284
+ : null
285
+ }
286
+ />,
287
+ { wrapper: wrapper(createMockStigmer()) },
288
+ );
289
+
290
+ const link = await screen.findByRole("link", { name: "support-line" });
291
+ expect(link.getAttribute("href")).toBe(
292
+ "/library/agents/acme/support-agent?tab=channels",
293
+ );
294
+ });
295
+
296
+ it("keeps the channel name plain text when the host provides no channel route (F-11)", async () => {
297
+ render(
298
+ <ConversationsWorkbench org="acme" selected={SELECTED} onSelectionChange={vi.fn()} />,
299
+ { wrapper: wrapper(createMockStigmer()) },
300
+ );
301
+
302
+ await waitFor(() =>
303
+ expect(
304
+ screen.getByText(
305
+ (_, el) => el?.tagName === "P" && el.textContent === "15550001111 · support-line",
306
+ ),
307
+ ).toBeDefined(),
308
+ );
309
+ expect(screen.queryByRole("link")).toBeNull();
310
+ });
311
+
312
+ it("hands the selected channel to a function-form headerAccessory (F-11)", async () => {
313
+ render(
314
+ <ConversationsWorkbench
315
+ org="acme"
316
+ selected={SELECTED}
317
+ onSelectionChange={vi.fn()}
318
+ headerAccessory={({ channel }) => (
319
+ <span>accessory for {channel?.metadata?.name ?? "nobody"}</span>
320
+ )}
321
+ />,
322
+ { wrapper: wrapper(createMockStigmer()) },
323
+ );
324
+
325
+ // The context carries the loaded channel, so a host can scope its
326
+ // access affordance ("Channel access" + the channel's name) without
327
+ // re-fetching what the workbench already holds.
328
+ await waitFor(() =>
329
+ expect(screen.getByText("accessory for support-line")).toBeDefined(),
330
+ );
331
+ });
332
+
333
+ it("keeps the composer busy after a reply until the sent item is on screen (F-05)", async () => {
334
+ const user = userEvent.setup();
335
+ const initialItems = [customerItem("wa:1", "where is my order?")];
336
+ let releaseRefetch!: (page: {
337
+ items: readonly unknown[];
338
+ nextPageToken: string;
339
+ }) => void;
340
+ const getTimeline = vi
341
+ .fn()
342
+ // Initial load, then the post-reply refetch held open under test
343
+ // control, then a default for any later poll tick.
344
+ .mockResolvedValueOnce({ items: initialItems, nextPageToken: "" })
345
+ .mockImplementationOnce(
346
+ () =>
347
+ new Promise((resolve) => {
348
+ releaseRefetch = resolve;
349
+ }),
350
+ )
351
+ .mockResolvedValue({ items: initialItems, nextPageToken: "" });
352
+ const client = createMockStigmer({ getTimeline });
353
+ render(
354
+ <ConversationsWorkbench org="acme" selected={SELECTED} onSelectionChange={vi.fn()} />,
355
+ { wrapper: wrapper(client) },
356
+ );
357
+ const input = await screen.findByLabelText("Reply to the customer");
358
+
359
+ await user.type(input, "on my way{Enter}");
360
+
361
+ // The post-reply refetch firing proves the reply COMMAND fully
362
+ // settled; flush its state commits. The reply's ledger item is
363
+ // still in flight, so the composer must STILL be busy — an idle
364
+ // button over an emptied box with the message nowhere on screen is
365
+ // exactly the F-05 dead interval.
366
+ await waitFor(() => expect(getTimeline).toHaveBeenCalledTimes(2));
367
+ await act(async () => {
368
+ await Promise.resolve();
369
+ await Promise.resolve();
370
+ });
371
+ const sendButton = screen.getByRole("button", { name: "Send reply" });
372
+ expect(sendButton.textContent).toContain("Sending…");
373
+ expect((sendButton as HTMLButtonElement).disabled).toBe(true);
374
+
375
+ // The refetch answers with the real ledger item (ob:<id>, the id
376
+ // the reply output promised) — busy clears only now.
377
+ releaseRefetch({
378
+ items: [
379
+ publicItem(
380
+ "ob:obm_1",
381
+ ConversationItemAuthor.author_teammate,
382
+ "2026-08-07T10:06:00Z",
383
+ "on my way",
384
+ ),
385
+ ...initialItems,
386
+ ],
387
+ nextPageToken: "",
388
+ });
389
+ await waitFor(() => expect(screen.getByText("on my way")).toBeDefined());
390
+ await waitFor(() => {
391
+ const btn = screen.getByRole("button", { name: "Send reply" });
392
+ expect(btn.textContent).not.toContain("Sending…");
393
+ });
394
+ });
395
+
396
+ it("frees the composer immediately on a refused reply — the draft needs editing, not a wait (F-05)", async () => {
397
+ const user = userEvent.setup();
398
+ const client = createMockStigmer({
399
+ reply: vi.fn().mockResolvedValue(
400
+ create(SendChannelMessageOutputSchema, {
401
+ outcome: ChannelSendOutcome.refused,
402
+ detail: "the 24-hour service window is closed",
403
+ }),
404
+ ),
405
+ });
406
+ render(
407
+ <ConversationsWorkbench org="acme" selected={SELECTED} onSelectionChange={vi.fn()} />,
408
+ { wrapper: wrapper(client) },
409
+ );
410
+ const input = await screen.findByLabelText("Reply to the customer");
411
+
412
+ await user.type(input, "did this arrive?{Enter}");
413
+
414
+ await waitFor(() =>
415
+ expect(screen.getByText("the 24-hour service window is closed")).toBeDefined(),
416
+ );
417
+ // Refusal restores the draft for editing — holding the composer
418
+ // busy would fight the correction the notice is asking for.
419
+ expect((input as HTMLTextAreaElement).value).toBe("did this arrive?");
420
+ const sendButton = screen.getByRole("button", { name: "Send reply" });
421
+ expect(sendButton.textContent).not.toContain("Sending…");
422
+ expect((sendButton as HTMLButtonElement).disabled).toBe(false);
423
+ });
424
+
425
+ it("arms the handback confirm from a customer message that arrives on a poll tick (F-16, DD-007 D-e)", async () => {
426
+ // The composed guard path: detail poll → the row's awaiting_reply →
427
+ // confirm (F-28 moved the guard's input from timeline authorship to
428
+ // the server fact). The banner-level guard is pinned in
429
+ // ConversationControlBanner.test.tsx; what production exposed (and
430
+ // a starved poll would break again) is the fact ARRIVING here at
431
+ // all — so this test flips awaiting_reply via a later poll tick,
432
+ // never via the initial load. The detail poll is the hand-rolled
433
+ // loop that survived the F-14 starvation (OQ-2), and the timeline
434
+ // poll must still deliver the message VISIBLY in the same window.
435
+ vi.useFakeTimers();
436
+ try {
437
+ const answered = [
438
+ publicItem(
439
+ "dl:2",
440
+ ConversationItemAuthor.author_agent,
441
+ "2026-08-07T10:01:00Z",
442
+ "We are open 6am to 10pm.",
443
+ ),
444
+ publicItem(
445
+ "wa:1",
446
+ ConversationItemAuthor.author_customer,
447
+ "2026-08-07T10:00:00Z",
448
+ "When are you open?",
449
+ ),
450
+ ];
451
+ const getTimeline = vi
452
+ .fn()
453
+ .mockResolvedValue({ items: answered, nextPageToken: "" });
454
+ const getConversation = vi.fn().mockResolvedValue(
455
+ row({
456
+ control: ConversationControl.control_human,
457
+ controlledBy: "idt_me",
458
+ awaitingReply: false,
459
+ }),
460
+ );
461
+ const client = createMockStigmer({ getConversation, getTimeline });
462
+ render(
463
+ <ConversationsWorkbench
464
+ org="acme"
465
+ selected={SELECTED}
466
+ onSelectionChange={vi.fn()}
467
+ currentIdentityAccountId="idt_me"
468
+ />,
469
+ { wrapper: wrapper(client) },
470
+ );
471
+
472
+ // Flush the initial load: human-held, the customer answered.
473
+ await act(async () => {
474
+ await Promise.resolve();
475
+ await Promise.resolve();
476
+ });
477
+ expect(
478
+ screen.getByRole("button", { name: "Hand back to agent" }),
479
+ ).toBeDefined();
480
+ const callsBeforePoll = getTimeline.mock.calls.length;
481
+
482
+ // The customer writes again: the next detail poll answers the
483
+ // row with awaiting_reply true, the next timeline poll delivers
484
+ // the message itself.
485
+ getConversation.mockResolvedValue(
486
+ row({
487
+ control: ConversationControl.control_human,
488
+ controlledBy: "idt_me",
489
+ awaitingReply: true,
490
+ }),
491
+ );
492
+ getTimeline.mockResolvedValue({
493
+ items: [
494
+ publicItem(
495
+ "wa:3",
496
+ ConversationItemAuthor.author_customer,
497
+ "2026-08-07T10:05:00Z",
498
+ "And on Sundays?",
499
+ ),
500
+ ...answered,
501
+ ],
502
+ nextPageToken: "",
503
+ });
504
+ await advanceInSlices(5000);
505
+ expect(getTimeline.mock.calls.length).toBeGreaterThan(callsBeforePoll);
506
+ expect(screen.getByText("And on Sundays?")).toBeDefined();
507
+
508
+ // The polled-in unanswered message must arm the confirm guard.
509
+ // fireEvent, not userEvent: userEvent's inter-event waits hang
510
+ // under fake timers, and pointer realism adds nothing here — the
511
+ // confirm's interaction contract is pinned with real userEvent in
512
+ // ConversationControlBanner.test.tsx.
513
+ fireEvent.click(screen.getByRole("button", { name: "Hand back to agent" }));
514
+ expect(
515
+ screen.getByText(/The customer's last message has no reply/),
516
+ ).toBeDefined();
517
+ const agentChannel = (client as {
518
+ agentChannel: Record<string, ReturnType<typeof vi.fn>>;
519
+ }).agentChannel;
520
+ expect(agentChannel.handBack).not.toHaveBeenCalled();
521
+ } finally {
522
+ vi.useRealTimers();
523
+ }
524
+ });
525
+
526
+ it("keeps the confirm armed when the newest item is the staff's own BOUNCED reply (F-28, DD-007 D-e)", async () => {
527
+ // The F-28 live shape (2026-08-08, S11): the newest customer-visible
528
+ // item is the staff's reply — words Meta later bounced, so the
529
+ // customer never received them. Timeline authorship reads
530
+ // "answered"; the row's awaiting_reply reads the truth, because the
531
+ // receipt-side void re-opened it (T08, DD-015). The guard must read
532
+ // the fact, never re-derive it from timeline shape — the client-side
533
+ // twin of the re-derivation DD-011 A-1 removed server-side.
534
+ const user = userEvent.setup();
535
+ const client = createMockStigmer({
536
+ getConversation: vi.fn().mockResolvedValue(
537
+ row({
538
+ control: ConversationControl.control_human,
539
+ controlledBy: "idt_me",
540
+ awaitingReply: true,
541
+ }),
542
+ ),
543
+ getTimeline: vi.fn().mockResolvedValue({
544
+ items: [
545
+ create(ConversationTimelineItemSchema, {
546
+ itemId: "ob:obm_bounced",
547
+ lane: ConversationLane.lane_public,
548
+ author: ConversationItemAuthor.author_teammate,
549
+ text: "We're on it — expect a call today.",
550
+ at: timestampFromDate(new Date("2026-08-07T10:05:00Z")),
551
+ receiptState: ChannelReceiptState.receipt_failed,
552
+ receiptDetail: "Re-engagement message required",
553
+ }),
554
+ customerItem("wa:1", "where is my order?"),
555
+ ],
556
+ nextPageToken: "",
557
+ }),
558
+ });
559
+ render(
560
+ <ConversationsWorkbench
561
+ org="acme"
562
+ selected={SELECTED}
563
+ onSelectionChange={vi.fn()}
564
+ currentIdentityAccountId="idt_me"
565
+ />,
566
+ { wrapper: wrapper(client) },
567
+ );
568
+ const handBackButton = await screen.findByRole("button", {
569
+ name: "Hand back to agent",
570
+ });
571
+
572
+ await user.click(handBackButton);
573
+
574
+ expect(
575
+ screen.getByText(/The customer's last message has no reply/),
576
+ ).toBeDefined();
577
+ const agentChannel = (client as {
578
+ agentChannel: Record<string, ReturnType<typeof vi.fn>>;
579
+ }).agentChannel;
580
+ expect(agentChannel.handBack).not.toHaveBeenCalled();
581
+ });
582
+
583
+ it("keeps the confirm armed when the platform acknowledgment is the newest item (F-28)", async () => {
584
+ // Platform copy ("someone from our team is looking at this") never
585
+ // answers the customer — it never stamps last_answered_at (DD-011
586
+ // D-b) — yet an authorship-derived guard read `platform ≠ customer`
587
+ // and went silent. The same latent miss as the bounced reply, fixed
588
+ // by the same move: the row's fact decides.
589
+ const user = userEvent.setup();
590
+ const client = createMockStigmer({
591
+ getConversation: vi.fn().mockResolvedValue(
592
+ row({
593
+ control: ConversationControl.control_human,
594
+ controlledBy: "idt_me",
595
+ awaitingReply: true,
596
+ }),
597
+ ),
598
+ getTimeline: vi.fn().mockResolvedValue({
599
+ items: [
600
+ publicItem(
601
+ "out:ack1",
602
+ ConversationItemAuthor.author_platform,
603
+ "2026-08-07T10:05:00Z",
604
+ "Someone from our team is looking at this and will reply here.",
605
+ ),
606
+ customerItem("wa:1", "I need a real person"),
607
+ ],
608
+ nextPageToken: "",
609
+ }),
610
+ });
611
+ render(
612
+ <ConversationsWorkbench
613
+ org="acme"
614
+ selected={SELECTED}
615
+ onSelectionChange={vi.fn()}
616
+ currentIdentityAccountId="idt_me"
617
+ />,
618
+ { wrapper: wrapper(client) },
619
+ );
620
+ const handBackButton = await screen.findByRole("button", {
621
+ name: "Hand back to agent",
622
+ });
623
+
624
+ await user.click(handBackButton);
625
+
626
+ expect(
627
+ screen.getByText(/The customer's last message has no reply/),
628
+ ).toBeDefined();
629
+ const agentChannel = (client as {
630
+ agentChannel: Record<string, ReturnType<typeof vi.fn>>;
631
+ }).agentChannel;
632
+ expect(agentChannel.handBack).not.toHaveBeenCalled();
633
+ });
634
+
635
+ it("hands back without the confirm once the row says answered — the fact, never the timeline's shape, decides (F-28)", async () => {
636
+ // The mirror case: the staff answer DELIVERED (the row settled
637
+ // awaiting_reply = false) but the reply's ledger item has not
638
+ // reached the independently-polled timeline window yet, so the
639
+ // newest visible item is still the customer's. An authorship guard
640
+ // warns about a customer who was in fact answered; the fact-reading
641
+ // guard does not. Same single-source-of-truth contract, disarm
642
+ // direction.
643
+ const user = userEvent.setup();
644
+ const client = createMockStigmer({
645
+ getConversation: vi.fn().mockResolvedValue(
646
+ row({
647
+ control: ConversationControl.control_human,
648
+ controlledBy: "idt_me",
649
+ awaitingReply: false,
650
+ }),
651
+ ),
652
+ getTimeline: vi.fn().mockResolvedValue({
653
+ items: [customerItem("wa:1", "where is my order?")],
654
+ nextPageToken: "",
655
+ }),
656
+ });
657
+ render(
658
+ <ConversationsWorkbench
659
+ org="acme"
660
+ selected={SELECTED}
661
+ onSelectionChange={vi.fn()}
662
+ currentIdentityAccountId="idt_me"
663
+ />,
664
+ { wrapper: wrapper(client) },
665
+ );
666
+ const handBackButton = await screen.findByRole("button", {
667
+ name: "Hand back to agent",
668
+ });
669
+
670
+ await user.click(handBackButton);
671
+
672
+ const agentChannel = (client as {
673
+ agentChannel: Record<string, ReturnType<typeof vi.fn>>;
674
+ }).agentChannel;
675
+ await waitFor(() => expect(agentChannel.handBack).toHaveBeenCalled());
676
+ expect(
677
+ screen.queryByText(/The customer's last message has no reply/),
678
+ ).toBeNull();
679
+ });
680
+
681
+ it("resets the composer draft when switching conversations (F-22)", async () => {
682
+ // The detail column is keyed by conversation identity (DD-014), so a
683
+ // switch REMOUNTS it — a half-typed reply to customer A must never
684
+ // sit in the box one Enter away from being sent to customer B. This
685
+ // is the remount's observable contract: remove the key and this
686
+ // fails.
687
+ const user = userEvent.setup();
688
+ const other = { agentChannelId: "ach_wa", conversationKey: "15550009999" };
689
+ const client = createMockStigmer({
690
+ listConversations: vi.fn().mockResolvedValue({
691
+ items: [row(), row({ conversationKey: other.conversationKey, displayName: "Sam" })],
692
+ totalCount: 2,
693
+ }),
694
+ });
695
+ const { rerender } = render(
696
+ <ConversationsWorkbench org="acme" selected={SELECTED} onSelectionChange={vi.fn()} />,
697
+ { wrapper: wrapper(client) },
698
+ );
699
+ const input = await screen.findByLabelText("Reply to the customer");
700
+
701
+ await user.type(input, "half-typed reply meant for Pat");
702
+ expect((input as HTMLTextAreaElement).value).toBe("half-typed reply meant for Pat");
703
+
704
+ rerender(
705
+ <ConversationsWorkbench org="acme" selected={other} onSelectionChange={vi.fn()} />,
706
+ );
707
+
708
+ const freshInput = await screen.findByLabelText("Reply to the customer");
709
+ expect((freshInput as HTMLTextAreaElement).value).toBe("");
710
+ });
711
+
712
+ it("shows the closed-window advisory on a stale WhatsApp conversation, input still enabled (DD-014 D-b)", async () => {
713
+ const client = createMockStigmer({
714
+ getConversation: vi.fn().mockResolvedValue(
715
+ // The customer last wrote 27 hours before `now` — well outside
716
+ // Meta's 24-hour customer-service window.
717
+ row({ lastCustomerMessageAt: timestampFromDate(new Date("2026-08-07T09:00:00Z")) }),
718
+ ),
719
+ });
720
+ render(
721
+ <ConversationsWorkbench
722
+ org="acme"
723
+ selected={SELECTED}
724
+ onSelectionChange={vi.fn()}
725
+ now={new Date("2026-08-08T12:00:00Z")}
726
+ />,
727
+ { wrapper: wrapper(client) },
728
+ );
729
+
730
+ await waitFor(() =>
731
+ expect(screen.getByText(/closes free-form replies 24 hours/)).toBeDefined(),
732
+ );
733
+ // A forecast, never a gate: the input stays usable — Meta and the
734
+ // server refusal remain the authority (DD-014 D-b).
735
+ const input = screen.getByLabelText("Reply to the customer") as HTMLTextAreaElement;
736
+ expect(input.disabled).toBe(false);
737
+ });
738
+
739
+ it("shows no advisory inside the window", async () => {
740
+ const client = createMockStigmer({
741
+ getConversation: vi.fn().mockResolvedValue(
742
+ row({ lastCustomerMessageAt: timestampFromDate(new Date("2026-08-08T11:00:00Z")) }),
743
+ ),
744
+ });
745
+ render(
746
+ <ConversationsWorkbench
747
+ org="acme"
748
+ selected={SELECTED}
749
+ onSelectionChange={vi.fn()}
750
+ now={new Date("2026-08-08T12:00:00Z")}
751
+ />,
752
+ { wrapper: wrapper(client) },
753
+ );
754
+
755
+ await screen.findByLabelText("Reply to the customer");
756
+ expect(screen.queryByText(/closes free-form replies/)).toBeNull();
757
+ });
758
+
759
+ it("recomputes the advisory at render — same data, later instant", async () => {
760
+ const client = createMockStigmer({
761
+ getConversation: vi.fn().mockResolvedValue(
762
+ row({ lastCustomerMessageAt: timestampFromDate(new Date("2026-08-07T09:00:00Z")) }),
763
+ ),
764
+ });
765
+ const { rerender } = render(
766
+ <ConversationsWorkbench
767
+ org="acme"
768
+ selected={SELECTED}
769
+ onSelectionChange={vi.fn()}
770
+ // 23 hours after the customer's message: still open.
771
+ now={new Date("2026-08-08T08:00:00Z")}
772
+ />,
773
+ { wrapper: wrapper(client) },
774
+ );
775
+ await screen.findByLabelText("Reply to the customer");
776
+ expect(screen.queryByText(/closes free-form replies/)).toBeNull();
777
+
778
+ rerender(
779
+ <ConversationsWorkbench
780
+ org="acme"
781
+ selected={SELECTED}
782
+ onSelectionChange={vi.fn()}
783
+ // 24.5 hours: the boundary crossed with NO data change.
784
+ now={new Date("2026-08-08T09:30:00Z")}
785
+ />,
786
+ );
787
+ expect(screen.getByText(/closes free-form replies/)).toBeDefined();
788
+ });
789
+
790
+ it("the 5s detail poll alone carries the advisory over the boundary — no data change, no memo freeze (DD-014 D-b)", async () => {
791
+ // The load-bearing composition fact this test pins: `useConversation`
792
+ // preserves the row's object REFERENCE when a poll answers identical
793
+ // data (DD-010), so the advisory cannot ride a data change — only the
794
+ // poll's own re-render refreshes the wall-clock estimate. Wrapping
795
+ // the workbench's advisory computation in a useMemo keyed on
796
+ // [detail.conversation] — a plausible "cleanup" — freezes the warning
797
+ // forever on a quiet conversation, and THIS test is what fails.
798
+ const SEED = new Date("2026-08-08T12:00:00Z");
799
+ // Explicit clock seed: the boundary math must not ride vitest's
800
+ // toFake defaults.
801
+ vi.useFakeTimers({ now: SEED });
802
+ try {
803
+ const staleRow = row({
804
+ // 10 seconds inside the window at mount; the poll ticks cross it.
805
+ lastCustomerMessageAt: timestampFromDate(
806
+ new Date(SEED.getTime() - (24 * 60 * 60 * 1000 - 10_000)),
807
+ ),
808
+ });
809
+ const client = createMockStigmer({
810
+ getConversation: vi.fn().mockResolvedValue(staleRow),
811
+ });
812
+ render(
813
+ <ConversationsWorkbench org="acme" selected={SELECTED} onSelectionChange={vi.fn()} />,
814
+ { wrapper: wrapper(client) },
815
+ );
816
+
817
+ await act(async () => {
818
+ await Promise.resolve();
819
+ await Promise.resolve();
820
+ });
821
+ expect(screen.getByLabelText("Reply to the customer")).toBeDefined();
822
+ expect(screen.queryByText(/closes free-form replies/)).toBeNull();
823
+
824
+ // Three detail-poll ticks, every answer the SAME row object.
825
+ await advanceInSlices(15_000);
826
+ expect(screen.getByText(/closes free-form replies/)).toBeDefined();
827
+ } finally {
828
+ vi.useRealTimers();
829
+ }
830
+ });
831
+
832
+ it("disables the composer on Slack with the honest reason", async () => {
833
+ const client = createMockStigmer({
834
+ list: vi.fn().mockResolvedValue({ items: [slackChannel()], totalCount: 1 }),
835
+ listConversations: vi.fn().mockResolvedValue({
836
+ items: [row({ agentChannelId: "ach_sl", conversationKey: "1723012345.678900" })],
837
+ totalCount: 1,
838
+ }),
839
+ getConversation: vi
840
+ .fn()
841
+ .mockResolvedValue(row({ agentChannelId: "ach_sl", conversationKey: "1723012345.678900" })),
842
+ getTimeline: vi.fn().mockResolvedValue({ items: [], nextPageToken: "" }),
843
+ });
844
+ render(
845
+ <ConversationsWorkbench
846
+ org="acme"
847
+ selected={{ agentChannelId: "ach_sl", conversationKey: "1723012345.678900" }}
848
+ onSelectionChange={vi.fn()}
849
+ />,
850
+ { wrapper: wrapper(client) },
851
+ );
852
+
853
+ await waitFor(() =>
854
+ expect(
855
+ screen.getByText(/Staff replies aren't available on Slack channels yet/),
856
+ ).toBeDefined(),
857
+ );
858
+ expect(screen.queryByLabelText("Reply to the customer")).toBeNull();
859
+ });
860
+ });