@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,258 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// The focus-refetch test broadcasts window events, which reach EVERY
|
|
6
|
+
// still-mounted hook in this file — including earlier tests' hooks whose
|
|
7
|
+
// one-shot mock chains are exhausted. Unmount between tests so a window
|
|
8
|
+
// broadcast only ever exercises the test that sent it.
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
import { create } from "@bufbuild/protobuf";
|
|
11
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
12
|
+
import { ConversationTimelineItemSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
13
|
+
import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
|
|
14
|
+
import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
|
|
15
|
+
import { StigmerContext } from "../../context";
|
|
16
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
17
|
+
import { useConversationTimeline } from "../useConversationTimeline";
|
|
18
|
+
|
|
19
|
+
function item(id: string, iso: string, overrides: Record<string, unknown> = {}) {
|
|
20
|
+
return create(ConversationTimelineItemSchema, {
|
|
21
|
+
itemId: id,
|
|
22
|
+
at: timestampFromDate(new Date(iso)),
|
|
23
|
+
text: `body of ${id}`,
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createMockStigmer(getTimeline: (input: unknown) => Promise<unknown>) {
|
|
29
|
+
return { agentChannel: { getTimeline } } as never;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function wrapper(client: unknown) {
|
|
33
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
34
|
+
return (
|
|
35
|
+
<FetchCacheContext.Provider value={null}>
|
|
36
|
+
<StigmerContext.Provider value={client as never}>
|
|
37
|
+
{children}
|
|
38
|
+
</StigmerContext.Provider>
|
|
39
|
+
</FetchCacheContext.Provider>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NO_POLL = { refetchIntervalMs: false as const };
|
|
45
|
+
|
|
46
|
+
describe("useConversationTimeline", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("serves the head page in chronological order — the server pages newest-first", async () => {
|
|
52
|
+
const getTimeline = vi.fn().mockResolvedValue({
|
|
53
|
+
items: [item("wa:3", "2026-08-07T09:02:00Z"), item("wa:1", "2026-08-07T09:00:00Z")],
|
|
54
|
+
nextPageToken: "",
|
|
55
|
+
});
|
|
56
|
+
const { result } = renderHook(
|
|
57
|
+
() => useConversationTimeline("ach_1", "15550001111", NO_POLL),
|
|
58
|
+
{ wrapper: wrapper(createMockStigmer(getTimeline)) },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
62
|
+
expect(result.current.items.map((i) => i.itemId)).toEqual(["wa:1", "wa:3"]);
|
|
63
|
+
expect(result.current.hasOlder).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("loads older pages behind the cursor and never infers the end from a short page", async () => {
|
|
67
|
+
const getTimeline = vi
|
|
68
|
+
.fn()
|
|
69
|
+
.mockResolvedValueOnce({
|
|
70
|
+
items: [item("wa:9", "2026-08-07T09:09:00Z")],
|
|
71
|
+
nextPageToken: "cursor-1",
|
|
72
|
+
})
|
|
73
|
+
// A SHORT mid-history page with a live cursor: same-second bursts
|
|
74
|
+
// narrow pages, so only the empty token means the end.
|
|
75
|
+
.mockResolvedValueOnce({
|
|
76
|
+
items: [item("wa:5", "2026-08-07T09:05:00Z")],
|
|
77
|
+
nextPageToken: "cursor-2",
|
|
78
|
+
})
|
|
79
|
+
.mockResolvedValueOnce({
|
|
80
|
+
items: [item("wa:1", "2026-08-07T09:01:00Z")],
|
|
81
|
+
nextPageToken: "",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const { result } = renderHook(
|
|
85
|
+
() => useConversationTimeline("ach_1", "15550001111", NO_POLL),
|
|
86
|
+
{ wrapper: wrapper(createMockStigmer(getTimeline)) },
|
|
87
|
+
);
|
|
88
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
89
|
+
expect(result.current.hasOlder).toBe(true);
|
|
90
|
+
|
|
91
|
+
act(() => result.current.loadOlder());
|
|
92
|
+
await waitFor(() => expect(result.current.isLoadingOlder).toBe(false));
|
|
93
|
+
expect(result.current.items.map((i) => i.itemId)).toEqual(["wa:5", "wa:9"]);
|
|
94
|
+
expect(result.current.hasOlder).toBe(true);
|
|
95
|
+
expect(getTimeline).toHaveBeenLastCalledWith(
|
|
96
|
+
expect.objectContaining({ pageToken: "cursor-1" }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
act(() => result.current.loadOlder());
|
|
100
|
+
await waitFor(() => expect(result.current.isLoadingOlder).toBe(false));
|
|
101
|
+
expect(result.current.items.map((i) => i.itemId)).toEqual(["wa:1", "wa:5", "wa:9"]);
|
|
102
|
+
expect(result.current.hasOlder).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps items that slide out of the polled head window — nothing ever vanishes mid-scroll", async () => {
|
|
106
|
+
const getTimeline = vi
|
|
107
|
+
.fn()
|
|
108
|
+
.mockResolvedValueOnce({
|
|
109
|
+
items: [item("wa:2", "2026-08-07T09:02:00Z"), item("wa:1", "2026-08-07T09:01:00Z")],
|
|
110
|
+
nextPageToken: "",
|
|
111
|
+
})
|
|
112
|
+
// The refreshed head window has shifted entirely to newer items;
|
|
113
|
+
// wa:1 and wa:2 are in no loaded page anymore.
|
|
114
|
+
.mockResolvedValueOnce({
|
|
115
|
+
items: [item("wa:4", "2026-08-07T09:04:00Z"), item("wa:3", "2026-08-07T09:03:00Z")],
|
|
116
|
+
nextPageToken: "cursor-x",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const { result } = renderHook(
|
|
120
|
+
() => useConversationTimeline("ach_1", "15550001111", NO_POLL),
|
|
121
|
+
{ wrapper: wrapper(createMockStigmer(getTimeline)) },
|
|
122
|
+
);
|
|
123
|
+
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
|
124
|
+
|
|
125
|
+
act(() => result.current.refetch());
|
|
126
|
+
await waitFor(() => expect(result.current.items).toHaveLength(4));
|
|
127
|
+
|
|
128
|
+
expect(result.current.items.map((i) => i.itemId)).toEqual([
|
|
129
|
+
"wa:1",
|
|
130
|
+
"wa:2",
|
|
131
|
+
"wa:3",
|
|
132
|
+
"wa:4",
|
|
133
|
+
]);
|
|
134
|
+
// The older cursor stays anchored to the FIRST head page: it tracks
|
|
135
|
+
// scroll depth into history, not the shifting head window.
|
|
136
|
+
expect(result.current.hasOlder).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("upserts mutated statuses in place — delivery and receipt ticks advance on poll", async () => {
|
|
140
|
+
const sent = item("ob:1", "2026-08-07T09:00:00Z", {
|
|
141
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
142
|
+
receiptState: ChannelReceiptState.receipt_sent,
|
|
143
|
+
});
|
|
144
|
+
const read = item("ob:1", "2026-08-07T09:00:00Z", {
|
|
145
|
+
deliveryStatus: ChannelDeliveryStatus.delivered,
|
|
146
|
+
receiptState: ChannelReceiptState.receipt_read,
|
|
147
|
+
});
|
|
148
|
+
const getTimeline = vi
|
|
149
|
+
.fn()
|
|
150
|
+
.mockResolvedValueOnce({ items: [sent], nextPageToken: "" })
|
|
151
|
+
.mockResolvedValueOnce({ items: [read], nextPageToken: "" });
|
|
152
|
+
|
|
153
|
+
const { result } = renderHook(
|
|
154
|
+
() => useConversationTimeline("ach_1", "15550001111", NO_POLL),
|
|
155
|
+
{ wrapper: wrapper(createMockStigmer(getTimeline)) },
|
|
156
|
+
);
|
|
157
|
+
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
|
158
|
+
expect(result.current.items[0].receiptState).toBe(ChannelReceiptState.receipt_sent);
|
|
159
|
+
|
|
160
|
+
act(() => result.current.refetch());
|
|
161
|
+
await waitFor(() =>
|
|
162
|
+
expect(result.current.items[0].receiptState).toBe(ChannelReceiptState.receipt_read),
|
|
163
|
+
);
|
|
164
|
+
expect(result.current.items).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps item and list references stable across polls that change nothing (DD-010)", async () => {
|
|
168
|
+
const getTimeline = vi.fn().mockImplementation(() =>
|
|
169
|
+
Promise.resolve({
|
|
170
|
+
// Fresh-but-equal proto objects every call, like a real poll.
|
|
171
|
+
items: [item("wa:1", "2026-08-07T09:00:00Z")],
|
|
172
|
+
nextPageToken: "",
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
const { result } = renderHook(
|
|
176
|
+
() => useConversationTimeline("ach_1", "15550001111", NO_POLL),
|
|
177
|
+
{ wrapper: wrapper(createMockStigmer(getTimeline)) },
|
|
178
|
+
);
|
|
179
|
+
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
|
180
|
+
const firstItems = result.current.items;
|
|
181
|
+
|
|
182
|
+
act(() => result.current.refetch());
|
|
183
|
+
await waitFor(() => expect(result.current.isRefetching).toBe(false));
|
|
184
|
+
|
|
185
|
+
expect(result.current.items).toBe(firstItems);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("resets on identity change so conversation A's thread never flashes under B", async () => {
|
|
189
|
+
const getTimeline = vi
|
|
190
|
+
.fn()
|
|
191
|
+
.mockResolvedValueOnce({
|
|
192
|
+
items: [item("wa:1", "2026-08-07T09:00:00Z")],
|
|
193
|
+
nextPageToken: "cursor-a",
|
|
194
|
+
})
|
|
195
|
+
.mockReturnValue(new Promise(() => {}));
|
|
196
|
+
|
|
197
|
+
const { result, rerender } = renderHook(
|
|
198
|
+
({ key }: { key: string }) => useConversationTimeline("ach_1", key, NO_POLL),
|
|
199
|
+
{
|
|
200
|
+
wrapper: wrapper(createMockStigmer(getTimeline)),
|
|
201
|
+
initialProps: { key: "15550001111" },
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
|
205
|
+
|
|
206
|
+
rerender({ key: "15550002222" });
|
|
207
|
+
|
|
208
|
+
expect(result.current.items).toHaveLength(0);
|
|
209
|
+
expect(result.current.hasOlder).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("refetches the head when the window regains focus (DD-012: refocus is fresh)", async () => {
|
|
213
|
+
const getTimeline = vi.fn().mockResolvedValue({ items: [], nextPageToken: "" });
|
|
214
|
+
renderHook(() => useConversationTimeline("ach_1", "15550001111", NO_POLL), {
|
|
215
|
+
wrapper: wrapper(createMockStigmer(getTimeline)),
|
|
216
|
+
});
|
|
217
|
+
await waitFor(() => expect(getTimeline).toHaveBeenCalledTimes(1));
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
window.dispatchEvent(new Event("focus"));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await waitFor(() => expect(getTimeline).toHaveBeenCalledTimes(2));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("skips fetching for empty identity (stable no-op)", async () => {
|
|
227
|
+
const getTimeline = vi.fn();
|
|
228
|
+
const { result } = renderHook(() => useConversationTimeline("", "", NO_POLL), {
|
|
229
|
+
wrapper: wrapper(createMockStigmer(getTimeline)),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
233
|
+
expect(getTimeline).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("records a loadOlder failure beside the control, leaving the loaded thread intact", async () => {
|
|
237
|
+
const getTimeline = vi
|
|
238
|
+
.fn()
|
|
239
|
+
.mockResolvedValueOnce({
|
|
240
|
+
items: [item("wa:1", "2026-08-07T09:00:00Z")],
|
|
241
|
+
nextPageToken: "cursor-1",
|
|
242
|
+
})
|
|
243
|
+
.mockRejectedValueOnce(new Error("boom"));
|
|
244
|
+
|
|
245
|
+
const { result } = renderHook(
|
|
246
|
+
() => useConversationTimeline("ach_1", "15550001111", NO_POLL),
|
|
247
|
+
{ wrapper: wrapper(createMockStigmer(getTimeline)) },
|
|
248
|
+
);
|
|
249
|
+
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
|
250
|
+
|
|
251
|
+
act(() => result.current.loadOlder());
|
|
252
|
+
await waitFor(() => expect(result.current.loadOlderError).not.toBeNull());
|
|
253
|
+
|
|
254
|
+
expect(result.current.items).toHaveLength(1);
|
|
255
|
+
expect(result.current.error).toBeNull();
|
|
256
|
+
expect(result.current.hasOlder).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ChannelConversationListFilter } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
5
|
+
import { StigmerContext } from "../../context";
|
|
6
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
7
|
+
import { useConversationsWantsHumanCount } from "../useConversationsWantsHumanCount";
|
|
8
|
+
|
|
9
|
+
// The focus-refetch test broadcasts window events, which reach EVERY
|
|
10
|
+
// still-mounted hook in this file. Unmount between tests so a window
|
|
11
|
+
// broadcast only ever exercises the test that sent it.
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
function createMockStigmer(listConversations: (input: unknown) => Promise<unknown>) {
|
|
15
|
+
return { agentChannel: { listConversations } } as never;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function wrapper(client: unknown) {
|
|
19
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
20
|
+
return (
|
|
21
|
+
<FetchCacheContext.Provider value={null}>
|
|
22
|
+
<StigmerContext.Provider value={client as never}>
|
|
23
|
+
{children}
|
|
24
|
+
</StigmerContext.Provider>
|
|
25
|
+
</FetchCacheContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const NO_POLL = { refetchIntervalMs: false as const };
|
|
31
|
+
|
|
32
|
+
describe("useConversationsWantsHumanCount", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads the filtered list's total_count with a one-row page — zero dedicated RPCs (DD-011 D-g)", async () => {
|
|
38
|
+
const listConversations = vi
|
|
39
|
+
.fn()
|
|
40
|
+
.mockResolvedValue({ items: [], totalCount: 7 });
|
|
41
|
+
const { result } = renderHook(
|
|
42
|
+
() => useConversationsWantsHumanCount("acme", NO_POLL),
|
|
43
|
+
{ wrapper: wrapper(createMockStigmer(listConversations)) },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
47
|
+
|
|
48
|
+
expect(result.current.count).toBe(7);
|
|
49
|
+
expect(listConversations).toHaveBeenCalledWith(
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
org: "acme",
|
|
52
|
+
filter: ChannelConversationListFilter.filter_wants_human,
|
|
53
|
+
pageInfo: expect.objectContaining({ num: 1, size: 1 }),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("skips fetching without an org and answers zero", async () => {
|
|
59
|
+
const listConversations = vi.fn();
|
|
60
|
+
const { result } = renderHook(
|
|
61
|
+
() => useConversationsWantsHumanCount(null, NO_POLL),
|
|
62
|
+
{ wrapper: wrapper(createMockStigmer(listConversations)) },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
66
|
+
expect(listConversations).not.toHaveBeenCalled();
|
|
67
|
+
expect(result.current.count).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps the stale count visible through a failed refresh — a flapping badge teaches distrust", async () => {
|
|
71
|
+
const listConversations = vi
|
|
72
|
+
.fn()
|
|
73
|
+
.mockResolvedValueOnce({ items: [], totalCount: 4 })
|
|
74
|
+
.mockRejectedValueOnce(new Error("boom"));
|
|
75
|
+
const { result } = renderHook(
|
|
76
|
+
() => useConversationsWantsHumanCount("acme", NO_POLL),
|
|
77
|
+
{ wrapper: wrapper(createMockStigmer(listConversations)) },
|
|
78
|
+
);
|
|
79
|
+
await waitFor(() => expect(result.current.count).toBe(4));
|
|
80
|
+
|
|
81
|
+
act(() => result.current.refetch());
|
|
82
|
+
await waitFor(() => expect(result.current.error).not.toBeNull());
|
|
83
|
+
|
|
84
|
+
expect(result.current.count).toBe(4);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("refetches when the window regains focus (DD-012: refocus is fresh)", async () => {
|
|
88
|
+
const listConversations = vi
|
|
89
|
+
.fn()
|
|
90
|
+
.mockResolvedValue({ items: [], totalCount: 2 });
|
|
91
|
+
renderHook(() => useConversationsWantsHumanCount("acme", NO_POLL), {
|
|
92
|
+
wrapper: wrapper(createMockStigmer(listConversations)),
|
|
93
|
+
});
|
|
94
|
+
await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(1));
|
|
95
|
+
|
|
96
|
+
await act(async () => {
|
|
97
|
+
window.dispatchEvent(new Event("focus"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
|
|
2
|
+
import {
|
|
3
|
+
type ChannelConversation,
|
|
4
|
+
ConversationControl,
|
|
5
|
+
ConversationItemAuthor,
|
|
6
|
+
ConversationLane,
|
|
7
|
+
type ConversationTimelineItem,
|
|
8
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
9
|
+
import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
|
|
10
|
+
import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
|
|
11
|
+
import type { ChannelProviderId } from "../channel/providers.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pure render vocabulary for the conversation surface — the single place
|
|
15
|
+
* the wire enums become words and layout facts (the providerPresentation
|
|
16
|
+
* convention). No React, no I/O; every component and every test reads the
|
|
17
|
+
* same mapping.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Who authored a timeline item, in render vocabulary. */
|
|
21
|
+
export type ConversationAuthorKind =
|
|
22
|
+
| "customer"
|
|
23
|
+
| "agent"
|
|
24
|
+
| "teammate"
|
|
25
|
+
| "platform"
|
|
26
|
+
| "unknown";
|
|
27
|
+
|
|
28
|
+
/** Map the wire author onto the render vocabulary. */
|
|
29
|
+
export function authorKindOf(item: ConversationTimelineItem): ConversationAuthorKind {
|
|
30
|
+
switch (item.author) {
|
|
31
|
+
case ConversationItemAuthor.author_customer:
|
|
32
|
+
return "customer";
|
|
33
|
+
case ConversationItemAuthor.author_agent:
|
|
34
|
+
return "agent";
|
|
35
|
+
case ConversationItemAuthor.author_teammate:
|
|
36
|
+
return "teammate";
|
|
37
|
+
case ConversationItemAuthor.author_platform:
|
|
38
|
+
return "platform";
|
|
39
|
+
default:
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True for org-internal items (escalations, attention clears, notes). */
|
|
45
|
+
export function isInternalItem(item: ConversationTimelineItem): boolean {
|
|
46
|
+
return item.lane === ConversationLane.lane_internal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The platform's own send attempt, in render vocabulary. `null` on items
|
|
51
|
+
* that carry no attempt (inbound messages, internal events).
|
|
52
|
+
*
|
|
53
|
+
* `"suppressed"` and `"failed"` are items the customer NEVER saw and
|
|
54
|
+
* `"pending"`/`"delivering"` are items the customer has not seen YET —
|
|
55
|
+
* a renderer that shows any of them as a plain sent bubble lies to staff.
|
|
56
|
+
*/
|
|
57
|
+
export type SendAttemptKind =
|
|
58
|
+
| "pending"
|
|
59
|
+
| "delivering"
|
|
60
|
+
| "delivered"
|
|
61
|
+
| "failed"
|
|
62
|
+
| "suppressed";
|
|
63
|
+
|
|
64
|
+
/** Map the wire delivery status onto the render vocabulary. */
|
|
65
|
+
export function sendAttemptOf(item: ConversationTimelineItem): SendAttemptKind | null {
|
|
66
|
+
switch (item.deliveryStatus) {
|
|
67
|
+
case ChannelDeliveryStatus.pending:
|
|
68
|
+
return "pending";
|
|
69
|
+
case ChannelDeliveryStatus.delivering:
|
|
70
|
+
return "delivering";
|
|
71
|
+
case ChannelDeliveryStatus.delivered:
|
|
72
|
+
return "delivered";
|
|
73
|
+
case ChannelDeliveryStatus.failed:
|
|
74
|
+
return "failed";
|
|
75
|
+
case ChannelDeliveryStatus.suppressed:
|
|
76
|
+
return "suppressed";
|
|
77
|
+
default:
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The provider's receipt report, in render vocabulary. `null` until the
|
|
84
|
+
* provider reports (and always on items with no receipt axis).
|
|
85
|
+
*
|
|
86
|
+
* A SECOND axis beside {@link sendAttemptOf}, never collapsed into it
|
|
87
|
+
* (channel-conversations DD-004 D-d): "we handed it to WhatsApp" and
|
|
88
|
+
* "it reached the phone" are different facts — an item can be
|
|
89
|
+
* `delivered` + `receipt_failed`.
|
|
90
|
+
*/
|
|
91
|
+
export type ReceiptKind = "sent" | "delivered" | "read" | "failed";
|
|
92
|
+
|
|
93
|
+
/** Map the wire receipt state onto the render vocabulary. */
|
|
94
|
+
export function receiptOf(item: ConversationTimelineItem): ReceiptKind | null {
|
|
95
|
+
switch (item.receiptState) {
|
|
96
|
+
case ChannelReceiptState.receipt_sent:
|
|
97
|
+
return "sent";
|
|
98
|
+
case ChannelReceiptState.receipt_delivered:
|
|
99
|
+
return "delivered";
|
|
100
|
+
case ChannelReceiptState.receipt_read:
|
|
101
|
+
return "read";
|
|
102
|
+
case ChannelReceiptState.receipt_failed:
|
|
103
|
+
return "failed";
|
|
104
|
+
default:
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Placeholder copy for inbound items whose body the platform cannot
|
|
111
|
+
* render (`text` empty, `provider_message_type` names the kind). Media
|
|
112
|
+
* retrieval is a named non-goal of this surface — the placeholder tells
|
|
113
|
+
* staff something arrived and what shape it was.
|
|
114
|
+
*
|
|
115
|
+
* Returns `null` when the item needs no placeholder (it has text, or it
|
|
116
|
+
* is not a provider-typed inbound item — e.g. an attention-clear event,
|
|
117
|
+
* whose copy is the renderer's own vocabulary).
|
|
118
|
+
*/
|
|
119
|
+
export function inboundPlaceholderOf(item: ConversationTimelineItem): string | null {
|
|
120
|
+
if (item.text !== "" || item.providerMessageType === "") return null;
|
|
121
|
+
switch (item.providerMessageType) {
|
|
122
|
+
case "text":
|
|
123
|
+
return null;
|
|
124
|
+
case "image":
|
|
125
|
+
return "Photo";
|
|
126
|
+
case "video":
|
|
127
|
+
return "Video";
|
|
128
|
+
case "audio":
|
|
129
|
+
return "Voice message";
|
|
130
|
+
case "document":
|
|
131
|
+
return "Document";
|
|
132
|
+
case "sticker":
|
|
133
|
+
return "Sticker";
|
|
134
|
+
case "location":
|
|
135
|
+
return "Location";
|
|
136
|
+
case "contacts":
|
|
137
|
+
return "Contact card";
|
|
138
|
+
default:
|
|
139
|
+
// The provider's vocabulary passes through verbatim (never
|
|
140
|
+
// re-encoded), so an unknown kind still names itself.
|
|
141
|
+
return `Unsupported message (${item.providerMessageType})`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The list-row label for a conversation: the provider-reported display
|
|
147
|
+
* name when one exists, else a provider-aware fallback.
|
|
148
|
+
*
|
|
149
|
+
* The fallback is provider-aware because conversation keys are not
|
|
150
|
+
* uniformly meaningful: WhatsApp's key is the customer's phone number
|
|
151
|
+
* (a real label), while Slack's key is a thread timestamp like
|
|
152
|
+
* `1723012345.678900` (noise a human cannot use).
|
|
153
|
+
*/
|
|
154
|
+
export function conversationLabelOf(
|
|
155
|
+
conversation: ChannelConversation,
|
|
156
|
+
provider: ChannelProviderId | null,
|
|
157
|
+
): string {
|
|
158
|
+
if (conversation.displayName !== "") return conversation.displayName;
|
|
159
|
+
if (provider === "slack") return "Slack thread";
|
|
160
|
+
return conversation.conversationKey;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The customer's reachable address, when the label hides it: the fact a
|
|
165
|
+
* staffer needs to call or message the customer outside the platform.
|
|
166
|
+
*
|
|
167
|
+
* Provider-aware like {@link conversationLabelOf}, because conversation
|
|
168
|
+
* keys are not uniformly addresses: WhatsApp's key IS the customer's
|
|
169
|
+
* phone number (the wa_id), and when a display name wins the label slot
|
|
170
|
+
* that number would otherwise appear nowhere on the surface. Slack's
|
|
171
|
+
* key is a thread timestamp — not a way to reach anyone — so there is
|
|
172
|
+
* nothing to show. Unknown providers show nothing rather than guess.
|
|
173
|
+
*
|
|
174
|
+
* Returns `null` when the label already shows the key (no display
|
|
175
|
+
* name), so the same fact never renders twice. The key returns
|
|
176
|
+
* verbatim — no fabricated `+` prefix: the wire value is the truth this
|
|
177
|
+
* module reports, and formatting belongs to hosts that know their
|
|
178
|
+
* display rules.
|
|
179
|
+
*/
|
|
180
|
+
export function conversationContactOf(
|
|
181
|
+
conversation: ChannelConversation,
|
|
182
|
+
provider: ChannelProviderId | null,
|
|
183
|
+
): string | null {
|
|
184
|
+
if (provider !== "whatsapp") return null;
|
|
185
|
+
if (conversation.displayName === "") return null;
|
|
186
|
+
return conversation.conversationKey;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Meta's customer-service window: a business can send free-form WhatsApp
|
|
191
|
+
* messages for 24 hours after the customer's last inbound message; after
|
|
192
|
+
* that, only template messages deliver. The constant lives beside its
|
|
193
|
+
* only consumer and is promoted to `ChannelProviderDescriptor` only if a
|
|
194
|
+
* second windowed provider ever appears (the second-sighting rule,
|
|
195
|
+
* DD-014 D-a).
|
|
196
|
+
*/
|
|
197
|
+
const WHATSAPP_SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
198
|
+
|
|
199
|
+
/** The service-window estimate, in render vocabulary. */
|
|
200
|
+
export type ServiceWindowState = "open" | "closed";
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Estimate whether the provider's free-form reply window is open
|
|
204
|
+
* (channel-conversations DD-014 D-a). Provider-aware like
|
|
205
|
+
* {@link conversationLabelOf}: WhatsApp is the only windowed provider, so
|
|
206
|
+
* every other provider answers `null` — no window, no claim — as does a
|
|
207
|
+
* conversation whose customer has never written
|
|
208
|
+
* (`last_customer_message_at` unset; that conversation takes the
|
|
209
|
+
* composer's disabled branch instead).
|
|
210
|
+
*
|
|
211
|
+
* Client-derived on purpose, and NOT a divergence from DD-011 A-1's
|
|
212
|
+
* "the predicate is expressed once, in SQL" rule: the window is
|
|
213
|
+
* wall-clock-anchored — it flips with NO row change — so a
|
|
214
|
+
* server-computed boolean would be stale by construction between polls.
|
|
215
|
+
* Time-decaying presentation derived from wire instants is this
|
|
216
|
+
* surface's established class (relative timestamps, day grouping).
|
|
217
|
+
*
|
|
218
|
+
* Honesty caveat (DD-014 D-a): the anchor is the platform's receipt
|
|
219
|
+
* instant, minutes AFTER Meta's own clock started the window, so the
|
|
220
|
+
* estimate can claim "open" briefly after Meta closed it — which is why
|
|
221
|
+
* consumers warn and never block (D-b). For the same reason the exact
|
|
222
|
+
* 24-hour tie reads "closed": erring a moment early offsets an anchor
|
|
223
|
+
* that always errs late.
|
|
224
|
+
*/
|
|
225
|
+
export function serviceWindowOf(
|
|
226
|
+
conversation: ChannelConversation,
|
|
227
|
+
provider: ChannelProviderId | null,
|
|
228
|
+
now: Date,
|
|
229
|
+
): ServiceWindowState | null {
|
|
230
|
+
if (provider !== "whatsapp") return null;
|
|
231
|
+
const anchor = conversation.lastCustomerMessageAt;
|
|
232
|
+
if (anchor === undefined) return null;
|
|
233
|
+
const elapsedMs = now.getTime() - timestampDate(anchor).getTime();
|
|
234
|
+
return elapsedMs >= WHATSAPP_SERVICE_WINDOW_MS ? "closed" : "open";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* How strongly the awaiting-reply indicator renders. Two strengths, not
|
|
239
|
+
* one, because "waiting" means different things per holder (DD-011 D-a).
|
|
240
|
+
*/
|
|
241
|
+
export type AwaitingIndicator = "strong" | "muted";
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The awaiting-reply indicator for a conversation (channel-conversations
|
|
245
|
+
* DD-011 D-a): `"strong"` when a human holds it — the agent will not
|
|
246
|
+
* answer, so a person must (F-13's exact case) — and `"muted"` when the
|
|
247
|
+
* agent holds it: the agent is about to answer, and if its turn dies the
|
|
248
|
+
* conversation stays visibly waiting, which is the built-in recovery
|
|
249
|
+
* path (DD-011 D-b). `null` when the customer's last message has been
|
|
250
|
+
* answered (or the customer has not written).
|
|
251
|
+
*
|
|
252
|
+
* The fact itself is server-derived (`awaiting_reply` rides the wire as
|
|
253
|
+
* a boolean precisely so no client re-implements the NULL-and-compare
|
|
254
|
+
* rule); this helper only maps fact × holder onto render strength.
|
|
255
|
+
*/
|
|
256
|
+
export function awaitingIndicatorOf(
|
|
257
|
+
conversation: ChannelConversation,
|
|
258
|
+
): AwaitingIndicator | null {
|
|
259
|
+
if (!conversation.awaitingReply) return null;
|
|
260
|
+
return conversation.control === ConversationControl.control_human
|
|
261
|
+
? "strong"
|
|
262
|
+
: "muted";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The timeline item id an accepted staff reply appears under: the
|
|
267
|
+
* outbound ledger's `ob:` namespace over the reply output's
|
|
268
|
+
* `outbound_message_id` — the contract
|
|
269
|
+
* `useConversationParticipation.reply` documents ("the item will
|
|
270
|
+
* appear on the timeline as `ob:<that id>`"). The workbench's
|
|
271
|
+
* post-send busy state watches for exactly this id (F-05).
|
|
272
|
+
*/
|
|
273
|
+
export function outboundItemIdOf(outboundMessageId: string): string {
|
|
274
|
+
return `ob:${outboundMessageId}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Newest-first timeline order: `(at, item_id)` descending — the exact
|
|
279
|
+
* comparator the server pages with, so client-side merges can never
|
|
280
|
+
* disagree with server pages about order.
|
|
281
|
+
*/
|
|
282
|
+
export function compareTimelineItemsNewestFirst(
|
|
283
|
+
a: ConversationTimelineItem,
|
|
284
|
+
b: ConversationTimelineItem,
|
|
285
|
+
): number {
|
|
286
|
+
const byInstant = compareInstants(b.at, a.at);
|
|
287
|
+
if (byInstant !== 0) return byInstant;
|
|
288
|
+
return a.itemId < b.itemId ? 1 : a.itemId > b.itemId ? -1 : 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// BigInt() call form, not a 0n literal: the web console compiles SDK
|
|
292
|
+
// sources under a pre-ES2020 target where the literal is a syntax error.
|
|
293
|
+
const ZERO_SECONDS = BigInt(0);
|
|
294
|
+
|
|
295
|
+
function compareInstants(a: Timestamp | undefined, b: Timestamp | undefined): number {
|
|
296
|
+
// A missing instant sorts oldest — the server always stamps `at`, so
|
|
297
|
+
// this only orders a defensively-handled corrupt item to the top of
|
|
298
|
+
// history rather than crashing the sort.
|
|
299
|
+
const aSeconds = a?.seconds ?? ZERO_SECONDS;
|
|
300
|
+
const bSeconds = b?.seconds ?? ZERO_SECONDS;
|
|
301
|
+
if (aSeconds !== bSeconds) return aSeconds < bSeconds ? -1 : 1;
|
|
302
|
+
const aNanos = a?.nanos ?? 0;
|
|
303
|
+
const bNanos = b?.nanos ?? 0;
|
|
304
|
+
return aNanos - bNanos;
|
|
305
|
+
}
|