@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,80 @@
|
|
|
1
|
+
export { ConversationsWorkbench } from "./ConversationsWorkbench.js";
|
|
2
|
+
export type {
|
|
3
|
+
ConversationHeaderContext,
|
|
4
|
+
ConversationsWorkbenchProps,
|
|
5
|
+
} from "./ConversationsWorkbench.js";
|
|
6
|
+
|
|
7
|
+
export { ConversationListPane } from "./ConversationListPane.js";
|
|
8
|
+
export type {
|
|
9
|
+
ConversationIdentity,
|
|
10
|
+
ConversationListPaneProps,
|
|
11
|
+
} from "./ConversationListPane.js";
|
|
12
|
+
|
|
13
|
+
export { ConversationTimelineView } from "./ConversationTimelineView.js";
|
|
14
|
+
export type { ConversationTimelineViewProps } from "./ConversationTimelineView.js";
|
|
15
|
+
|
|
16
|
+
export { ConversationControlBanner } from "./ConversationControlBanner.js";
|
|
17
|
+
export type { ConversationControlBannerProps } from "./ConversationControlBanner.js";
|
|
18
|
+
|
|
19
|
+
export { ConversationAttentionBanner } from "./ConversationAttentionBanner.js";
|
|
20
|
+
export type { ConversationAttentionBannerProps } from "./ConversationAttentionBanner.js";
|
|
21
|
+
|
|
22
|
+
export { ConversationComposer } from "./ConversationComposer.js";
|
|
23
|
+
export type { ConversationComposerProps } from "./ConversationComposer.js";
|
|
24
|
+
|
|
25
|
+
export { useConversation } from "./useConversation.js";
|
|
26
|
+
export type {
|
|
27
|
+
UseConversationOptions,
|
|
28
|
+
UseConversationReturn,
|
|
29
|
+
} from "./useConversation.js";
|
|
30
|
+
|
|
31
|
+
export { useConversationList } from "./useConversationList.js";
|
|
32
|
+
export type {
|
|
33
|
+
UseConversationListOptions,
|
|
34
|
+
UseConversationListReturn,
|
|
35
|
+
} from "./useConversationList.js";
|
|
36
|
+
|
|
37
|
+
export { useConversationTimeline } from "./useConversationTimeline.js";
|
|
38
|
+
export type {
|
|
39
|
+
UseConversationTimelineOptions,
|
|
40
|
+
UseConversationTimelineReturn,
|
|
41
|
+
} from "./useConversationTimeline.js";
|
|
42
|
+
|
|
43
|
+
export { useConversationParticipation } from "./useConversationParticipation.js";
|
|
44
|
+
export type {
|
|
45
|
+
ConversationCommand,
|
|
46
|
+
UseConversationParticipationOptions,
|
|
47
|
+
UseConversationParticipationReturn,
|
|
48
|
+
} from "./useConversationParticipation.js";
|
|
49
|
+
|
|
50
|
+
export { useConversationsWantsHumanCount } from "./useConversationsWantsHumanCount.js";
|
|
51
|
+
export type {
|
|
52
|
+
UseConversationsWantsHumanCountOptions,
|
|
53
|
+
UseConversationsWantsHumanCountReturn,
|
|
54
|
+
} from "./useConversationsWantsHumanCount.js";
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
authorKindOf,
|
|
58
|
+
awaitingIndicatorOf,
|
|
59
|
+
compareTimelineItemsNewestFirst,
|
|
60
|
+
conversationContactOf,
|
|
61
|
+
conversationLabelOf,
|
|
62
|
+
inboundPlaceholderOf,
|
|
63
|
+
isInternalItem,
|
|
64
|
+
receiptOf,
|
|
65
|
+
sendAttemptOf,
|
|
66
|
+
serviceWindowOf,
|
|
67
|
+
} from "./conversationPresentation.js";
|
|
68
|
+
export type {
|
|
69
|
+
AwaitingIndicator,
|
|
70
|
+
ConversationAuthorKind,
|
|
71
|
+
ReceiptKind,
|
|
72
|
+
SendAttemptKind,
|
|
73
|
+
ServiceWindowState,
|
|
74
|
+
} from "./conversationPresentation.js";
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
CONVERSATION_BADGE_POLL_INTERVAL_MS,
|
|
78
|
+
CONVERSATION_DETAIL_POLL_INTERVAL_MS,
|
|
79
|
+
CONVERSATION_LIST_POLL_INTERVAL_MS,
|
|
80
|
+
} from "./polling.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness for the conversation surface is HONEST POLLING (channel-
|
|
3
|
+
* conversations DD-012) — no streaming or watch RPC exists on the
|
|
4
|
+
* conversation query controller, and none ships until a revisit trigger
|
|
5
|
+
* fires (a real push consumer, or sub-second liveness becoming a product
|
|
6
|
+
* property). These constants are the deliberate transport seam: hooks
|
|
7
|
+
* default to them, consumers may override per instance, and when
|
|
8
|
+
* push-shaped liveness lands the defaults change here without touching a
|
|
9
|
+
* consumer.
|
|
10
|
+
*
|
|
11
|
+
* The latency budget is a stated contract (DD-012 D-b), not an accident
|
|
12
|
+
* of three numbers:
|
|
13
|
+
*
|
|
14
|
+
* | Surface | Budget | Mechanism |
|
|
15
|
+
* | ---------------------------------------- | ---------------------------- | ------------------------------------------------ |
|
|
16
|
+
* | Your own action (take over, hand back, | immediate | command answer applied to the detail AND list |
|
|
17
|
+
* | dismiss, reply) | | `applyServerState` seams — zero polls involved |
|
|
18
|
+
* | The open conversation (row + timeline) | ≤ 5s | detail polls |
|
|
19
|
+
* | The inbox list | ≤ 20s, immediate on refocus | list poll + focus refetch |
|
|
20
|
+
* | The nav badge | ≤ 60s, immediate on refocus | count poll (the filtered list's `total_count`) |
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Default poll interval for the org-wide conversation list. */
|
|
24
|
+
export const CONVERSATION_LIST_POLL_INTERVAL_MS = 20_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default poll interval for the OPEN conversation (its row and its
|
|
28
|
+
* timeline head). Tighter than the list on purpose: this is where a
|
|
29
|
+
* teammate's takeover or the customer's next message must appear while
|
|
30
|
+
* a human is watching.
|
|
31
|
+
*/
|
|
32
|
+
export const CONVERSATION_DETAIL_POLL_INTERVAL_MS = 5_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default poll interval for the sidebar's wants-human count. Deliberately
|
|
36
|
+
* the slowest budget on the surface: a badge tolerates staleness an open
|
|
37
|
+
* inbox does not, it renders on every console page in every tab, and each
|
|
38
|
+
* tick pays an FGA ListObjects before the count (DD-011 D-g's stated
|
|
39
|
+
* price). Focus refetch keeps it honest at the moment someone returns.
|
|
40
|
+
*/
|
|
41
|
+
export const CONVERSATION_BADGE_POLL_INTERVAL_MS = 60_000;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { create, equals } from "@bufbuild/protobuf";
|
|
4
|
+
import { isNotFound } from "@stigmer/sdk";
|
|
5
|
+
import type { ChannelConversation } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
6
|
+
import {
|
|
7
|
+
ChannelConversationSchema,
|
|
8
|
+
GetChannelConversationInputSchema,
|
|
9
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { useStigmer } from "../hooks.js";
|
|
12
|
+
import { toError } from "../internal/toError.js";
|
|
13
|
+
import { CONVERSATION_DETAIL_POLL_INTERVAL_MS } from "./polling.js";
|
|
14
|
+
|
|
15
|
+
/** Options for {@link useConversation}. */
|
|
16
|
+
export interface UseConversationOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Poll interval in milliseconds; `false` disables polling. Defaults to
|
|
19
|
+
* {@link CONVERSATION_DETAIL_POLL_INTERVAL_MS} — the open conversation
|
|
20
|
+
* is where another teammate's takeover must appear promptly.
|
|
21
|
+
*/
|
|
22
|
+
readonly refetchIntervalMs?: number | false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Return value of {@link useConversation}. */
|
|
26
|
+
export interface UseConversationReturn {
|
|
27
|
+
/**
|
|
28
|
+
* The conversation's participation state, or `null` while loading and
|
|
29
|
+
* while the conversation does not exist yet (see
|
|
30
|
+
* {@link awaitingCustomer} for the distinction).
|
|
31
|
+
*/
|
|
32
|
+
readonly conversation: ChannelConversation | null;
|
|
33
|
+
/**
|
|
34
|
+
* `true` when the server answered NOT_FOUND: no conversation row
|
|
35
|
+
* exists yet for this key. NOT an error — a proactive cold-send can
|
|
36
|
+
* have a timeline before the customer ever writes, and the row is
|
|
37
|
+
* created by the customer's first message. Render it as
|
|
38
|
+
* "participation controls unlock when the customer writes".
|
|
39
|
+
*/
|
|
40
|
+
readonly awaitingCustomer: boolean;
|
|
41
|
+
/** `true` only until the first answer (row or NOT_FOUND) arrives. */
|
|
42
|
+
readonly isLoading: boolean;
|
|
43
|
+
/** `true` while a background poll is in flight and stale state shows. */
|
|
44
|
+
readonly isRefetching: boolean;
|
|
45
|
+
/** Error from the last failed request (NOT_FOUND excluded), or `null`. */
|
|
46
|
+
readonly error: Error | null;
|
|
47
|
+
/** Imperatively re-read the row. */
|
|
48
|
+
readonly refetch: () => void;
|
|
49
|
+
/**
|
|
50
|
+
* Adopt a fresh row the server just returned — every participation
|
|
51
|
+
* command answers the post-command state (including for the loser of
|
|
52
|
+
* a concurrent-takeover race), and that answer is newer than anything
|
|
53
|
+
* a poll already in flight will deliver. Applying it also fences
|
|
54
|
+
* those in-flight polls so a stale answer can never overwrite it.
|
|
55
|
+
*/
|
|
56
|
+
readonly applyServerState: (fresh: ChannelConversation) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Data hook for one conversation's identity and participation state —
|
|
61
|
+
* the single-row read behind a conversation detail view (who holds
|
|
62
|
+
* control, whether attention is needed and why, the display name).
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT built on the shared `useFetch`: participation
|
|
65
|
+
* commands hand back fresher state than any in-flight poll
|
|
66
|
+
* ({@link UseConversationReturn.applyServerState}), which needs a write
|
|
67
|
+
* fence over the fetch state — a seam `useFetch`'s single-writer model
|
|
68
|
+
* does not have. The fence is an epoch counter: applying server state
|
|
69
|
+
* bumps it, and a poll that started under an older epoch discards its
|
|
70
|
+
* answer instead of resurrecting pre-command state under the user's
|
|
71
|
+
* cursor.
|
|
72
|
+
*
|
|
73
|
+
* Pass empty strings to skip fetching (the `null`-fetchFn convention).
|
|
74
|
+
*/
|
|
75
|
+
export function useConversation(
|
|
76
|
+
agentChannelId: string,
|
|
77
|
+
conversationKey: string,
|
|
78
|
+
options?: UseConversationOptions,
|
|
79
|
+
): UseConversationReturn {
|
|
80
|
+
const stigmer = useStigmer();
|
|
81
|
+
const refetchIntervalMs =
|
|
82
|
+
options?.refetchIntervalMs ?? CONVERSATION_DETAIL_POLL_INTERVAL_MS;
|
|
83
|
+
|
|
84
|
+
const enabled = agentChannelId !== "" && conversationKey !== "";
|
|
85
|
+
|
|
86
|
+
const [conversation, setConversation] = useState<ChannelConversation | null>(null);
|
|
87
|
+
const [awaitingCustomer, setAwaitingCustomer] = useState(false);
|
|
88
|
+
const [hasAnswer, setHasAnswer] = useState(false);
|
|
89
|
+
const [isFetching, setIsFetching] = useState(enabled);
|
|
90
|
+
const [error, setError] = useState<Error | null>(null);
|
|
91
|
+
const [fetchKey, setFetchKey] = useState(0);
|
|
92
|
+
|
|
93
|
+
// The write fence: bumped by applyServerState and by identity changes;
|
|
94
|
+
// a fetch that started under an older epoch throws its answer away.
|
|
95
|
+
const epochRef = useRef(0);
|
|
96
|
+
// Which fetch is the latest — ONLY the latest fetch's resolution may
|
|
97
|
+
// clear the in-flight flags, so a fenced stale answer cannot strand
|
|
98
|
+
// isFetching=true (which would silence the poll interval forever).
|
|
99
|
+
const fetchIdRef = useRef(0);
|
|
100
|
+
const isFetchingRef = useRef(false);
|
|
101
|
+
|
|
102
|
+
const refetch = useCallback(() => setFetchKey((k) => k + 1), []);
|
|
103
|
+
|
|
104
|
+
const applyServerState = useCallback((fresh: ChannelConversation) => {
|
|
105
|
+
epochRef.current += 1;
|
|
106
|
+
setConversation(fresh);
|
|
107
|
+
setAwaitingCustomer(false);
|
|
108
|
+
setHasAnswer(true);
|
|
109
|
+
setError(null);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
// Identity change (or disable): reset so channel A's control state
|
|
114
|
+
// can never render under channel B's key.
|
|
115
|
+
epochRef.current += 1;
|
|
116
|
+
setConversation(null);
|
|
117
|
+
setAwaitingCustomer(false);
|
|
118
|
+
setHasAnswer(false);
|
|
119
|
+
setError(null);
|
|
120
|
+
if (!enabled) {
|
|
121
|
+
setIsFetching(false);
|
|
122
|
+
isFetchingRef.current = false;
|
|
123
|
+
}
|
|
124
|
+
}, [agentChannelId, conversationKey, stigmer, enabled]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!enabled) return;
|
|
128
|
+
const epoch = epochRef.current;
|
|
129
|
+
const fetchId = ++fetchIdRef.current;
|
|
130
|
+
setIsFetching(true);
|
|
131
|
+
isFetchingRef.current = true;
|
|
132
|
+
|
|
133
|
+
const settle = () => {
|
|
134
|
+
if (fetchIdRef.current !== fetchId) return;
|
|
135
|
+
setIsFetching(false);
|
|
136
|
+
isFetchingRef.current = false;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
stigmer.agentChannel
|
|
140
|
+
.getConversation(
|
|
141
|
+
create(GetChannelConversationInputSchema, { agentChannelId, conversationKey }),
|
|
142
|
+
)
|
|
143
|
+
.then(
|
|
144
|
+
(row) => {
|
|
145
|
+
if (epochRef.current === epoch) {
|
|
146
|
+
// Preserve the reference across polls that change nothing —
|
|
147
|
+
// a fresh-but-equal proto every 5s would defeat React.memo
|
|
148
|
+
// on everything rendering the row (DD-010).
|
|
149
|
+
setConversation((current) =>
|
|
150
|
+
current && equals(ChannelConversationSchema, current, row)
|
|
151
|
+
? current
|
|
152
|
+
: row,
|
|
153
|
+
);
|
|
154
|
+
setAwaitingCustomer(false);
|
|
155
|
+
setHasAnswer(true);
|
|
156
|
+
setError(null);
|
|
157
|
+
}
|
|
158
|
+
settle();
|
|
159
|
+
},
|
|
160
|
+
(err) => {
|
|
161
|
+
if (epochRef.current === epoch) {
|
|
162
|
+
if (isNotFound(err)) {
|
|
163
|
+
// The legitimate "no row yet" answer — not an error state.
|
|
164
|
+
setConversation(null);
|
|
165
|
+
setAwaitingCustomer(true);
|
|
166
|
+
setHasAnswer(true);
|
|
167
|
+
setError(null);
|
|
168
|
+
} else {
|
|
169
|
+
setError(toError(err));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
settle();
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
}, [enabled, agentChannelId, conversationKey, stigmer, fetchKey]);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!enabled || !refetchIntervalMs || refetchIntervalMs <= 0) return;
|
|
179
|
+
const id = setInterval(() => {
|
|
180
|
+
if (!isFetchingRef.current) refetch();
|
|
181
|
+
}, refetchIntervalMs);
|
|
182
|
+
return () => clearInterval(id);
|
|
183
|
+
}, [enabled, refetchIntervalMs, refetch]);
|
|
184
|
+
|
|
185
|
+
// Focus refetch (DD-012 D-a: returning to the tab is fresh), guarded by
|
|
186
|
+
// the same in-flight flag as the interval. Mirrors useFetch's
|
|
187
|
+
// refetchOnWindowFocus — the second sighting of this listener shape;
|
|
188
|
+
// extract a shared helper if a third hand-rolled loop ever needs it.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!enabled) return;
|
|
191
|
+
if (typeof window === "undefined") return;
|
|
192
|
+
const onActive = () => {
|
|
193
|
+
if (!isFetchingRef.current) refetch();
|
|
194
|
+
};
|
|
195
|
+
const onVisible = () => {
|
|
196
|
+
if (document.visibilityState === "visible") onActive();
|
|
197
|
+
};
|
|
198
|
+
window.addEventListener("focus", onActive);
|
|
199
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
200
|
+
return () => {
|
|
201
|
+
window.removeEventListener("focus", onActive);
|
|
202
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
203
|
+
};
|
|
204
|
+
}, [enabled, refetch]);
|
|
205
|
+
|
|
206
|
+
const isLoading = isFetching && !hasAnswer;
|
|
207
|
+
const isRefetching = isFetching && hasAnswer;
|
|
208
|
+
|
|
209
|
+
return useMemo(
|
|
210
|
+
() => ({
|
|
211
|
+
conversation,
|
|
212
|
+
awaitingCustomer,
|
|
213
|
+
isLoading,
|
|
214
|
+
isRefetching,
|
|
215
|
+
error,
|
|
216
|
+
refetch,
|
|
217
|
+
applyServerState,
|
|
218
|
+
}),
|
|
219
|
+
[conversation, awaitingCustomer, isLoading, isRefetching, error, refetch, applyServerState],
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { create } from "@bufbuild/protobuf";
|
|
4
|
+
import type { ChannelConversation } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
5
|
+
import {
|
|
6
|
+
ChannelConversationListFilter,
|
|
7
|
+
ListChannelConversationsInputSchema,
|
|
8
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { useStigmer } from "../hooks.js";
|
|
11
|
+
import { toError } from "../internal/toError.js";
|
|
12
|
+
import { useFetch } from "../internal/useFetch.js";
|
|
13
|
+
import { CONVERSATION_LIST_POLL_INTERVAL_MS } from "./polling.js";
|
|
14
|
+
|
|
15
|
+
/** Options for {@link useConversationList}. */
|
|
16
|
+
export interface UseConversationListOptions {
|
|
17
|
+
/** Organization whose conversations to list. `null` skips fetching. */
|
|
18
|
+
readonly org: string | null;
|
|
19
|
+
/** Optional filter: only conversations on this agent channel. */
|
|
20
|
+
readonly agentChannelId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional server-evaluated predicate filter (channel-conversations
|
|
23
|
+
* DD-011 D-g). The generated enum is the vocabulary on purpose — the
|
|
24
|
+
* predicate lives in ONE place, the server, and the hook never
|
|
25
|
+
* re-expresses it. Defaults to unspecified (no filter).
|
|
26
|
+
*/
|
|
27
|
+
readonly filter?: ChannelConversationListFilter;
|
|
28
|
+
/** Page size for the head page and each loadMore page. Default 50. */
|
|
29
|
+
readonly pageSize?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Poll interval in milliseconds; `false` disables polling. Defaults to
|
|
32
|
+
* {@link CONVERSATION_LIST_POLL_INTERVAL_MS}. Only the head page polls
|
|
33
|
+
* — loaded older pages refresh on the next full identity change.
|
|
34
|
+
*/
|
|
35
|
+
readonly refetchIntervalMs?: number | false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Return value of {@link useConversationList}. */
|
|
39
|
+
export interface UseConversationListReturn {
|
|
40
|
+
/**
|
|
41
|
+
* Conversations, newest activity first: the polled head page followed
|
|
42
|
+
* by the accumulated older pages, deduplicated by conversation
|
|
43
|
+
* identity (offset pages drift under live activity — a conversation
|
|
44
|
+
* that moved up between fetches appears once, in its head position),
|
|
45
|
+
* with any {@link applyServerState} rows overriding both.
|
|
46
|
+
*/
|
|
47
|
+
readonly conversations: readonly ChannelConversation[];
|
|
48
|
+
/** Exact total for the current filter, from the server. */
|
|
49
|
+
readonly totalCount: number;
|
|
50
|
+
/** `true` while more conversations exist beyond what is loaded. */
|
|
51
|
+
readonly hasMore: boolean;
|
|
52
|
+
/** Load the next older page. No-op while one is already loading. */
|
|
53
|
+
readonly loadMore: () => void;
|
|
54
|
+
/** `true` while a loadMore page is in flight. */
|
|
55
|
+
readonly isLoadingMore: boolean;
|
|
56
|
+
/** Error from the last failed loadMore, or `null`. Cleared on retry. */
|
|
57
|
+
readonly loadMoreError: Error | null;
|
|
58
|
+
/** `true` only during the first head-page load. */
|
|
59
|
+
readonly isLoading: boolean;
|
|
60
|
+
/** `true` while the head page refreshes in the background. */
|
|
61
|
+
readonly isRefetching: boolean;
|
|
62
|
+
/** Error from the last failed head-page fetch, or `null`. */
|
|
63
|
+
readonly error: Error | null;
|
|
64
|
+
/** Re-fetch the head page. */
|
|
65
|
+
readonly refetch: () => void;
|
|
66
|
+
/**
|
|
67
|
+
* Adopt a fresh row the server just returned — every participation
|
|
68
|
+
* command answers the post-command state, and that answer is newer
|
|
69
|
+
* than anything a poll already in flight will deliver (DD-012 D-a:
|
|
70
|
+
* your own action reflects immediately, zero polls involved). The row
|
|
71
|
+
* overrides the listed copy for exactly one round-trip: applying also
|
|
72
|
+
* starts a fresh head fetch, and the override is dropped when a page
|
|
73
|
+
* provably fetched after the apply answers.
|
|
74
|
+
*/
|
|
75
|
+
readonly applyServerState: (fresh: ChannelConversation) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HeadPage {
|
|
79
|
+
readonly items: readonly ChannelConversation[];
|
|
80
|
+
readonly totalCount: number;
|
|
81
|
+
/**
|
|
82
|
+
* The apply-sequence at the moment this fetch STARTED. The overlay
|
|
83
|
+
* (see {@link useConversationList}) may only be cleared by a page
|
|
84
|
+
* whose `seq` proves it observed the post-command server state.
|
|
85
|
+
*/
|
|
86
|
+
readonly seq: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const EMPTY_HEAD: HeadPage = { items: [], totalCount: 0, seq: 0 };
|
|
90
|
+
|
|
91
|
+
/** Rows adopted from command answers, waiting for the head to catch up. */
|
|
92
|
+
interface Overlay {
|
|
93
|
+
readonly rows: ReadonlyMap<string, ChannelConversation>;
|
|
94
|
+
/** The apply-sequence of the newest apply in {@link rows}. */
|
|
95
|
+
readonly seq: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const EMPTY_OVERLAY: Overlay = { rows: new Map(), seq: 0 };
|
|
99
|
+
|
|
100
|
+
function identityOf(conversation: ChannelConversation): string {
|
|
101
|
+
return `${conversation.agentChannelId}\u0000${conversation.conversationKey}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Activity instant in epoch milliseconds, for sorted overlay inserts. */
|
|
105
|
+
function activityMillisOf(conversation: ChannelConversation): number {
|
|
106
|
+
const at = conversation.lastActivityAt;
|
|
107
|
+
if (!at) return 0;
|
|
108
|
+
return Number(at.seconds) * 1_000 + Math.floor(at.nanos / 1_000_000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Data hook for the org-wide conversation list (channel-conversations
|
|
113
|
+
* DD-004): newest activity first across every channel the caller can
|
|
114
|
+
* view, optionally narrowed to one channel and/or a server-evaluated
|
|
115
|
+
* predicate ({@link ChannelConversationListFilter}).
|
|
116
|
+
*
|
|
117
|
+
* The head page rides `useFetch` and polls; older pages accumulate via
|
|
118
|
+
* {@link UseConversationListReturn.loadMore} and are merged with head
|
|
119
|
+
* precedence — the server's offset pagination shifts under live activity,
|
|
120
|
+
* so identity dedup (head copy wins: it is the fresher read) is what
|
|
121
|
+
* keeps a moving conversation from rendering twice.
|
|
122
|
+
*
|
|
123
|
+
* Command answers enter through
|
|
124
|
+
* {@link UseConversationListReturn.applyServerState} as a short-lived
|
|
125
|
+
* OVERLAY rather than a write into the fetched state, because `useFetch`
|
|
126
|
+
* has no write fence (the reason `useConversation` is hand-rolled): a
|
|
127
|
+
* poll answer already in the microtask queue when the command answers can
|
|
128
|
+
* commit AFTER the apply, and clearing the adopted row on "any head
|
|
129
|
+
* change" would hand the inbox back to pre-command state for a full poll
|
|
130
|
+
* period. Instead every head page is stamped with the apply-sequence at
|
|
131
|
+
* fetch start, and the overlay is dropped only when a page with
|
|
132
|
+
* `seq >= overlay.seq` — one provably started after the apply — answers.
|
|
133
|
+
* Applying triggers that fetch immediately, so the overlay lives for
|
|
134
|
+
* exactly one round-trip.
|
|
135
|
+
*
|
|
136
|
+
* Two honesty guards on overlay INSERTS (rows the server has not listed):
|
|
137
|
+
* a row from another channel never enters a channel-scoped list (identity
|
|
138
|
+
* equality — the same scope the hook sends to the server), and under a
|
|
139
|
+
* server-evaluated predicate filter the overlay is update-only, because
|
|
140
|
+
* the client cannot honestly claim membership the server has not
|
|
141
|
+
* asserted (the DD-011 A-1 one-predicate discipline). Updates in place
|
|
142
|
+
* are always honest: the server listed the row.
|
|
143
|
+
*/
|
|
144
|
+
export function useConversationList(
|
|
145
|
+
options: UseConversationListOptions,
|
|
146
|
+
): UseConversationListReturn {
|
|
147
|
+
const {
|
|
148
|
+
org,
|
|
149
|
+
agentChannelId = "",
|
|
150
|
+
filter = ChannelConversationListFilter.channel_conversation_list_filter_unspecified,
|
|
151
|
+
pageSize = 50,
|
|
152
|
+
} = options;
|
|
153
|
+
const refetchIntervalMs =
|
|
154
|
+
options.refetchIntervalMs ?? CONVERSATION_LIST_POLL_INTERVAL_MS;
|
|
155
|
+
const stigmer = useStigmer();
|
|
156
|
+
|
|
157
|
+
// Monotonic count of applyServerState calls; head pages carry the
|
|
158
|
+
// value read at their fetch start (see HeadPage.seq).
|
|
159
|
+
const applySeqRef = useRef(0);
|
|
160
|
+
|
|
161
|
+
const fetchFn = org
|
|
162
|
+
? async (): Promise<HeadPage> => {
|
|
163
|
+
const seq = applySeqRef.current;
|
|
164
|
+
const result = await stigmer.agentChannel.listConversations(
|
|
165
|
+
create(ListChannelConversationsInputSchema, {
|
|
166
|
+
org,
|
|
167
|
+
agentChannelId,
|
|
168
|
+
filter,
|
|
169
|
+
pageInfo: { num: 1, size: pageSize },
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
return { items: result.items, totalCount: result.totalCount, seq };
|
|
173
|
+
}
|
|
174
|
+
: null;
|
|
175
|
+
|
|
176
|
+
const {
|
|
177
|
+
data: head,
|
|
178
|
+
isLoading,
|
|
179
|
+
isRefetching,
|
|
180
|
+
error,
|
|
181
|
+
refetch,
|
|
182
|
+
} = useFetch(
|
|
183
|
+
fetchFn,
|
|
184
|
+
[org, agentChannelId, filter, pageSize, stigmer],
|
|
185
|
+
EMPTY_HEAD,
|
|
186
|
+
{
|
|
187
|
+
refetchInterval: refetchIntervalMs,
|
|
188
|
+
// DD-012 D-a: returning to the tab is fresh.
|
|
189
|
+
refetchOnWindowFocus: true,
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Older pages, keyed by the same identity deps as the head fetch; the
|
|
194
|
+
// epoch fences in-flight loadMore answers across an identity change.
|
|
195
|
+
const [olderPages, setOlderPages] = useState<readonly ChannelConversation[][]>([]);
|
|
196
|
+
const [loadedThroughPage, setLoadedThroughPage] = useState(1);
|
|
197
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
198
|
+
const [loadMoreError, setLoadMoreError] = useState<Error | null>(null);
|
|
199
|
+
const [overlay, setOverlay] = useState<Overlay>(EMPTY_OVERLAY);
|
|
200
|
+
const epochRef = useRef(0);
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
epochRef.current += 1;
|
|
204
|
+
setOlderPages([]);
|
|
205
|
+
setLoadedThroughPage(1);
|
|
206
|
+
setIsLoadingMore(false);
|
|
207
|
+
setLoadMoreError(null);
|
|
208
|
+
setOverlay(EMPTY_OVERLAY);
|
|
209
|
+
}, [org, agentChannelId, filter, pageSize, stigmer]);
|
|
210
|
+
|
|
211
|
+
const applyServerState = useCallback(
|
|
212
|
+
(fresh: ChannelConversation) => {
|
|
213
|
+
const seq = ++applySeqRef.current;
|
|
214
|
+
setOverlay((current) => {
|
|
215
|
+
const rows = new Map(current.rows);
|
|
216
|
+
rows.set(identityOf(fresh), fresh);
|
|
217
|
+
return { rows, seq };
|
|
218
|
+
});
|
|
219
|
+
// Starts the round-trip that retires the overlay; also cancels any
|
|
220
|
+
// in-flight pre-command head fetch (useFetch's cleanup semantics).
|
|
221
|
+
refetch();
|
|
222
|
+
},
|
|
223
|
+
[refetch],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Retire the overlay once a head page fetched after the newest apply
|
|
227
|
+
// answers — server truth has caught up. A page with an older stamp
|
|
228
|
+
// (the microtask-race case) keeps the overlay in force.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (overlay.rows.size === 0) return;
|
|
231
|
+
if (head.seq >= overlay.seq) setOverlay(EMPTY_OVERLAY);
|
|
232
|
+
}, [head, overlay]);
|
|
233
|
+
|
|
234
|
+
const loadMore = useCallback(() => {
|
|
235
|
+
if (!org || isLoadingMore) return;
|
|
236
|
+
const epoch = epochRef.current;
|
|
237
|
+
const nextPage = loadedThroughPage + 1;
|
|
238
|
+
setIsLoadingMore(true);
|
|
239
|
+
setLoadMoreError(null);
|
|
240
|
+
|
|
241
|
+
stigmer.agentChannel
|
|
242
|
+
.listConversations(
|
|
243
|
+
create(ListChannelConversationsInputSchema, {
|
|
244
|
+
org,
|
|
245
|
+
agentChannelId,
|
|
246
|
+
filter,
|
|
247
|
+
pageInfo: { num: nextPage, size: pageSize },
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
.then(
|
|
251
|
+
(result) => {
|
|
252
|
+
if (epochRef.current !== epoch) return;
|
|
253
|
+
setOlderPages((pages) => [...pages, result.items]);
|
|
254
|
+
setLoadedThroughPage(nextPage);
|
|
255
|
+
setIsLoadingMore(false);
|
|
256
|
+
},
|
|
257
|
+
(err) => {
|
|
258
|
+
if (epochRef.current !== epoch) return;
|
|
259
|
+
setLoadMoreError(toError(err));
|
|
260
|
+
setIsLoadingMore(false);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
}, [org, agentChannelId, filter, pageSize, stigmer, isLoadingMore, loadedThroughPage]);
|
|
264
|
+
|
|
265
|
+
const conversations = useMemo(() => {
|
|
266
|
+
const seen = new Set<string>();
|
|
267
|
+
const merged: ChannelConversation[] = [];
|
|
268
|
+
for (const conversation of [...head.items, ...olderPages.flat()]) {
|
|
269
|
+
const identity = identityOf(conversation);
|
|
270
|
+
if (seen.has(identity)) continue;
|
|
271
|
+
seen.add(identity);
|
|
272
|
+
// Update-in-place is always honest: the server listed this row.
|
|
273
|
+
merged.push(overlay.rows.get(identity) ?? conversation);
|
|
274
|
+
}
|
|
275
|
+
// Overlay rows the server has not listed: sorted insert (a command
|
|
276
|
+
// answer is not a recency claim — a takeover does not advance the
|
|
277
|
+
// activity clock, so head-of-list would misorder), subject to the
|
|
278
|
+
// two honesty guards documented on the hook.
|
|
279
|
+
for (const [identity, fresh] of overlay.rows) {
|
|
280
|
+
if (seen.has(identity)) continue;
|
|
281
|
+
if (
|
|
282
|
+
filter !==
|
|
283
|
+
ChannelConversationListFilter.channel_conversation_list_filter_unspecified
|
|
284
|
+
) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (agentChannelId !== "" && fresh.agentChannelId !== agentChannelId) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const at = activityMillisOf(fresh);
|
|
291
|
+
const index = merged.findIndex((c) => activityMillisOf(c) < at);
|
|
292
|
+
merged.splice(index === -1 ? merged.length : index, 0, fresh);
|
|
293
|
+
seen.add(identity);
|
|
294
|
+
}
|
|
295
|
+
return merged;
|
|
296
|
+
}, [head.items, olderPages, overlay, agentChannelId, filter]);
|
|
297
|
+
|
|
298
|
+
const hasMore = conversations.length < head.totalCount;
|
|
299
|
+
|
|
300
|
+
return useMemo(
|
|
301
|
+
() => ({
|
|
302
|
+
conversations,
|
|
303
|
+
totalCount: head.totalCount,
|
|
304
|
+
hasMore,
|
|
305
|
+
loadMore,
|
|
306
|
+
isLoadingMore,
|
|
307
|
+
loadMoreError,
|
|
308
|
+
isLoading,
|
|
309
|
+
isRefetching,
|
|
310
|
+
error,
|
|
311
|
+
refetch,
|
|
312
|
+
applyServerState,
|
|
313
|
+
}),
|
|
314
|
+
[
|
|
315
|
+
conversations,
|
|
316
|
+
head.totalCount,
|
|
317
|
+
hasMore,
|
|
318
|
+
loadMore,
|
|
319
|
+
isLoadingMore,
|
|
320
|
+
loadMoreError,
|
|
321
|
+
isLoading,
|
|
322
|
+
isRefetching,
|
|
323
|
+
error,
|
|
324
|
+
refetch,
|
|
325
|
+
applyServerState,
|
|
326
|
+
],
|
|
327
|
+
);
|
|
328
|
+
}
|