@stigmer/react 3.7.0 → 3.8.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 (177) hide show
  1. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  2. package/channel/AgentChannelsPanel.js +21 -2
  3. package/channel/AgentChannelsPanel.js.map +1 -1
  4. package/channel/ChannelConversationsDialog.d.ts +12 -4
  5. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  6. package/channel/ChannelConversationsDialog.js +18 -24
  7. package/channel/ChannelConversationsDialog.js.map +1 -1
  8. package/channel/providers.d.ts +23 -0
  9. package/channel/providers.d.ts.map +1 -1
  10. package/channel/providers.js +4 -0
  11. package/channel/providers.js.map +1 -1
  12. package/channel/useSaveAgentChannel.d.ts +5 -4
  13. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  14. package/channel/useSaveAgentChannel.js +18 -4
  15. package/channel/useSaveAgentChannel.js.map +1 -1
  16. package/composer/ComposerToolbar.d.ts +1 -1
  17. package/composer/ComposerToolbar.d.ts.map +1 -1
  18. package/composer/SessionComposer.d.ts +1 -1
  19. package/composer/SessionComposer.js +1 -1
  20. package/composer/SessionComposer.js.map +1 -1
  21. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  22. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  23. package/conversation/ConversationAttentionBanner.js +25 -0
  24. package/conversation/ConversationAttentionBanner.js.map +1 -0
  25. package/conversation/ConversationComposer.d.ts +41 -0
  26. package/conversation/ConversationComposer.d.ts.map +1 -0
  27. package/conversation/ConversationComposer.js +57 -0
  28. package/conversation/ConversationComposer.js.map +1 -0
  29. package/conversation/ConversationControlBanner.d.ts +52 -0
  30. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  31. package/conversation/ConversationControlBanner.js +59 -0
  32. package/conversation/ConversationControlBanner.js.map +1 -0
  33. package/conversation/ConversationListPane.d.ts +68 -0
  34. package/conversation/ConversationListPane.d.ts.map +1 -0
  35. package/conversation/ConversationListPane.js +120 -0
  36. package/conversation/ConversationListPane.js.map +1 -0
  37. package/conversation/ConversationTimelineView.d.ts +46 -0
  38. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  39. package/conversation/ConversationTimelineView.js +166 -0
  40. package/conversation/ConversationTimelineView.js.map +1 -0
  41. package/conversation/ConversationsWorkbench.d.ts +63 -0
  42. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  43. package/conversation/ConversationsWorkbench.js +165 -0
  44. package/conversation/ConversationsWorkbench.js.map +1 -0
  45. package/conversation/conversationPresentation.d.ts +137 -0
  46. package/conversation/conversationPresentation.d.ts.map +1 -0
  47. package/conversation/conversationPresentation.js +230 -0
  48. package/conversation/conversationPresentation.js.map +1 -0
  49. package/conversation/index.d.ts +26 -0
  50. package/conversation/index.d.ts.map +1 -0
  51. package/conversation/index.js +14 -0
  52. package/conversation/index.js.map +1 -0
  53. package/conversation/polling.d.ts +39 -0
  54. package/conversation/polling.d.ts.map +1 -0
  55. package/conversation/polling.js +39 -0
  56. package/conversation/polling.js.map +1 -0
  57. package/conversation/useConversation.d.ts +61 -0
  58. package/conversation/useConversation.d.ts.map +1 -0
  59. package/conversation/useConversation.js +153 -0
  60. package/conversation/useConversation.js.map +1 -0
  61. package/conversation/useConversationList.d.ts +98 -0
  62. package/conversation/useConversationList.d.ts.map +1 -0
  63. package/conversation/useConversationList.js +200 -0
  64. package/conversation/useConversationList.js.map +1 -0
  65. package/conversation/useConversationParticipation.d.ts +63 -0
  66. package/conversation/useConversationParticipation.d.ts.map +1 -0
  67. package/conversation/useConversationParticipation.js +84 -0
  68. package/conversation/useConversationParticipation.js.map +1 -0
  69. package/conversation/useConversationTimeline.d.ts +71 -0
  70. package/conversation/useConversationTimeline.d.ts.map +1 -0
  71. package/conversation/useConversationTimeline.js +152 -0
  72. package/conversation/useConversationTimeline.js.map +1 -0
  73. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  74. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  75. package/conversation/useConversationsWantsHumanCount.js +40 -0
  76. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  77. package/index.d.ts +4 -0
  78. package/index.d.ts.map +1 -1
  79. package/index.js +8 -0
  80. package/index.js.map +1 -1
  81. package/internal/tooltip.d.ts.map +1 -1
  82. package/internal/tooltip.js +7 -1
  83. package/internal/tooltip.js.map +1 -1
  84. package/internal/useAutoScroll.d.ts +25 -4
  85. package/internal/useAutoScroll.d.ts.map +1 -1
  86. package/internal/useAutoScroll.js +55 -15
  87. package/internal/useAutoScroll.js.map +1 -1
  88. package/internal/useFetch.d.ts.map +1 -1
  89. package/internal/useFetch.js +14 -4
  90. package/internal/useFetch.js.map +1 -1
  91. package/models/ModelSelector.d.ts +24 -5
  92. package/models/ModelSelector.d.ts.map +1 -1
  93. package/models/ModelSelector.js +31 -9
  94. package/models/ModelSelector.js.map +1 -1
  95. package/models/registry.d.ts +3 -2
  96. package/models/registry.d.ts.map +1 -1
  97. package/models/registry.js.map +1 -1
  98. package/models/service-tier.d.ts +1 -1
  99. package/models/service-tier.d.ts.map +1 -1
  100. package/models/service-tier.js +1 -1
  101. package/models/service-tier.js.map +1 -1
  102. package/package.json +4 -4
  103. package/schedule/ScheduleDetailView.js +1 -1
  104. package/schedule/ScheduleDetailView.js.map +1 -1
  105. package/schedule/ScheduleForm.d.ts.map +1 -1
  106. package/schedule/ScheduleForm.js +7 -6
  107. package/schedule/ScheduleForm.js.map +1 -1
  108. package/sharing/useSaveAgentShare.d.ts +10 -1
  109. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  110. package/sharing/useSaveAgentShare.js +9 -0
  111. package/sharing/useSaveAgentShare.js.map +1 -1
  112. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  113. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  114. package/sidebar/WorkspaceSidebar.js +19 -7
  115. package/sidebar/WorkspaceSidebar.js.map +1 -1
  116. package/src/channel/AgentChannelsPanel.tsx +33 -2
  117. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  118. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  119. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  120. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  121. package/src/channel/providers.ts +27 -0
  122. package/src/channel/useSaveAgentChannel.ts +18 -4
  123. package/src/composer/ComposerToolbar.tsx +1 -1
  124. package/src/composer/SessionComposer.tsx +2 -2
  125. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  126. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  127. package/src/conversation/ConversationComposer.tsx +156 -0
  128. package/src/conversation/ConversationControlBanner.tsx +193 -0
  129. package/src/conversation/ConversationListPane.tsx +459 -0
  130. package/src/conversation/ConversationTimelineView.tsx +435 -0
  131. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  132. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  133. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  134. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  135. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  136. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  137. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  138. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  139. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  140. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  141. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  142. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  143. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  144. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  145. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  147. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  148. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  149. package/src/conversation/conversationPresentation.ts +305 -0
  150. package/src/conversation/index.ts +80 -0
  151. package/src/conversation/polling.ts +41 -0
  152. package/src/conversation/useConversation.ts +221 -0
  153. package/src/conversation/useConversationList.ts +328 -0
  154. package/src/conversation/useConversationParticipation.ts +178 -0
  155. package/src/conversation/useConversationTimeline.ts +244 -0
  156. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  157. package/src/index.ts +67 -0
  158. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  159. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  160. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  161. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  162. package/src/internal/tooltip.tsx +6 -1
  163. package/src/internal/useAutoScroll.ts +66 -20
  164. package/src/internal/useFetch.ts +15 -4
  165. package/src/models/ModelSelector.tsx +81 -49
  166. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  167. package/src/models/registry.ts +3 -2
  168. package/src/models/service-tier.ts +1 -1
  169. package/src/schedule/ScheduleDetailView.tsx +1 -1
  170. package/src/schedule/ScheduleForm.tsx +7 -6
  171. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  172. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  173. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  174. package/src/sharing/useSaveAgentShare.ts +19 -1
  175. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  176. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  177. package/styles.css +1 -1
@@ -0,0 +1,190 @@
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 {
6
+ ChannelConversationSchema,
7
+ ConversationControl,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
9
+ import { ConversationControlBanner } from "../ConversationControlBanner";
10
+ import type {
11
+ ConversationCommand,
12
+ UseConversationParticipationReturn,
13
+ } from "../useConversationParticipation";
14
+
15
+ function conversation(control: ConversationControl, controlledBy = "") {
16
+ return create(ChannelConversationSchema, {
17
+ agentChannelId: "ach_1",
18
+ conversationKey: "15550001111",
19
+ org: "acme",
20
+ control,
21
+ controlledBy,
22
+ });
23
+ }
24
+
25
+ function participation(
26
+ overrides: Partial<UseConversationParticipationReturn> = {},
27
+ ): UseConversationParticipationReturn {
28
+ return {
29
+ reply: vi.fn(),
30
+ takeOver: vi.fn().mockResolvedValue(
31
+ conversation(ConversationControl.control_human, "idt_me"),
32
+ ),
33
+ handBack: vi.fn().mockResolvedValue(conversation(ConversationControl.control_agent)),
34
+ clearAttention: vi.fn(),
35
+ pendingCommands: new Set<ConversationCommand>(),
36
+ commandErrors: new Map<ConversationCommand, Error>(),
37
+ clearErrors: vi.fn(),
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function baseProps() {
43
+ return {
44
+ conversation: conversation(ConversationControl.control_agent),
45
+ participation: participation(),
46
+ unansweredCustomer: false,
47
+ supportsStaffReplies: true,
48
+ };
49
+ }
50
+
51
+ describe("ConversationControlBanner", () => {
52
+ afterEach(() => cleanup());
53
+
54
+ it("offers Take over while the agent serves, and calls it", async () => {
55
+ const user = userEvent.setup();
56
+ const p = participation();
57
+ render(<ConversationControlBanner {...baseProps()} participation={p} />);
58
+
59
+ expect(screen.getByText("The agent is serving this conversation.")).toBeDefined();
60
+ await user.click(screen.getByRole("button", { name: "Take over" }));
61
+ expect(p.takeOver).toHaveBeenCalled();
62
+ });
63
+
64
+ it("says YOU hold it only when the host-provided identity matches", () => {
65
+ const held = conversation(ConversationControl.control_human, "idt_me");
66
+ const { rerender } = render(
67
+ <ConversationControlBanner
68
+ {...baseProps()}
69
+ conversation={held}
70
+ currentIdentityAccountId="idt_me"
71
+ />,
72
+ );
73
+ expect(screen.getByText(/You have this conversation/)).toBeDefined();
74
+
75
+ // The CAS loser's truth: the winner holds it, and the copy says a
76
+ // teammate does — never "you".
77
+ rerender(
78
+ <ConversationControlBanner
79
+ {...baseProps()}
80
+ conversation={conversation(ConversationControl.control_human, "idt_someone_else")}
81
+ currentIdentityAccountId="idt_me"
82
+ />,
83
+ );
84
+ expect(screen.getByText(/A teammate has this conversation/)).toBeDefined();
85
+ });
86
+
87
+ it("never claims a teammate holds it when the host passed no identity (F-01)", () => {
88
+ // Identity omitted: the holder may be the viewer themself, so the
89
+ // only honest statement is that a human holds it.
90
+ render(
91
+ <ConversationControlBanner
92
+ {...baseProps()}
93
+ conversation={conversation(ConversationControl.control_human, "idt_someone")}
94
+ />,
95
+ );
96
+ expect(
97
+ screen.getByText(/This conversation is with a human/),
98
+ ).toBeDefined();
99
+ expect(screen.queryByText(/A teammate has this conversation/)).toBeNull();
100
+ expect(screen.queryByText(/You have this conversation/)).toBeNull();
101
+ });
102
+
103
+ it("hands back directly when the customer's last message is answered", async () => {
104
+ const user = userEvent.setup();
105
+ const p = participation();
106
+ render(
107
+ <ConversationControlBanner
108
+ {...baseProps()}
109
+ conversation={conversation(ConversationControl.control_human, "idt_me")}
110
+ participation={p}
111
+ unansweredCustomer={false}
112
+ />,
113
+ );
114
+
115
+ await user.click(screen.getByRole("button", { name: "Hand back to agent" }));
116
+ expect(p.handBack).toHaveBeenCalled();
117
+ });
118
+
119
+ it("guards handback behind a confirm while the customer awaits an answer (DD-007 D-e)", async () => {
120
+ const user = userEvent.setup();
121
+ const p = participation();
122
+ render(
123
+ <ConversationControlBanner
124
+ {...baseProps()}
125
+ conversation={conversation(ConversationControl.control_human, "idt_me")}
126
+ participation={p}
127
+ unansweredCustomer
128
+ />,
129
+ );
130
+
131
+ await user.click(screen.getByRole("button", { name: "Hand back to agent" }));
132
+ // No turn runs on handback — the agent stays quiet until the
133
+ // customer writes again. The state must be unmissable first.
134
+ expect(p.handBack).not.toHaveBeenCalled();
135
+ expect(screen.getByText(/last message has no reply/)).toBeDefined();
136
+
137
+ await user.click(screen.getByRole("button", { name: "Hand back anyway" }));
138
+ expect(p.handBack).toHaveBeenCalled();
139
+ });
140
+
141
+ it("lets the user keep the conversation from the guard without handing back", async () => {
142
+ const user = userEvent.setup();
143
+ const p = participation();
144
+ render(
145
+ <ConversationControlBanner
146
+ {...baseProps()}
147
+ conversation={conversation(ConversationControl.control_human, "idt_me")}
148
+ participation={p}
149
+ unansweredCustomer
150
+ />,
151
+ );
152
+
153
+ await user.click(screen.getByRole("button", { name: "Hand back to agent" }));
154
+ await user.click(screen.getByRole("button", { name: "Keep the conversation" }));
155
+
156
+ expect(p.handBack).not.toHaveBeenCalled();
157
+ expect(screen.getByRole("button", { name: "Hand back to agent" })).toBeDefined();
158
+ });
159
+
160
+ it("disables takeover on senderless providers with the reason VISIBLE (F-18)", () => {
161
+ const { container } = render(
162
+ <ConversationControlBanner {...baseProps()} supportsStaffReplies={false} />,
163
+ );
164
+
165
+ const button = screen.getByRole("button", { name: "Take over" });
166
+ expect(button.hasAttribute("disabled")).toBe(true);
167
+ // The reason must be visible text, never a native title: a disabled
168
+ // Button swallows hover (disabled:pointer-events-none) and leaves
169
+ // the tab order, so a title there is unreachable by EVERY input
170
+ // method — mouse, keyboard, and touch alike.
171
+ expect(screen.getByText(/no staff send lane/)).toBeDefined();
172
+ expect(container.querySelector("[title]")).toBeNull();
173
+ });
174
+
175
+ it("renders a command failure verbatim beside the controls", () => {
176
+ render(
177
+ <ConversationControlBanner
178
+ {...baseProps()}
179
+ participation={participation({
180
+ commandErrors: new Map([
181
+ ["takeOver", new Error("this channel is disabled: set spec.enabled to resume messaging on it")],
182
+ ]),
183
+ })}
184
+ />,
185
+ );
186
+ expect(
187
+ screen.getByText("this channel is disabled: set spec.enabled to resume messaging on it"),
188
+ ).toBeDefined();
189
+ });
190
+ });
@@ -0,0 +1,372 @@
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 { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
+ import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
7
+ import {
8
+ ChannelConversationListFilter,
9
+ ChannelConversationSchema,
10
+ ConversationControl,
11
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
12
+ import { ConversationListPane } from "../ConversationListPane";
13
+
14
+ const NOW = new Date("2026-08-07T12:00:00Z");
15
+
16
+ function whatsappChannel(id: string, name: string) {
17
+ return create(AgentChannelSchema, {
18
+ metadata: { id, org: "acme", name, slug: name },
19
+ spec: { providerConfig: { case: "whatsapp", value: {} } },
20
+ });
21
+ }
22
+
23
+ function slackChannel(id: string, name: string) {
24
+ return create(AgentChannelSchema, {
25
+ metadata: { id, org: "acme", name, slug: name },
26
+ spec: { providerConfig: { case: "slack", value: {} } },
27
+ });
28
+ }
29
+
30
+ function conversation(overrides: Record<string, unknown> = {}) {
31
+ return create(ChannelConversationSchema, {
32
+ agentChannelId: "ach_wa",
33
+ conversationKey: "15550001111",
34
+ org: "acme",
35
+ lastActivityAt: timestampFromDate(new Date("2026-08-07T11:55:00Z")),
36
+ ...overrides,
37
+ });
38
+ }
39
+
40
+ function baseProps() {
41
+ return {
42
+ conversations: [] as never[],
43
+ isLoading: false,
44
+ error: null,
45
+ hasMore: false,
46
+ loadMore: vi.fn(),
47
+ isLoadingMore: false,
48
+ channels: [whatsappChannel("ach_wa", "support-line")],
49
+ channelFilter: "",
50
+ onChannelFilterChange: vi.fn(),
51
+ selected: null,
52
+ onSelect: vi.fn(),
53
+ now: NOW,
54
+ };
55
+ }
56
+
57
+ describe("ConversationListPane", () => {
58
+ afterEach(() => cleanup());
59
+
60
+ it("renders rows with the display name and relative activity", () => {
61
+ render(
62
+ <ConversationListPane
63
+ {...baseProps()}
64
+ conversations={[conversation({ displayName: "Pat" })]}
65
+ />,
66
+ );
67
+
68
+ expect(screen.getByText("Pat")).toBeDefined();
69
+ expect(screen.getByText("5m")).toBeDefined();
70
+ });
71
+
72
+ it("labels a nameless Slack conversation as a thread, never a raw timestamp", () => {
73
+ render(
74
+ <ConversationListPane
75
+ {...baseProps()}
76
+ channels={[slackChannel("ach_sl", "eng-help")]}
77
+ conversations={[
78
+ conversation({ agentChannelId: "ach_sl", conversationKey: "1723012345.678900" }),
79
+ ]}
80
+ />,
81
+ );
82
+
83
+ expect(screen.getByText("Slack thread")).toBeDefined();
84
+ expect(screen.queryByText("1723012345.678900")).toBeNull();
85
+ });
86
+
87
+ it("falls back to the phone number on WhatsApp — a real label", () => {
88
+ render(
89
+ <ConversationListPane {...baseProps()} conversations={[conversation()]} />,
90
+ );
91
+ expect(screen.getByText("15550001111")).toBeDefined();
92
+ });
93
+
94
+ it("keeps the number visible under a display name — and never twice (F-17)", () => {
95
+ // A display name wins the row title, so the call-back path renders
96
+ // as the muted sub-line. When the number IS the title (no display
97
+ // name), no sub-line repeats it — pinned by the fallback test above
98
+ // rendering exactly one occurrence.
99
+ render(
100
+ <ConversationListPane
101
+ {...baseProps()}
102
+ conversations={[conversation({ displayName: "Pat" })]}
103
+ />,
104
+ );
105
+
106
+ expect(screen.getByText("Pat")).toBeDefined();
107
+ expect(screen.getByText("15550001111")).toBeDefined();
108
+ });
109
+
110
+ it("shows no contact sub-line for Slack threads — a timestamp is not an address", () => {
111
+ render(
112
+ <ConversationListPane
113
+ {...baseProps()}
114
+ channels={[slackChannel("ach_sl", "eng-help")]}
115
+ conversations={[
116
+ conversation({
117
+ agentChannelId: "ach_sl",
118
+ conversationKey: "1723012345.678900",
119
+ displayName: "deploy question",
120
+ }),
121
+ ]}
122
+ />,
123
+ );
124
+
125
+ expect(screen.getByText("deploy question")).toBeDefined();
126
+ expect(screen.queryByText("1723012345.678900")).toBeNull();
127
+ });
128
+
129
+ it("badges attention with the escalating agent's reason", () => {
130
+ render(
131
+ <ConversationListPane
132
+ {...baseProps()}
133
+ conversations={[
134
+ conversation({
135
+ displayName: "Pat",
136
+ needsAttention: true,
137
+ attentionReason: "refund I cannot process",
138
+ }),
139
+ ]}
140
+ />,
141
+ );
142
+
143
+ expect(
144
+ screen.getByText("Needs attention: refund I cannot process"),
145
+ ).toBeDefined();
146
+ });
147
+
148
+ it("uses the house tooltip for the attention badge and no native titles anywhere (F-18)", () => {
149
+ const { container } = render(
150
+ <ConversationListPane
151
+ {...baseProps()}
152
+ conversations={[
153
+ conversation({
154
+ displayName: "Pat with a very long name that will truncate",
155
+ needsAttention: true,
156
+ attentionReason: "refund I cannot process",
157
+ }),
158
+ ]}
159
+ />,
160
+ );
161
+
162
+ // The truncation title on the row label is deliberately dropped
163
+ // (the full name renders in the open conversation's header), and
164
+ // the badge's reason moved to the house tooltip — so NO native
165
+ // title remains on the pane.
166
+ expect(container.querySelector("[title]")).toBeNull();
167
+ // The badge keeps its screen-reader name.
168
+ expect(
169
+ screen.getByText("Needs attention: refund I cannot process"),
170
+ ).toBeDefined();
171
+ // The tooltip trigger must not nest a button inside the row button
172
+ // or add a tab stop.
173
+ expect(container.querySelectorAll("button button")).toHaveLength(0);
174
+ expect(container.querySelectorAll("[tabindex]")).toHaveLength(0);
175
+ });
176
+
177
+ it("marks a human-held conversation", () => {
178
+ render(
179
+ <ConversationListPane
180
+ {...baseProps()}
181
+ conversations={[
182
+ conversation({
183
+ displayName: "Pat",
184
+ control: ConversationControl.control_human,
185
+ controlledBy: "idt_staff",
186
+ }),
187
+ ]}
188
+ />,
189
+ );
190
+
191
+ expect(screen.getByText("Human has the conversation")).toBeDefined();
192
+ });
193
+
194
+ it("marks an awaiting conversation strongly when a human holds it — the agent will not answer (DD-011 D-a, F-13)", () => {
195
+ render(
196
+ <ConversationListPane
197
+ {...baseProps()}
198
+ conversations={[
199
+ conversation({
200
+ awaitingReply: true,
201
+ control: ConversationControl.control_human,
202
+ }),
203
+ ]}
204
+ />,
205
+ );
206
+
207
+ expect(
208
+ screen.getByText("Customer awaiting reply — the conversation is human-held"),
209
+ ).toBeDefined();
210
+ });
211
+
212
+ it("marks an awaiting agent-held conversation mutedly — the agent is about to answer", () => {
213
+ render(
214
+ <ConversationListPane
215
+ {...baseProps()}
216
+ conversations={[conversation({ awaitingReply: true })]}
217
+ />,
218
+ );
219
+
220
+ expect(screen.getByText("Customer awaiting reply")).toBeDefined();
221
+ expect(
222
+ screen.queryByText("Customer awaiting reply — the conversation is human-held"),
223
+ ).toBeNull();
224
+ });
225
+
226
+ it("shows no awaiting indicator once the customer is answered", () => {
227
+ render(
228
+ <ConversationListPane {...baseProps()} conversations={[conversation()]} />,
229
+ );
230
+
231
+ expect(screen.queryByText(/Customer awaiting reply/)).toBeNull();
232
+ });
233
+
234
+ it("offers the wants-human filter as a radio group and reports the choice", async () => {
235
+ const user = userEvent.setup();
236
+ const onFilterChange = vi.fn();
237
+ render(
238
+ <ConversationListPane
239
+ {...baseProps()}
240
+ conversations={[conversation()]}
241
+ onFilterChange={onFilterChange}
242
+ />,
243
+ );
244
+
245
+ const group = screen.getByRole("radiogroup", { name: "Conversation filter" });
246
+ expect(group).toBeDefined();
247
+ expect(
248
+ screen.getByRole("radio", { name: "All conversations", checked: true }),
249
+ ).toBeDefined();
250
+
251
+ await user.click(
252
+ screen.getByRole("radio", {
253
+ name: "Conversations where a human action is wanted",
254
+ }),
255
+ );
256
+
257
+ expect(onFilterChange).toHaveBeenCalledWith(
258
+ ChannelConversationListFilter.filter_wants_human,
259
+ );
260
+ });
261
+
262
+ it("moves selection with arrow keys — the ScopeToggle radiogroup contract", async () => {
263
+ const user = userEvent.setup();
264
+ const onFilterChange = vi.fn();
265
+ render(
266
+ <ConversationListPane
267
+ {...baseProps()}
268
+ conversations={[conversation()]}
269
+ onFilterChange={onFilterChange}
270
+ />,
271
+ );
272
+
273
+ screen.getByRole("radio", { name: "All conversations" }).focus();
274
+ await user.keyboard("{ArrowRight}");
275
+
276
+ expect(onFilterChange).toHaveBeenCalledWith(
277
+ ChannelConversationListFilter.filter_wants_human,
278
+ );
279
+ });
280
+
281
+ it("renders no filter control when the host does not wire it — a dead toggle would lie", () => {
282
+ render(
283
+ <ConversationListPane {...baseProps()} conversations={[conversation()]} />,
284
+ );
285
+
286
+ expect(screen.queryByRole("radiogroup")).toBeNull();
287
+ });
288
+
289
+ it("tells the truth when the filtered list is empty — never 'No conversations yet' over a full inbox", () => {
290
+ render(
291
+ <ConversationListPane
292
+ {...baseProps()}
293
+ filter={ChannelConversationListFilter.filter_wants_human}
294
+ onFilterChange={vi.fn()}
295
+ />,
296
+ );
297
+
298
+ expect(screen.getByText("Nothing needs a human right now")).toBeDefined();
299
+ expect(screen.queryByText("No conversations yet")).toBeNull();
300
+ });
301
+
302
+ it("selects a conversation on click and highlights the open one", async () => {
303
+ const user = userEvent.setup();
304
+ const onSelect = vi.fn();
305
+ const open = conversation({ displayName: "Pat" });
306
+ render(
307
+ <ConversationListPane
308
+ {...baseProps()}
309
+ conversations={[open]}
310
+ onSelect={onSelect}
311
+ selected={{ agentChannelId: "ach_wa", conversationKey: "15550001111" }}
312
+ />,
313
+ );
314
+
315
+ const row = screen.getByRole("button", { name: /Pat/ });
316
+ expect(row.getAttribute("aria-current")).toBe("true");
317
+ await user.click(row);
318
+ expect(onSelect).toHaveBeenCalledWith(open);
319
+ });
320
+
321
+ it("distinguishes no-channel-access from no-conversations-yet", () => {
322
+ const { rerender } = render(
323
+ <ConversationListPane {...baseProps()} channels={[]} />,
324
+ );
325
+ expect(screen.getByText("No channels to watch")).toBeDefined();
326
+
327
+ rerender(<ConversationListPane {...baseProps()} />);
328
+ expect(screen.getByText("No conversations yet")).toBeDefined();
329
+ });
330
+
331
+ it("shows the channel filter only when there is a choice to make", async () => {
332
+ const user = userEvent.setup();
333
+ const onChannelFilterChange = vi.fn();
334
+ const { rerender } = render(
335
+ <ConversationListPane {...baseProps()} onChannelFilterChange={onChannelFilterChange} />,
336
+ );
337
+ expect(screen.queryByLabelText("Filter by channel")).toBeNull();
338
+
339
+ rerender(
340
+ <ConversationListPane
341
+ {...baseProps()}
342
+ onChannelFilterChange={onChannelFilterChange}
343
+ channels={[whatsappChannel("ach_wa", "support-line"), slackChannel("ach_sl", "eng-help")]}
344
+ />,
345
+ );
346
+ await user.selectOptions(screen.getByLabelText("Filter by channel"), "ach_sl");
347
+ expect(onChannelFilterChange).toHaveBeenCalledWith("ach_sl");
348
+ });
349
+
350
+ it("offers Show more exactly while more pages exist", async () => {
351
+ const user = userEvent.setup();
352
+ const loadMore = vi.fn();
353
+ render(
354
+ <ConversationListPane
355
+ {...baseProps()}
356
+ conversations={[conversation()]}
357
+ hasMore
358
+ loadMore={loadMore}
359
+ />,
360
+ );
361
+
362
+ await user.click(screen.getByRole("button", { name: "Show more" }));
363
+ expect(loadMore).toHaveBeenCalled();
364
+ });
365
+
366
+ it("surfaces a read failure verbatim", () => {
367
+ render(
368
+ <ConversationListPane {...baseProps()} error={new Error("unauthorized to list")} />,
369
+ );
370
+ expect(screen.getByText("unauthorized to list")).toBeDefined();
371
+ });
372
+ });