@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,235 @@
1
+ // Accessibility audit — the conversation list pane and timeline view.
2
+ //
3
+ // Covers the populated inbox (attention badge, human-control caption,
4
+ // channel filter, the wants-human filter control, both awaiting-reply
5
+ // indicator strengths), the WhatsApp-style timeline (both bubble sides,
6
+ // the two status axes, the visible receipt-failure explanation,
7
+ // internal-lane system rows, the Slack honesty notice), the staff
8
+ // composer with the closed-window advisory (T07 DD-014), and the empty
9
+ // states including the filtered one — each in light + dark against the
10
+ // shipped stylesheet.
11
+
12
+ import { describe, it, afterEach, vi } from "vitest";
13
+ import { create } from "@bufbuild/protobuf";
14
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
15
+ import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
16
+ import {
17
+ ChannelConversationListFilter,
18
+ ChannelConversationSchema,
19
+ ConversationControl,
20
+ ConversationItemAuthor,
21
+ ConversationLane,
22
+ ConversationTimelineItemSchema,
23
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
24
+ import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
25
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
26
+ import { ConversationComposer } from "../../ConversationComposer.js";
27
+ import { ConversationListPane } from "../../ConversationListPane.js";
28
+ import { ConversationTimelineView } from "../../ConversationTimelineView.js";
29
+ import {
30
+ COLOR_MODES,
31
+ auditA11y,
32
+ renderAudited,
33
+ resetConversationAudit,
34
+ } from "./harness.js";
35
+
36
+ const NOW = new Date("2026-08-07T12:00:00Z");
37
+ const noop = () => {};
38
+
39
+ const channels = [
40
+ create(AgentChannelSchema, {
41
+ metadata: { id: "ach_wa", org: "acme", name: "support-line", slug: "support-line" },
42
+ spec: { providerConfig: { case: "whatsapp", value: {} } },
43
+ }),
44
+ create(AgentChannelSchema, {
45
+ metadata: { id: "ach_sl", org: "acme", name: "eng-help", slug: "eng-help" },
46
+ spec: { providerConfig: { case: "slack", value: {} } },
47
+ }),
48
+ ];
49
+
50
+ const conversations = [
51
+ create(ChannelConversationSchema, {
52
+ agentChannelId: "ach_wa",
53
+ conversationKey: "15550001111",
54
+ org: "acme",
55
+ displayName: "Pat",
56
+ needsAttention: true,
57
+ attentionReason: "refund I cannot process",
58
+ // Agent-held and awaiting: the muted indicator strength.
59
+ awaitingReply: true,
60
+ lastActivityAt: timestampFromDate(new Date("2026-08-07T11:55:00Z")),
61
+ }),
62
+ create(ChannelConversationSchema, {
63
+ agentChannelId: "ach_wa",
64
+ conversationKey: "15550002222",
65
+ org: "acme",
66
+ control: ConversationControl.control_human,
67
+ controlledBy: "idt_staff",
68
+ // Human-held and awaiting: the strong indicator strength (F-13).
69
+ awaitingReply: true,
70
+ lastActivityAt: timestampFromDate(new Date("2026-08-07T09:00:00Z")),
71
+ }),
72
+ ];
73
+
74
+ const timelineItems = [
75
+ create(ConversationTimelineItemSchema, {
76
+ itemId: "wa:1",
77
+ lane: ConversationLane.lane_public,
78
+ author: ConversationItemAuthor.author_customer,
79
+ text: "where is my order?",
80
+ at: timestampFromDate(new Date("2026-08-06T18:00:00Z")),
81
+ }),
82
+ create(ConversationTimelineItemSchema, {
83
+ itemId: "wa:2",
84
+ lane: ConversationLane.lane_public,
85
+ author: ConversationItemAuthor.author_customer,
86
+ providerMessageType: "image",
87
+ at: timestampFromDate(new Date("2026-08-07T08:59:00Z")),
88
+ }),
89
+ create(ConversationTimelineItemSchema, {
90
+ itemId: "ev:3",
91
+ lane: ConversationLane.lane_internal,
92
+ author: ConversationItemAuthor.author_agent,
93
+ text: "the member is asking about a refund I cannot process",
94
+ at: timestampFromDate(new Date("2026-08-07T09:00:00Z")),
95
+ }),
96
+ create(ConversationTimelineItemSchema, {
97
+ itemId: "dl:4",
98
+ lane: ConversationLane.lane_public,
99
+ author: ConversationItemAuthor.author_agent,
100
+ text: "checking with the team now",
101
+ deliveryStatus: ChannelDeliveryStatus.delivered,
102
+ receiptState: ChannelReceiptState.receipt_read,
103
+ at: timestampFromDate(new Date("2026-08-07T09:01:00Z")),
104
+ }),
105
+ create(ConversationTimelineItemSchema, {
106
+ itemId: "ob:5",
107
+ lane: ConversationLane.lane_public,
108
+ author: ConversationItemAuthor.author_teammate,
109
+ text: "I'll take this personally",
110
+ deliveryStatus: ChannelDeliveryStatus.delivered,
111
+ receiptState: ChannelReceiptState.receipt_failed,
112
+ // The T07 visible explanation line (DD-014 D-c) rides the audit.
113
+ receiptDetail:
114
+ "More than 24 hours have passed since the recipient last replied to the sender number.",
115
+ receiptErrorCode: 131047,
116
+ at: timestampFromDate(new Date("2026-08-07T09:02:00Z")),
117
+ }),
118
+ create(ConversationTimelineItemSchema, {
119
+ itemId: "ob:6",
120
+ lane: ConversationLane.lane_public,
121
+ author: ConversationItemAuthor.author_agent,
122
+ text: "a reminder that never went",
123
+ deliveryStatus: ChannelDeliveryStatus.suppressed,
124
+ at: timestampFromDate(new Date("2026-08-07T09:03:00Z")),
125
+ }),
126
+ ];
127
+
128
+ const listBaseProps = {
129
+ isLoading: false,
130
+ error: null,
131
+ hasMore: true,
132
+ loadMore: noop,
133
+ isLoadingMore: false,
134
+ channels,
135
+ channelFilter: "",
136
+ onChannelFilterChange: noop,
137
+ onSelect: noop,
138
+ now: NOW,
139
+ };
140
+
141
+ const timelineBaseProps = {
142
+ isLoading: false,
143
+ error: null,
144
+ hasOlder: true,
145
+ loadOlder: noop,
146
+ isLoadingOlder: false,
147
+ now: NOW,
148
+ };
149
+
150
+ afterEach(() => {
151
+ resetConversationAudit();
152
+ vi.restoreAllMocks();
153
+ });
154
+
155
+ describe("Conversation surfaces a11y", () => {
156
+ it.each(COLOR_MODES)("populated inbox with badges, indicators, and filters (%s)", async (mode) => {
157
+ const container = renderAudited(
158
+ <ConversationListPane
159
+ {...listBaseProps}
160
+ conversations={conversations}
161
+ onFilterChange={noop}
162
+ selected={{ agentChannelId: "ach_wa", conversationKey: "15550001111" }}
163
+ />,
164
+ mode,
165
+ );
166
+ await auditA11y(container, `conversation list · ${mode}`);
167
+ });
168
+
169
+ it.each(COLOR_MODES)("inbox empty states (%s)", async (mode) => {
170
+ const container = renderAudited(
171
+ <ConversationListPane
172
+ {...listBaseProps}
173
+ conversations={[]}
174
+ channels={[]}
175
+ hasMore={false}
176
+ selected={null}
177
+ />,
178
+ mode,
179
+ );
180
+ await auditA11y(container, `conversation list empty · ${mode}`);
181
+ });
182
+
183
+ it.each(COLOR_MODES)("filtered inbox empty state (%s)", async (mode) => {
184
+ const container = renderAudited(
185
+ <ConversationListPane
186
+ {...listBaseProps}
187
+ conversations={[]}
188
+ hasMore={false}
189
+ filter={ChannelConversationListFilter.filter_wants_human}
190
+ onFilterChange={noop}
191
+ selected={null}
192
+ />,
193
+ mode,
194
+ );
195
+ await auditA11y(container, `filtered conversation list empty · ${mode}`);
196
+ });
197
+
198
+ it.each(COLOR_MODES)("timeline with both sides, status axes, internal rows (%s)", async (mode) => {
199
+ const container = renderAudited(
200
+ <ConversationTimelineView
201
+ {...timelineBaseProps}
202
+ items={timelineItems}
203
+ provider="whatsapp"
204
+ />,
205
+ mode,
206
+ );
207
+ await auditA11y(container, `conversation timeline · ${mode}`);
208
+ });
209
+
210
+ it.each(COLOR_MODES)("composer with the closed-window advisory (%s)", async (mode) => {
211
+ const container = renderAudited(
212
+ <ConversationComposer
213
+ onSend={() => Promise.reject(new Error("never sent in an audit"))}
214
+ isSending={false}
215
+ disabledReason={null}
216
+ advisory="WhatsApp 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."
217
+ />,
218
+ mode,
219
+ );
220
+ await auditA11y(container, `composer with advisory · ${mode}`);
221
+ });
222
+
223
+ it.each(COLOR_MODES)("timeline Slack honesty notice (%s)", async (mode) => {
224
+ const container = renderAudited(
225
+ <ConversationTimelineView
226
+ {...timelineBaseProps}
227
+ items={[]}
228
+ hasOlder={false}
229
+ provider="slack"
230
+ />,
231
+ mode,
232
+ );
233
+ await auditA11y(container, `slack timeline notice · ${mode}`);
234
+ });
235
+ });
@@ -0,0 +1,100 @@
1
+ // Shared harness for the conversation surface's browser-mode a11y audits.
2
+ //
3
+ // The audit policy (WCAG 2.0 A/AA via axe-core, fail on critical/serious,
4
+ // log the rest, `color-contrast` advisory pending the app-wide token
5
+ // contrast pass) is the workspace suite's — see
6
+ // `src/workspace/__tests__/a11y/harness.tsx`, the canonical statement of
7
+ // the policy and its rationale. This is the SECOND a11y suite; per the
8
+ // house third-call-site rule the shared extraction happens when a third
9
+ // surface needs it, not speculatively here.
10
+
11
+ import "../../../../dist/styles.css";
12
+
13
+ import { render, cleanup } from "@testing-library/react";
14
+ import type { ReactElement } from "react";
15
+ import axe from "axe-core";
16
+ import { expect } from "vitest";
17
+ import type { Stigmer } from "@stigmer/sdk";
18
+ import { StigmerContext } from "../../../context.js";
19
+
20
+ /** The color modes every state is audited under. */
21
+ export const COLOR_MODES = ["light", "dark"] as const;
22
+ export type ColorMode = (typeof COLOR_MODES)[number];
23
+
24
+ /** See the workspace harness for why contrast stays advisory for now. */
25
+ const ADVISORY_RULE_IDS = new Set<string>(["color-contrast"]);
26
+
27
+ // The audited components are presentational (data arrives as props), so no
28
+ // client method is ever invoked — but `useStigmer` throws outside a
29
+ // provider, so a stub context is still required.
30
+ const stubClient = {} as unknown as Stigmer;
31
+
32
+ /**
33
+ * Render `ui` inside a themed `.stgm` container for the given color mode,
34
+ * attached to the live DOM so both `screen` queries and axe can see it.
35
+ */
36
+ export function renderAudited(ui: ReactElement, mode: ColorMode): HTMLElement {
37
+ const container = document.createElement("div");
38
+ container.className = "stgm";
39
+ container.setAttribute("data-stgm-color-mode", mode);
40
+ container.style.background = "var(--stgm-background)";
41
+ container.style.color = "var(--stgm-foreground)";
42
+ // A real box for the flex/scroll panes to lay out in.
43
+ container.style.height = "720px";
44
+ container.style.width = "1024px";
45
+ document.body.appendChild(container);
46
+
47
+ render(<StigmerContext.Provider value={stubClient}>{ui}</StigmerContext.Provider>, {
48
+ container,
49
+ });
50
+ return container;
51
+ }
52
+
53
+ function summarize(violations: axe.Result[]): string {
54
+ return violations
55
+ .map((v) => {
56
+ const nodes = v.nodes
57
+ .slice(0, 5)
58
+ .map((n) => ` · ${n.target.join(" ")}\n ${n.html.slice(0, 160)}`)
59
+ .join("\n");
60
+ return ` [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} node(s))\n${nodes}`;
61
+ })
62
+ .join("\n");
63
+ }
64
+
65
+ /**
66
+ * Run the WCAG 2.0 A/AA audit over `container`. Fails the test on any
67
+ * critical/serious violation; logs moderate/minor and advisory rules.
68
+ */
69
+ export async function auditA11y(container: HTMLElement, label: string): Promise<void> {
70
+ const results = await axe.run(container, {
71
+ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] },
72
+ });
73
+
74
+ const serious = results.violations.filter(
75
+ (v) =>
76
+ (v.impact === "critical" || v.impact === "serious") &&
77
+ !ADVISORY_RULE_IDS.has(v.id),
78
+ );
79
+ const advisory = results.violations.filter(
80
+ (v) =>
81
+ ADVISORY_RULE_IDS.has(v.id) ||
82
+ v.impact === "moderate" ||
83
+ v.impact === "minor",
84
+ );
85
+
86
+ if (advisory.length > 0) {
87
+ console.warn(`[a11y: ${label}] non-blocking violations:\n${summarize(advisory)}`);
88
+ }
89
+
90
+ expect(
91
+ serious,
92
+ `[a11y: ${label}] critical/serious axe violations:\n${summarize(serious)}`,
93
+ ).toHaveLength(0);
94
+ }
95
+
96
+ /** Reset cross-test state; call in `afterEach`. */
97
+ export function resetConversationAudit(): void {
98
+ cleanup();
99
+ document.querySelectorAll(".stgm").forEach((node) => node.remove());
100
+ }
@@ -0,0 +1,167 @@
1
+ // Tooltip behavior for the conversation status glyphs (F-18), in a real
2
+ // Chromium. The fix replaced native `title` hints (OS-delayed, invisible
3
+ // to keyboard and touch, dead on disabled buttons) with the house
4
+ // tooltip. The fast happy-dom suite pins the STRUCTURE (sr-only names
5
+ // kept, no titles, no new tab stops); it cannot honestly pin the
6
+ // REVEAL — Base UI opens on real pointer movement, which only a real
7
+ // browser delivers. This suite proves a mouse user actually gets the
8
+ // explanation. First real-browser tooltip coverage in the SDK; the
9
+ // WorkspaceSidebar recents tooltip rides the same primitive.
10
+
11
+ import "../../../dist/styles.css";
12
+
13
+ import { describe, it, expect, vi, afterEach } from "vitest";
14
+ import { render, cleanup } from "@testing-library/react";
15
+ import { userEvent } from "@vitest/browser/context";
16
+ import type { ReactElement } from "react";
17
+ import { create } from "@bufbuild/protobuf";
18
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
19
+ import {
20
+ ChannelConversationSchema,
21
+ ConversationItemAuthor,
22
+ ConversationLane,
23
+ ConversationTimelineItemSchema,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
25
+ import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
26
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
27
+ import { ConversationListPane } from "../ConversationListPane.js";
28
+ import { ConversationTimelineView } from "../ConversationTimelineView.js";
29
+
30
+ const NOW = new Date("2026-08-07T12:00:00Z");
31
+
32
+ /**
33
+ * A themed, sized box so hover geometry is real (the F-09 suite's
34
+ * shape) — narrow enough to fit the headless viewport, because a
35
+ * right-aligned business bubble in a pane wider than the viewport puts
36
+ * its receipt tick off-screen where a pointer cannot rest on it.
37
+ */
38
+ function renderInPane(ui: ReactElement) {
39
+ const pane = document.createElement("div");
40
+ pane.className = "stgm";
41
+ pane.style.height = "360px";
42
+ pane.style.width = "320px";
43
+ pane.style.display = "flex";
44
+ pane.style.flexDirection = "column";
45
+ document.body.appendChild(pane);
46
+ render(ui, { container: pane });
47
+ return pane;
48
+ }
49
+
50
+ /**
51
+ * Hover `trigger` and wait for `hint` to appear in the PORTAL — i.e. in
52
+ * a `document.body` child other than the pane, proving the tooltip
53
+ * escaped the scroll container — respecting Base UI's open delay.
54
+ */
55
+ async function expectHoverReveals(pane: HTMLElement, trigger: Element, hint: string) {
56
+ await userEvent.hover(trigger);
57
+ await vi.waitFor(
58
+ () => {
59
+ const portaled = Array.from(document.body.children)
60
+ .filter((node) => node !== pane)
61
+ .map((node) => node.textContent ?? "")
62
+ .join(" ");
63
+ expect(portaled).toContain(hint);
64
+ },
65
+ { timeout: 3000, interval: 50 },
66
+ );
67
+ await userEvent.unhover(trigger);
68
+ }
69
+
70
+ afterEach(() => {
71
+ cleanup();
72
+ document.querySelectorAll(".stgm").forEach((node) => node.remove());
73
+ });
74
+
75
+ describe("conversation status tooltips (F-18)", () => {
76
+ it("reveals the receipt and failure explanations on hover — with zero native titles", async () => {
77
+ const pane = renderInPane(
78
+ <ConversationTimelineView
79
+ items={[
80
+ create(ConversationTimelineItemSchema, {
81
+ itemId: "ob:read",
82
+ lane: ConversationLane.lane_public,
83
+ author: ConversationItemAuthor.author_teammate,
84
+ text: "your table is ready",
85
+ deliveryStatus: ChannelDeliveryStatus.delivered,
86
+ receiptState: ChannelReceiptState.receipt_read,
87
+ at: timestampFromDate(new Date("2026-08-07T11:58:00Z")),
88
+ }),
89
+ create(ConversationTimelineItemSchema, {
90
+ itemId: "dl:failed",
91
+ lane: ConversationLane.lane_public,
92
+ author: ConversationItemAuthor.author_agent,
93
+ text: "a reply that never arrived",
94
+ deliveryStatus: ChannelDeliveryStatus.failed,
95
+ at: timestampFromDate(new Date("2026-08-07T11:59:00Z")),
96
+ }),
97
+ ]}
98
+ isLoading={false}
99
+ error={null}
100
+ hasOlder={false}
101
+ loadOlder={() => {}}
102
+ isLoadingOlder={false}
103
+ provider="whatsapp"
104
+ now={NOW}
105
+ />,
106
+ );
107
+
108
+ expect(pane.querySelector("[title]")).toBeNull();
109
+
110
+ // The read tick's trigger: the outermost span whose text content is
111
+ // the sr-only name (document order puts the trigger before the
112
+ // sr-only span it contains).
113
+ const readTrigger = Array.from(pane.querySelectorAll("span")).find(
114
+ (el) => el.textContent === "Read",
115
+ );
116
+ await expectHoverReveals(pane, readTrigger!, "Read by the customer");
117
+
118
+ // The failed attempt keeps its visible short label and reveals the
119
+ // longer explanation on hover.
120
+ const failed = Array.from(pane.querySelectorAll("span")).find((el) =>
121
+ el.textContent?.includes("Not delivered"),
122
+ );
123
+ await expectHoverReveals(
124
+ pane,
125
+ failed!,
126
+ "The platform could not deliver this message",
127
+ );
128
+ });
129
+
130
+ it("reveals the attention reason on the inbox badge without focusable clutter", async () => {
131
+ const pane = renderInPane(
132
+ <ConversationListPane
133
+ conversations={[
134
+ create(ChannelConversationSchema, {
135
+ agentChannelId: "ach_wa",
136
+ conversationKey: "15550001111",
137
+ org: "acme",
138
+ displayName: "Pat",
139
+ needsAttention: true,
140
+ attentionReason: "refund I cannot process",
141
+ }),
142
+ ]}
143
+ isLoading={false}
144
+ error={null}
145
+ hasMore={false}
146
+ loadMore={() => {}}
147
+ isLoadingMore={false}
148
+ channels={[]}
149
+ channelFilter=""
150
+ onChannelFilterChange={() => {}}
151
+ selected={null}
152
+ onSelect={() => {}}
153
+ now={NOW}
154
+ />,
155
+ );
156
+
157
+ expect(pane.querySelector("[title]")).toBeNull();
158
+ // The trigger span stays out of the tab order (structure is pinned
159
+ // in the fast suite; re-asserted here against the real DOM).
160
+ expect(pane.querySelectorAll("li [tabindex]")).toHaveLength(0);
161
+
162
+ const badgeTrigger = Array.from(pane.querySelectorAll("span")).find(
163
+ (el) => el.textContent === "Needs attention: refund I cannot process",
164
+ );
165
+ await expectHoverReveals(pane, badgeTrigger!, "refund I cannot process");
166
+ });
167
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
+ import {
7
+ ChannelConversationSchema,
8
+ ConversationControl,
9
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
10
+ import { StigmerContext } from "../../context";
11
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
12
+ import { advanceInSlices } from "../../internal/__tests__/fake-timer-slices";
13
+ import { useConversation } from "../useConversation";
14
+ import { useConversationTimeline } from "../useConversationTimeline";
15
+ import { useConversationsWantsHumanCount } from "../useConversationsWantsHumanCount";
16
+
17
+ /**
18
+ * The F-14 composition net (channel-conversations T06): the open
19
+ * conversation mounts BOTH `useConversation` (the row poll, a
20
+ * hand-rolled loop with stable deps) and `useConversationTimeline` (a
21
+ * `useFetch` poll) in one component — the `ConversationsWorkbench`
22
+ * shape. The row poll re-renders that component at fetch-start and at
23
+ * settle every cycle; the timeline's poll must survive those renders.
24
+ *
25
+ * In production both hooks default to 5s and the timeline starved
26
+ * deterministically. The intervals here are OFFSET (row 3s, timeline
27
+ * 5s) because aligned periods mask the defect under fake timers: two
28
+ * timers sharing a phase both fire inside the same advanced instant,
29
+ * before React commits the re-render that would tear the poll timer
30
+ * down. With the row poll at 3s, the timeline's 5s window can never
31
+ * elapse untouched — unless its timer keeps its phase across renders,
32
+ * which is the behavior this test pins.
33
+ */
34
+
35
+ function conversationRow() {
36
+ return create(ChannelConversationSchema, {
37
+ agentChannelId: "ach_1",
38
+ conversationKey: "15550001111",
39
+ org: "acme",
40
+ displayName: "Pat",
41
+ control: ConversationControl.control_agent,
42
+ lastActivityAt: timestampFromDate(new Date("2026-08-07T11:00:00Z")),
43
+ });
44
+ }
45
+
46
+ function createMockStigmer() {
47
+ return {
48
+ agentChannel: {
49
+ getConversation: vi.fn().mockResolvedValue(conversationRow()),
50
+ getTimeline: vi.fn().mockResolvedValue({ items: [], nextPageToken: "" }),
51
+ listConversations: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
52
+ },
53
+ };
54
+ }
55
+
56
+ function wrapper(client: unknown) {
57
+ return function Wrapper({ children }: { readonly children: ReactNode }) {
58
+ return (
59
+ <FetchCacheContext.Provider value={null}>
60
+ <StigmerContext.Provider value={client as never}>
61
+ {children}
62
+ </StigmerContext.Provider>
63
+ </FetchCacheContext.Provider>
64
+ );
65
+ };
66
+ }
67
+
68
+ describe("conversation polling composition (F-14)", () => {
69
+ beforeEach(() => {
70
+ vi.useFakeTimers();
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.useRealTimers();
75
+ });
76
+
77
+ it("the timeline keeps polling under the row poll's render cadence", async () => {
78
+ const client = createMockStigmer();
79
+ renderHook(
80
+ () => {
81
+ const detail = useConversation("ach_1", "15550001111", {
82
+ refetchIntervalMs: 3000,
83
+ });
84
+ const timeline = useConversationTimeline("ach_1", "15550001111", {
85
+ refetchIntervalMs: 5000,
86
+ });
87
+ return { detail, timeline };
88
+ },
89
+ { wrapper: wrapper(client) },
90
+ );
91
+
92
+ // Flush the initial fetches of both hooks.
93
+ await act(async () => {
94
+ await Promise.resolve();
95
+ });
96
+ expect(client.agentChannel.getConversation).toHaveBeenCalledTimes(1);
97
+ expect(client.agentChannel.getTimeline).toHaveBeenCalledTimes(1);
98
+
99
+ // 15 seconds: the row poll fires at 3s, 6s, 9s, 12s, 15s — each
100
+ // firing re-renders the component twice (fetch-start and settle).
101
+ // The timeline poll must still fire at 5s, 10s, and 15s.
102
+ await advanceInSlices(15_000);
103
+ expect(client.agentChannel.getConversation).toHaveBeenCalledTimes(6);
104
+ expect(client.agentChannel.getTimeline).toHaveBeenCalledTimes(4);
105
+ });
106
+
107
+ it("the wants-human count keeps polling under the row poll's render cadence (the fourth poller)", async () => {
108
+ // T05's badge hook joins the same composition (the console mounts
109
+ // the sidebar beside the open conversation), so it inherits the
110
+ // F-14 exposure by construction: every row-poll firing re-renders,
111
+ // and a timer keyed on identity would never complete a period.
112
+ // Offset intervals for the same reason as above — aligned periods
113
+ // mask the defect under fake timers.
114
+ const client = createMockStigmer();
115
+ renderHook(
116
+ () => {
117
+ const detail = useConversation("ach_1", "15550001111", {
118
+ refetchIntervalMs: 3000,
119
+ });
120
+ const count = useConversationsWantsHumanCount("acme", {
121
+ refetchIntervalMs: 7000,
122
+ });
123
+ return { detail, count };
124
+ },
125
+ { wrapper: wrapper(client) },
126
+ );
127
+
128
+ await act(async () => {
129
+ await Promise.resolve();
130
+ });
131
+ expect(client.agentChannel.listConversations).toHaveBeenCalledTimes(1);
132
+
133
+ // 21 seconds: the row poll fires at 3s..21s (7 firings, each a
134
+ // double re-render); the count poll must still fire at 7s, 14s, 21s.
135
+ await advanceInSlices(21_000);
136
+ expect(client.agentChannel.listConversations).toHaveBeenCalledTimes(4);
137
+ });
138
+ });