@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.
- package/attachment/AttachmentChipList.d.ts.map +1 -1
- package/attachment/AttachmentChipList.js +30 -1
- package/attachment/AttachmentChipList.js.map +1 -1
- package/attachment/attachment-utils.d.ts +11 -0
- package/attachment/attachment-utils.d.ts.map +1 -1
- package/attachment/attachment-utils.js +23 -0
- package/attachment/attachment-utils.js.map +1 -1
- package/attachment/clipboard.d.ts +51 -0
- package/attachment/clipboard.d.ts.map +1 -0
- package/attachment/clipboard.js +89 -0
- package/attachment/clipboard.js.map +1 -0
- package/attachment/index.d.ts +6 -1
- package/attachment/index.d.ts.map +1 -1
- package/attachment/index.js +4 -1
- package/attachment/index.js.map +1 -1
- package/attachment/prepare-image.d.ts +43 -0
- package/attachment/prepare-image.d.ts.map +1 -0
- package/attachment/prepare-image.js +162 -0
- package/attachment/prepare-image.js.map +1 -0
- package/attachment/useAttachments.d.ts.map +1 -1
- package/attachment/useAttachments.js +23 -3
- package/attachment/useAttachments.js.map +1 -1
- package/attachment/vision-fit.d.ts +50 -0
- package/attachment/vision-fit.d.ts.map +1 -0
- package/attachment/vision-fit.js +76 -0
- package/attachment/vision-fit.js.map +1 -0
- 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 +4 -2
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +51 -5
- 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 +6 -2
- package/index.d.ts.map +1 -1
- package/index.js +11 -2
- 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/attachment/AttachmentChipList.tsx +45 -1
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
- package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
- package/src/attachment/__tests__/clipboard.test.ts +110 -0
- package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
- package/src/attachment/__tests__/prepare-image.test.ts +32 -0
- package/src/attachment/__tests__/vision-fit.test.ts +93 -0
- package/src/attachment/attachment-utils.ts +27 -0
- package/src/attachment/clipboard.ts +102 -0
- package/src/attachment/index.ts +13 -0
- package/src/attachment/prepare-image.ts +179 -0
- package/src/attachment/useAttachments.ts +26 -2
- package/src/attachment/vision-fit.ts +90 -0
- 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 +75 -6
- package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
- package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
- package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
- 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 +78 -1
- 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,160 @@
|
|
|
1
|
+
// Scroll-behavior regression for the conversation timeline (F-09), against
|
|
2
|
+
// the REAL component and the shipped stylesheet in a real Chromium — the
|
|
3
|
+
// composition that shipped the bug: `ConversationTimelineView` mounts its
|
|
4
|
+
// content wrapper inside the loading branch, so the auto-scroll machinery
|
|
5
|
+
// must attach to a wrapper that does not exist on first render. The
|
|
6
|
+
// hook-level mechanics are pinned in
|
|
7
|
+
// `internal/__tests__/useAutoScroll.layout.test.tsx`; this suite proves the
|
|
8
|
+
// component wiring delivers them: a conversation OPENS at the newest
|
|
9
|
+
// message with "Jump to latest" hidden, and stays pinned as messages land.
|
|
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 type { ReactElement } from "react";
|
|
16
|
+
import { create } from "@bufbuild/protobuf";
|
|
17
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
18
|
+
import {
|
|
19
|
+
ConversationItemAuthor,
|
|
20
|
+
ConversationLane,
|
|
21
|
+
ConversationTimelineItemSchema,
|
|
22
|
+
type ConversationTimelineItem,
|
|
23
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
24
|
+
import { ConversationTimelineView } from "../ConversationTimelineView.js";
|
|
25
|
+
|
|
26
|
+
const NOW = new Date("2026-08-07T12:00:00Z");
|
|
27
|
+
|
|
28
|
+
function items(count: number): ConversationTimelineItem[] {
|
|
29
|
+
return Array.from({ length: count }, (_, i) =>
|
|
30
|
+
create(ConversationTimelineItemSchema, {
|
|
31
|
+
itemId: `wa:${i}`,
|
|
32
|
+
lane: ConversationLane.lane_public,
|
|
33
|
+
author:
|
|
34
|
+
i % 2 === 0
|
|
35
|
+
? ConversationItemAuthor.author_customer
|
|
36
|
+
: ConversationItemAuthor.author_agent,
|
|
37
|
+
text: `message ${i}`,
|
|
38
|
+
at: timestampFromDate(new Date(NOW.getTime() - (count - i) * 60_000)),
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function view(props: {
|
|
44
|
+
readonly items: readonly ConversationTimelineItem[];
|
|
45
|
+
readonly isLoading: boolean;
|
|
46
|
+
}): ReactElement {
|
|
47
|
+
return (
|
|
48
|
+
<ConversationTimelineView
|
|
49
|
+
items={props.items}
|
|
50
|
+
isLoading={props.isLoading}
|
|
51
|
+
error={null}
|
|
52
|
+
hasOlder={false}
|
|
53
|
+
loadOlder={() => {}}
|
|
54
|
+
isLoadingOlder={false}
|
|
55
|
+
provider="whatsapp"
|
|
56
|
+
now={NOW}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render inside a fixed-height themed `.stgm` box (the shipped stylesheet
|
|
63
|
+
* is scoped to it) so the timeline's internal scroller actually overflows.
|
|
64
|
+
*/
|
|
65
|
+
function renderInPane(ui: ReactElement) {
|
|
66
|
+
const pane = document.createElement("div");
|
|
67
|
+
pane.className = "stgm";
|
|
68
|
+
pane.style.height = "360px";
|
|
69
|
+
pane.style.width = "640px";
|
|
70
|
+
pane.style.display = "flex";
|
|
71
|
+
pane.style.flexDirection = "column";
|
|
72
|
+
document.body.appendChild(pane);
|
|
73
|
+
return render(ui, { container: pane });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scroller(): HTMLElement {
|
|
77
|
+
// The component's scroll container is the timeline's direct child pane
|
|
78
|
+
// (ConversationTimelineView.tsx: the overflow-y-auto div under the
|
|
79
|
+
// aria-labelled root).
|
|
80
|
+
const rootEl = document.querySelector('[aria-label="Conversation timeline"]');
|
|
81
|
+
return rootEl?.firstElementChild as HTMLElement;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function jumpButton(): HTMLElement {
|
|
85
|
+
return document.querySelector('button[aria-label="Jump to latest"]') as HTMLElement;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isPinnedToBottom = (el: HTMLElement) =>
|
|
89
|
+
el.scrollHeight > el.clientHeight &&
|
|
90
|
+
Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) <= 1;
|
|
91
|
+
|
|
92
|
+
async function settled(assertion: () => void): Promise<void> {
|
|
93
|
+
await vi.waitFor(assertion, { timeout: 2000, interval: 20 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Determinism note: every case starts loading and fills content on a
|
|
97
|
+
// rerender — the production sequence, and also what makes the scroll-up
|
|
98
|
+
// case race-free: with a late-mounting wrapper the mount-time scroll
|
|
99
|
+
// write is a no-op, so "pinned" can only come from the ResizeObserver
|
|
100
|
+
// pathway, proving its initial delivery and rAF write have both run
|
|
101
|
+
// before any simulated reader scroll. (Headless Chromium delivers
|
|
102
|
+
// initial observations on its own schedule; frame-count waits were
|
|
103
|
+
// measured flaky.)
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
cleanup();
|
|
107
|
+
document.querySelectorAll(".stgm").forEach((node) => node.remove());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("ConversationTimelineView scroll behavior (F-09)", () => {
|
|
111
|
+
it("opens at the newest message after the async first load", async () => {
|
|
112
|
+
// The production sequence: the timeline mounts loading (skeleton, no
|
|
113
|
+
// content wrapper), then the first page lands.
|
|
114
|
+
const { rerender } = renderInPane(view({ items: [], isLoading: true }));
|
|
115
|
+
rerender(view({ items: items(40), isLoading: false }));
|
|
116
|
+
|
|
117
|
+
await settled(() => {
|
|
118
|
+
expect(
|
|
119
|
+
isPinnedToBottom(scroller()),
|
|
120
|
+
"a conversation must open at its newest message",
|
|
121
|
+
).toBe(true);
|
|
122
|
+
// Following ⇒ the recovery affordance stays hidden.
|
|
123
|
+
expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("stays pinned as new messages land on poll ticks", async () => {
|
|
128
|
+
const { rerender } = renderInPane(view({ items: [], isLoading: true }));
|
|
129
|
+
rerender(view({ items: items(40), isLoading: false }));
|
|
130
|
+
await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
|
|
131
|
+
|
|
132
|
+
rerender(view({ items: items(42), isLoading: false }));
|
|
133
|
+
|
|
134
|
+
await settled(() => {
|
|
135
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
136
|
+
expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("offers Jump to latest to a reader who scrolled up, without moving them", async () => {
|
|
141
|
+
const { rerender } = renderInPane(view({ items: [], isLoading: true }));
|
|
142
|
+
rerender(view({ items: items(40), isLoading: false }));
|
|
143
|
+
await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
|
|
144
|
+
|
|
145
|
+
scroller().scrollTop = 0;
|
|
146
|
+
await settled(() =>
|
|
147
|
+
expect(jumpButton().getAttribute("aria-hidden")).toBe("false"),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
rerender(view({ items: items(41), isLoading: false }));
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
152
|
+
expect(scroller().scrollTop).toBe(0);
|
|
153
|
+
|
|
154
|
+
jumpButton().click();
|
|
155
|
+
await settled(() => {
|
|
156
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
157
|
+
expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,405 @@
|
|
|
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 {
|
|
7
|
+
ConversationItemAuthor,
|
|
8
|
+
ConversationLane,
|
|
9
|
+
ConversationTimelineItemSchema,
|
|
10
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
11
|
+
import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
|
|
12
|
+
import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
|
|
13
|
+
import { ConversationTimelineView } from "../ConversationTimelineView";
|
|
14
|
+
|
|
15
|
+
const NOW = new Date("2026-08-07T12:00:00Z");
|
|
16
|
+
|
|
17
|
+
function item(id: string, fields: Record<string, unknown> = {}) {
|
|
18
|
+
return create(ConversationTimelineItemSchema, {
|
|
19
|
+
itemId: id,
|
|
20
|
+
lane: ConversationLane.lane_public,
|
|
21
|
+
at: timestampFromDate(new Date("2026-08-07T09:00:00Z")),
|
|
22
|
+
...fields,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function baseProps() {
|
|
27
|
+
return {
|
|
28
|
+
items: [] as never[],
|
|
29
|
+
isLoading: false,
|
|
30
|
+
error: null,
|
|
31
|
+
hasOlder: false,
|
|
32
|
+
loadOlder: vi.fn(),
|
|
33
|
+
isLoadingOlder: false,
|
|
34
|
+
provider: "whatsapp" as const,
|
|
35
|
+
now: NOW,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("ConversationTimelineView", () => {
|
|
40
|
+
afterEach(() => cleanup());
|
|
41
|
+
|
|
42
|
+
it("renders customer and business sides with author captions", () => {
|
|
43
|
+
render(
|
|
44
|
+
<ConversationTimelineView
|
|
45
|
+
{...baseProps()}
|
|
46
|
+
items={[
|
|
47
|
+
item("wa:1", {
|
|
48
|
+
author: ConversationItemAuthor.author_customer,
|
|
49
|
+
text: "where is my order?",
|
|
50
|
+
}),
|
|
51
|
+
item("dl:2", {
|
|
52
|
+
author: ConversationItemAuthor.author_agent,
|
|
53
|
+
text: "checking now",
|
|
54
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
55
|
+
}),
|
|
56
|
+
item("ob:3", {
|
|
57
|
+
author: ConversationItemAuthor.author_teammate,
|
|
58
|
+
text: "I'll take this one",
|
|
59
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
60
|
+
}),
|
|
61
|
+
item("ob:4", {
|
|
62
|
+
author: ConversationItemAuthor.author_platform,
|
|
63
|
+
text: "A team member will reply shortly.",
|
|
64
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
65
|
+
}),
|
|
66
|
+
]}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(screen.getByText("where is my order?")).toBeDefined();
|
|
71
|
+
expect(screen.getByText("Agent")).toBeDefined();
|
|
72
|
+
expect(screen.getByText("Teammate")).toBeDefined();
|
|
73
|
+
expect(screen.getByText("Automated")).toBeDefined();
|
|
74
|
+
// The customer side never carries a caption.
|
|
75
|
+
expect(screen.queryByText("Customer")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders a typed placeholder for non-text inbound instead of hiding it", () => {
|
|
79
|
+
render(
|
|
80
|
+
<ConversationTimelineView
|
|
81
|
+
{...baseProps()}
|
|
82
|
+
items={[
|
|
83
|
+
item("wa:1", {
|
|
84
|
+
author: ConversationItemAuthor.author_customer,
|
|
85
|
+
providerMessageType: "image",
|
|
86
|
+
}),
|
|
87
|
+
]}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
expect(screen.getByText("Photo")).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps the two status axes distinct: provider-accepted yet receipt-failed", () => {
|
|
94
|
+
render(
|
|
95
|
+
<ConversationTimelineView
|
|
96
|
+
{...baseProps()}
|
|
97
|
+
items={[
|
|
98
|
+
item("ob:1", {
|
|
99
|
+
author: ConversationItemAuthor.author_agent,
|
|
100
|
+
text: "your table is ready",
|
|
101
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
102
|
+
receiptState: ChannelReceiptState.receipt_failed,
|
|
103
|
+
}),
|
|
104
|
+
]}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
// The attempt succeeded — no "Not delivered" — but the provider's
|
|
108
|
+
// receipt axis reports the failure on its own.
|
|
109
|
+
expect(screen.queryByText("Not delivered")).toBeNull();
|
|
110
|
+
expect(screen.getByText("Delivery failed")).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("renders the provider's failure explanation as VISIBLE text on a failed receipt (DD-014 D-c, R-1)", () => {
|
|
114
|
+
render(
|
|
115
|
+
<ConversationTimelineView
|
|
116
|
+
{...baseProps()}
|
|
117
|
+
items={[
|
|
118
|
+
item("ob:1", {
|
|
119
|
+
author: ConversationItemAuthor.author_teammate,
|
|
120
|
+
text: "your table is ready",
|
|
121
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
122
|
+
receiptState: ChannelReceiptState.receipt_failed,
|
|
123
|
+
receiptDetail:
|
|
124
|
+
"More than 24 hours have passed since the recipient last replied to the sender number.",
|
|
125
|
+
receiptErrorCode: 131047,
|
|
126
|
+
}),
|
|
127
|
+
]}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Verbatim relay — the provider's own words, never pattern-matched
|
|
132
|
+
// into local copy. Visible (not hover-only): the reason changes what
|
|
133
|
+
// the operator does next, and the footer glyphs are deliberately
|
|
134
|
+
// non-focusable (F-18), so a tooltip reaches mouse users only.
|
|
135
|
+
expect(
|
|
136
|
+
screen.getByText(/More than 24 hours have passed since the recipient last replied/),
|
|
137
|
+
).toBeDefined();
|
|
138
|
+
// The numeric code stays off the surface — machine vocabulary for
|
|
139
|
+
// clients that branch, not operator copy.
|
|
140
|
+
expect(screen.queryByText(/131047/)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("keeps today's generic copy when the provider sent no explanation", () => {
|
|
144
|
+
render(
|
|
145
|
+
<ConversationTimelineView
|
|
146
|
+
{...baseProps()}
|
|
147
|
+
items={[
|
|
148
|
+
item("ob:1", {
|
|
149
|
+
author: ConversationItemAuthor.author_agent,
|
|
150
|
+
text: "your table is ready",
|
|
151
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
152
|
+
receiptState: ChannelReceiptState.receipt_failed,
|
|
153
|
+
// A code with no prose: the bare number would tell an
|
|
154
|
+
// operator nothing, so the tick keeps its generic copy.
|
|
155
|
+
receiptErrorCode: 131047,
|
|
156
|
+
}),
|
|
157
|
+
]}
|
|
158
|
+
/>,
|
|
159
|
+
);
|
|
160
|
+
expect(screen.getByText("Delivery failed")).toBeDefined();
|
|
161
|
+
expect(screen.queryByText(/131047/)).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("never shows the receipt explanation on an attempt-failed item — the F-25 boundary", () => {
|
|
165
|
+
render(
|
|
166
|
+
<ConversationTimelineView
|
|
167
|
+
{...baseProps()}
|
|
168
|
+
items={[
|
|
169
|
+
item("dl:1", {
|
|
170
|
+
author: ConversationItemAuthor.author_agent,
|
|
171
|
+
text: "lost reply",
|
|
172
|
+
deliveryStatus: ChannelDeliveryStatus.failed,
|
|
173
|
+
// A malformed item carrying a detail on the wrong axis: the
|
|
174
|
+
// attempt-axis explanation (last_error) is F-25's separate
|
|
175
|
+
// slice, so this lane renders nothing extra.
|
|
176
|
+
receiptDetail: "should never render",
|
|
177
|
+
}),
|
|
178
|
+
]}
|
|
179
|
+
/>,
|
|
180
|
+
);
|
|
181
|
+
expect(screen.getByText("Not delivered")).toBeDefined();
|
|
182
|
+
expect(screen.queryByText("should never render")).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("renders a dead-lettered send as exactly that", () => {
|
|
186
|
+
render(
|
|
187
|
+
<ConversationTimelineView
|
|
188
|
+
{...baseProps()}
|
|
189
|
+
items={[
|
|
190
|
+
item("dl:1", {
|
|
191
|
+
author: ConversationItemAuthor.author_agent,
|
|
192
|
+
text: "lost reply",
|
|
193
|
+
deliveryStatus: ChannelDeliveryStatus.failed,
|
|
194
|
+
}),
|
|
195
|
+
]}
|
|
196
|
+
/>,
|
|
197
|
+
);
|
|
198
|
+
expect(screen.getByText("Not delivered")).toBeDefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("renders a suppressed send as held, never as sent", () => {
|
|
202
|
+
render(
|
|
203
|
+
<ConversationTimelineView
|
|
204
|
+
{...baseProps()}
|
|
205
|
+
items={[
|
|
206
|
+
item("ob:1", {
|
|
207
|
+
author: ConversationItemAuthor.author_agent,
|
|
208
|
+
text: "reminder that never went",
|
|
209
|
+
deliveryStatus: ChannelDeliveryStatus.suppressed,
|
|
210
|
+
}),
|
|
211
|
+
]}
|
|
212
|
+
/>,
|
|
213
|
+
);
|
|
214
|
+
expect(screen.getByText("Held")).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("advances the receipt ticks: sent, delivered, read", () => {
|
|
218
|
+
render(
|
|
219
|
+
<ConversationTimelineView
|
|
220
|
+
{...baseProps()}
|
|
221
|
+
items={[
|
|
222
|
+
item("ob:1", {
|
|
223
|
+
author: ConversationItemAuthor.author_agent,
|
|
224
|
+
text: "one",
|
|
225
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
226
|
+
receiptState: ChannelReceiptState.receipt_sent,
|
|
227
|
+
}),
|
|
228
|
+
item("ob:2", {
|
|
229
|
+
author: ConversationItemAuthor.author_agent,
|
|
230
|
+
text: "two",
|
|
231
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
232
|
+
receiptState: ChannelReceiptState.receipt_delivered,
|
|
233
|
+
}),
|
|
234
|
+
item("ob:3", {
|
|
235
|
+
author: ConversationItemAuthor.author_agent,
|
|
236
|
+
text: "three",
|
|
237
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
238
|
+
receiptState: ChannelReceiptState.receipt_read,
|
|
239
|
+
}),
|
|
240
|
+
]}
|
|
241
|
+
/>,
|
|
242
|
+
);
|
|
243
|
+
expect(screen.getByText("Sent")).toBeDefined();
|
|
244
|
+
expect(screen.getByText("Delivered")).toBeDefined();
|
|
245
|
+
expect(screen.getByText("Read")).toBeDefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("explains status glyphs with tooltips, never native titles, and adds no tab stops (F-18)", () => {
|
|
249
|
+
// Every footer status variant at once: failed, suppressed, sending,
|
|
250
|
+
// and the three receipt ticks (sent / delivered / read) plus the
|
|
251
|
+
// receipt-failed arm.
|
|
252
|
+
const { container } = render(
|
|
253
|
+
<ConversationTimelineView
|
|
254
|
+
{...baseProps()}
|
|
255
|
+
items={[
|
|
256
|
+
item("dl:1", {
|
|
257
|
+
author: ConversationItemAuthor.author_agent,
|
|
258
|
+
text: "failed send",
|
|
259
|
+
deliveryStatus: ChannelDeliveryStatus.failed,
|
|
260
|
+
}),
|
|
261
|
+
item("ob:2", {
|
|
262
|
+
author: ConversationItemAuthor.author_agent,
|
|
263
|
+
text: "held send",
|
|
264
|
+
deliveryStatus: ChannelDeliveryStatus.suppressed,
|
|
265
|
+
}),
|
|
266
|
+
item("ob:3", {
|
|
267
|
+
author: ConversationItemAuthor.author_teammate,
|
|
268
|
+
text: "sending now",
|
|
269
|
+
deliveryStatus: ChannelDeliveryStatus.pending,
|
|
270
|
+
}),
|
|
271
|
+
item("ob:4", {
|
|
272
|
+
author: ConversationItemAuthor.author_teammate,
|
|
273
|
+
text: "sent",
|
|
274
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
275
|
+
}),
|
|
276
|
+
item("ob:5", {
|
|
277
|
+
author: ConversationItemAuthor.author_teammate,
|
|
278
|
+
text: "delivered",
|
|
279
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
280
|
+
receiptState: ChannelReceiptState.receipt_delivered,
|
|
281
|
+
}),
|
|
282
|
+
item("ob:6", {
|
|
283
|
+
author: ConversationItemAuthor.author_teammate,
|
|
284
|
+
text: "read",
|
|
285
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
286
|
+
receiptState: ChannelReceiptState.receipt_read,
|
|
287
|
+
}),
|
|
288
|
+
item("ob:7", {
|
|
289
|
+
author: ConversationItemAuthor.author_teammate,
|
|
290
|
+
text: "receipt failed",
|
|
291
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
292
|
+
receiptState: ChannelReceiptState.receipt_failed,
|
|
293
|
+
}),
|
|
294
|
+
]}
|
|
295
|
+
/>,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Native titles are gone — OS-delayed, imprecise, keyboard- and
|
|
299
|
+
// touch-invisible. The house tooltip replaced them.
|
|
300
|
+
expect(container.querySelector("[title]")).toBeNull();
|
|
301
|
+
// The screen-reader names survive the change (name stays the
|
|
302
|
+
// sr-only/visible text; the tooltip is only the visual description).
|
|
303
|
+
expect(screen.getByText("Sending")).toBeDefined();
|
|
304
|
+
expect(screen.getByText("Sent")).toBeDefined();
|
|
305
|
+
expect(screen.getByText("Delivered")).toBeDefined();
|
|
306
|
+
expect(screen.getByText("Read")).toBeDefined();
|
|
307
|
+
expect(screen.getByText("Not delivered")).toBeDefined();
|
|
308
|
+
expect(screen.getByText("Held")).toBeDefined();
|
|
309
|
+
expect(screen.getByText("Delivery failed")).toBeDefined();
|
|
310
|
+
// Zero new tab stops: seven focusable glyphs per screen of messages
|
|
311
|
+
// would be an accessibility regression dressed as a fix. Nothing
|
|
312
|
+
// inside a message row may be focusable — the Jump-to-latest
|
|
313
|
+
// affordance (which manages its own tabIndex) lives outside the
|
|
314
|
+
// rows and stays the surface's only interactive element.
|
|
315
|
+
expect(container.querySelectorAll("li [tabindex]")).toHaveLength(0);
|
|
316
|
+
expect(container.querySelectorAll("li button")).toHaveLength(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("renders internal-lane events as system rows the customer never saw", () => {
|
|
320
|
+
render(
|
|
321
|
+
<ConversationTimelineView
|
|
322
|
+
{...baseProps()}
|
|
323
|
+
items={[
|
|
324
|
+
item("ev:1", {
|
|
325
|
+
lane: ConversationLane.lane_internal,
|
|
326
|
+
author: ConversationItemAuthor.author_agent,
|
|
327
|
+
text: "the member is asking about a refund I cannot process",
|
|
328
|
+
}),
|
|
329
|
+
item("ev:2", {
|
|
330
|
+
lane: ConversationLane.lane_internal,
|
|
331
|
+
author: ConversationItemAuthor.author_teammate,
|
|
332
|
+
}),
|
|
333
|
+
]}
|
|
334
|
+
/>,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(
|
|
338
|
+
screen.getByText(/Agent escalated to a human — “the member is asking/),
|
|
339
|
+
).toBeDefined();
|
|
340
|
+
expect(screen.getByText("A teammate resolved the attention flag")).toBeDefined();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("tells the truth about Slack timelines", () => {
|
|
344
|
+
const { rerender } = render(
|
|
345
|
+
<ConversationTimelineView {...baseProps()} provider="slack" />,
|
|
346
|
+
);
|
|
347
|
+
expect(
|
|
348
|
+
screen.getByText(/Customer messages on Slack channels aren't shown here yet/),
|
|
349
|
+
).toBeDefined();
|
|
350
|
+
|
|
351
|
+
rerender(<ConversationTimelineView {...baseProps()} provider="whatsapp" />);
|
|
352
|
+
expect(
|
|
353
|
+
screen.queryByText(/Customer messages on .* channels aren't shown/),
|
|
354
|
+
).toBeNull();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("separates days and labels the current one Today", () => {
|
|
358
|
+
render(
|
|
359
|
+
<ConversationTimelineView
|
|
360
|
+
{...baseProps()}
|
|
361
|
+
items={[
|
|
362
|
+
item("wa:1", {
|
|
363
|
+
author: ConversationItemAuthor.author_customer,
|
|
364
|
+
text: "yesterday's question",
|
|
365
|
+
at: timestampFromDate(new Date("2026-08-06T18:00:00Z")),
|
|
366
|
+
}),
|
|
367
|
+
item("wa:2", {
|
|
368
|
+
author: ConversationItemAuthor.author_customer,
|
|
369
|
+
text: "today's follow-up",
|
|
370
|
+
at: timestampFromDate(new Date("2026-08-07T09:00:00Z")),
|
|
371
|
+
}),
|
|
372
|
+
]}
|
|
373
|
+
/>,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(screen.getByText("Yesterday")).toBeDefined();
|
|
377
|
+
expect(screen.getByText("Today")).toBeDefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("offers Load earlier messages exactly while older history exists", async () => {
|
|
381
|
+
const user = userEvent.setup();
|
|
382
|
+
const loadOlder = vi.fn();
|
|
383
|
+
render(
|
|
384
|
+
<ConversationTimelineView
|
|
385
|
+
{...baseProps()}
|
|
386
|
+
items={[item("wa:1", { author: ConversationItemAuthor.author_customer, text: "hi" })]}
|
|
387
|
+
hasOlder
|
|
388
|
+
loadOlder={loadOlder}
|
|
389
|
+
/>,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
await user.click(screen.getByRole("button", { name: "Load earlier messages" }));
|
|
393
|
+
expect(loadOlder).toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("renders the empty and error states honestly", () => {
|
|
397
|
+
const { rerender } = render(<ConversationTimelineView {...baseProps()} />);
|
|
398
|
+
expect(screen.getByText("No messages yet")).toBeDefined();
|
|
399
|
+
|
|
400
|
+
rerender(
|
|
401
|
+
<ConversationTimelineView {...baseProps()} error={new Error("stitch failed")} />,
|
|
402
|
+
);
|
|
403
|
+
expect(screen.getByText("stitch failed")).toBeDefined();
|
|
404
|
+
});
|
|
405
|
+
});
|