@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.
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +21 -2
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelConversationsDialog.d.ts +12 -4
- package/channel/ChannelConversationsDialog.d.ts.map +1 -1
- package/channel/ChannelConversationsDialog.js +18 -24
- package/channel/ChannelConversationsDialog.js.map +1 -1
- package/channel/providers.d.ts +23 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +4 -0
- package/channel/providers.js.map +1 -1
- package/channel/useSaveAgentChannel.d.ts +5 -4
- package/channel/useSaveAgentChannel.d.ts.map +1 -1
- package/channel/useSaveAgentChannel.js +18 -4
- package/channel/useSaveAgentChannel.js.map +1 -1
- package/composer/ComposerToolbar.d.ts +1 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/SessionComposer.d.ts +1 -1
- package/composer/SessionComposer.js +1 -1
- package/composer/SessionComposer.js.map +1 -1
- package/conversation/ConversationAttentionBanner.d.ts +22 -0
- package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
- package/conversation/ConversationAttentionBanner.js +25 -0
- package/conversation/ConversationAttentionBanner.js.map +1 -0
- package/conversation/ConversationComposer.d.ts +41 -0
- package/conversation/ConversationComposer.d.ts.map +1 -0
- package/conversation/ConversationComposer.js +57 -0
- package/conversation/ConversationComposer.js.map +1 -0
- package/conversation/ConversationControlBanner.d.ts +52 -0
- package/conversation/ConversationControlBanner.d.ts.map +1 -0
- package/conversation/ConversationControlBanner.js +59 -0
- package/conversation/ConversationControlBanner.js.map +1 -0
- package/conversation/ConversationListPane.d.ts +68 -0
- package/conversation/ConversationListPane.d.ts.map +1 -0
- package/conversation/ConversationListPane.js +120 -0
- package/conversation/ConversationListPane.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +46 -0
- package/conversation/ConversationTimelineView.d.ts.map +1 -0
- package/conversation/ConversationTimelineView.js +166 -0
- package/conversation/ConversationTimelineView.js.map +1 -0
- package/conversation/ConversationsWorkbench.d.ts +63 -0
- package/conversation/ConversationsWorkbench.d.ts.map +1 -0
- package/conversation/ConversationsWorkbench.js +165 -0
- package/conversation/ConversationsWorkbench.js.map +1 -0
- package/conversation/conversationPresentation.d.ts +137 -0
- package/conversation/conversationPresentation.d.ts.map +1 -0
- package/conversation/conversationPresentation.js +230 -0
- package/conversation/conversationPresentation.js.map +1 -0
- package/conversation/index.d.ts +26 -0
- package/conversation/index.d.ts.map +1 -0
- package/conversation/index.js +14 -0
- package/conversation/index.js.map +1 -0
- package/conversation/polling.d.ts +39 -0
- package/conversation/polling.d.ts.map +1 -0
- package/conversation/polling.js +39 -0
- package/conversation/polling.js.map +1 -0
- package/conversation/useConversation.d.ts +61 -0
- package/conversation/useConversation.d.ts.map +1 -0
- package/conversation/useConversation.js +153 -0
- package/conversation/useConversation.js.map +1 -0
- package/conversation/useConversationList.d.ts +98 -0
- package/conversation/useConversationList.d.ts.map +1 -0
- package/conversation/useConversationList.js +200 -0
- package/conversation/useConversationList.js.map +1 -0
- package/conversation/useConversationParticipation.d.ts +63 -0
- package/conversation/useConversationParticipation.d.ts.map +1 -0
- package/conversation/useConversationParticipation.js +84 -0
- package/conversation/useConversationParticipation.js.map +1 -0
- package/conversation/useConversationTimeline.d.ts +71 -0
- package/conversation/useConversationTimeline.d.ts.map +1 -0
- package/conversation/useConversationTimeline.js +152 -0
- package/conversation/useConversationTimeline.js.map +1 -0
- package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
- package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
- package/conversation/useConversationsWantsHumanCount.js +40 -0
- package/conversation/useConversationsWantsHumanCount.js.map +1 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -1
- package/index.js +8 -0
- package/index.js.map +1 -1
- package/internal/tooltip.d.ts.map +1 -1
- package/internal/tooltip.js +7 -1
- package/internal/tooltip.js.map +1 -1
- package/internal/useAutoScroll.d.ts +25 -4
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +55 -15
- package/internal/useAutoScroll.js.map +1 -1
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +14 -4
- package/internal/useFetch.js.map +1 -1
- package/models/ModelSelector.d.ts +24 -5
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +31 -9
- package/models/ModelSelector.js.map +1 -1
- package/models/registry.d.ts +3 -2
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js.map +1 -1
- package/models/service-tier.d.ts +1 -1
- package/models/service-tier.d.ts.map +1 -1
- package/models/service-tier.js +1 -1
- package/models/service-tier.js.map +1 -1
- package/package.json +4 -4
- package/schedule/ScheduleDetailView.js +1 -1
- package/schedule/ScheduleDetailView.js.map +1 -1
- package/schedule/ScheduleForm.d.ts.map +1 -1
- package/schedule/ScheduleForm.js +7 -6
- package/schedule/ScheduleForm.js.map +1 -1
- package/sharing/useSaveAgentShare.d.ts +10 -1
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +9 -0
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +12 -4
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -7
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- package/src/channel/AgentChannelsPanel.tsx +33 -2
- package/src/channel/ChannelConversationsDialog.tsx +22 -25
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
- package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
- package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
- package/src/channel/providers.ts +27 -0
- package/src/channel/useSaveAgentChannel.ts +18 -4
- package/src/composer/ComposerToolbar.tsx +1 -1
- package/src/composer/SessionComposer.tsx +2 -2
- package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
- package/src/conversation/ConversationAttentionBanner.tsx +77 -0
- package/src/conversation/ConversationComposer.tsx +156 -0
- package/src/conversation/ConversationControlBanner.tsx +193 -0
- package/src/conversation/ConversationListPane.tsx +459 -0
- package/src/conversation/ConversationTimelineView.tsx +435 -0
- package/src/conversation/ConversationsWorkbench.tsx +378 -0
- package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
- package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
- package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
- package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
- package/src/conversation/__tests__/a11y/harness.tsx +100 -0
- package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
- package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
- package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
- package/src/conversation/__tests__/useConversation.test.tsx +243 -0
- package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
- package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
- package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
- package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
- package/src/conversation/conversationPresentation.ts +305 -0
- package/src/conversation/index.ts +80 -0
- package/src/conversation/polling.ts +41 -0
- package/src/conversation/useConversation.ts +221 -0
- package/src/conversation/useConversationList.ts +328 -0
- package/src/conversation/useConversationParticipation.ts +178 -0
- package/src/conversation/useConversationTimeline.ts +244 -0
- package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
- package/src/index.ts +67 -0
- package/src/internal/__tests__/fake-timer-slices.ts +29 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
- package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
- package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
- package/src/internal/tooltip.tsx +6 -1
- package/src/internal/useAutoScroll.ts +66 -20
- package/src/internal/useFetch.ts +15 -4
- package/src/models/ModelSelector.tsx +81 -49
- package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
- package/src/models/registry.ts +3 -2
- package/src/models/service-tier.ts +1 -1
- package/src/schedule/ScheduleDetailView.tsx +1 -1
- package/src/schedule/ScheduleForm.tsx +7 -6
- package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
- package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
- package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
- package/src/sharing/useSaveAgentShare.ts +19 -1
- package/src/sidebar/WorkspaceSidebar.tsx +62 -3
- package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
- 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
|
+
});
|