-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathuseConversationSync.ts
More file actions
193 lines (172 loc) · 7.67 KB
/
useConversationSync.ts
File metadata and controls
193 lines (172 loc) · 7.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import { useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { conversationGet } from "@/api/generated/sdk.gen";
import type { ConversationWithProject } from "@/api/generated/types.gen";
import {
useConversationsContext,
type ForkConversationOptions,
} from "@/components/ConversationsProvider/ConversationsProvider";
import { useConversationStore } from "@/stores/conversationStore";
import { useIsStreaming } from "@/stores/streamingStore";
import { useChatUIStore } from "@/stores/chatUIStore";
/**
* Hook that synchronizes the current conversation between the persistence layer
* (ConversationsProvider) and the in-memory state (conversationStore).
*
* This hook handles:
* 1. Loading conversation data into stores when conversationId changes
* 2. Saving conversation data back to persistence when messages change
* 3. Debouncing saves to avoid excessive writes
* 4. Fetching conversations from the API when not found locally (direct URL navigation)
* 5. Updating the URL to use the server-assigned remoteId for shareable links
*
* @param conversationId - The ID of the current conversation from URL params
*/
export function useConversationSync(conversationId: string | undefined) {
const {
conversations,
isLoading,
createConversation,
updateConversation,
forkConversation,
addRemoteConversation,
} = useConversationsContext();
const navigate = useNavigate();
const isStreaming = useIsStreaming();
// Get store state and actions
const messages = useConversationStore((state) => state.messages);
const selectedModels = useConversationStore((state) => state.selectedModels);
const setMessages = useConversationStore((state) => state.setMessages);
const setSelectedModels = useConversationStore((state) => state.setSelectedModels);
const clearMessages = useConversationStore((state) => state.clearMessages);
const { setDisabledModels, clearSelectedBestResponses } = useChatUIStore();
// Find the current conversation from the provider (check both local id and server remoteId)
const currentConversation =
conversations.find((c) => c.id === conversationId || c.remoteId === conversationId) ?? null;
// Fetch from API when navigating directly to a conversation URL not in local state.
// This enables shareable URLs — the conversationId in the URL is the server-assigned
// remoteId which can be fetched from the API.
const { data: remoteConversation } = useQuery({
queryKey: ["conversation", conversationId],
queryFn: async () => {
const response = await conversationGet({ path: { id: conversationId! } });
return (response.data ?? null) as ConversationWithProject | null;
},
enabled: !!conversationId && !currentConversation && !isLoading,
retry: false,
staleTime: Infinity,
});
// Merge the fetched conversation into local state
useEffect(() => {
if (remoteConversation) {
addRemoteConversation(remoteConversation);
}
}, [remoteConversation, addRemoteConversation]);
// Once a remoteId is assigned (after sync), update the URL so it's shareable.
// Only redirect when the URL still uses the local id (not yet the remoteId).
useEffect(() => {
if (
currentConversation?.remoteId &&
conversationId &&
conversationId === currentConversation.id &&
conversationId !== currentConversation.remoteId
) {
navigate(`/chat/${currentConversation.remoteId}`, { replace: true });
}
}, [currentConversation?.remoteId, currentConversation?.id, conversationId, navigate]);
// Track which conversation we've loaded to avoid re-loading during updates
const loadedConversationIdRef = useRef<string | null>(null);
// Track conversations created with an immediate message (to skip loading empty state)
const pendingNewConversationRef = useRef<string | null>(null);
// Debounce timer for saves
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Skip the first save after loading a conversation (loading messages into the store
// triggers the save effect, but nothing actually changed — saving would bump updatedAt
// and cause the conversation to jump to the top of the sidebar)
const skipNextSaveRef = useRef(false);
// Ref for currentConversationId so the save effect doesn't re-trigger on conversation
// switch (which would consume the skipNextSaveRef before the real message-load render)
const currentConversationIdRef = useRef(currentConversation?.id);
// Load conversation when it changes
useEffect(() => {
const newId = currentConversation?.id ?? null;
if (newId !== loadedConversationIdRef.current) {
loadedConversationIdRef.current = newId;
// Skip loading if this is a conversation we just created with an immediate message
// (the message is already in state, and the conversation is empty)
if (newId === pendingNewConversationRef.current) {
pendingNewConversationRef.current = null;
skipNextSaveRef.current = false;
return;
}
if (currentConversation) {
skipNextSaveRef.current = true;
setMessages(currentConversation.messages);
if (currentConversation.models.length > 0) {
setSelectedModels(currentConversation.models);
}
setDisabledModels([]);
clearSelectedBestResponses();
} else {
skipNextSaveRef.current = false;
clearMessages();
clearSelectedBestResponses();
}
}
}, [currentConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Save conversation when messages change (debounced).
// currentConversationId is accessed via ref so that switching conversations doesn't
// trigger this effect — otherwise it fires before the message-load render and
// consumes skipNextSaveRef too early, causing a spurious save that bumps updatedAt.
currentConversationIdRef.current = currentConversation?.id;
useEffect(() => {
const convId = currentConversationIdRef.current;
if (isStreaming || !convId || messages.length === 0) return;
if (loadedConversationIdRef.current !== convId) return;
// After loading a conversation, the messages store changes which triggers this effect.
// Skip that first save to avoid bumping updatedAt (which reorders the sidebar).
if (skipNextSaveRef.current) {
skipNextSaveRef.current = false;
return;
}
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
updateConversation(currentConversationIdRef.current!, messages, selectedModels);
}, 100);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [messages, isStreaming, selectedModels, updateConversation]);
/**
* Fork a conversation, optionally up to a specific message.
* Returns the new forked conversation.
*/
const handleForkConversation = useCallback(
(sourceId: string, options?: ForkConversationOptions) => {
return forkConversation(sourceId, options);
},
[forkConversation]
);
return {
currentConversation,
/**
* Create a new conversation and mark it as pending so the load effect
* doesn't clear the messages that are about to be added.
*/
createConversation: (models: string[], projectId?: string, projectName?: string) => {
const newConv = createConversation(models, projectId, projectName);
pendingNewConversationRef.current = newConv.id;
return newConv;
},
/**
* Fork the current or another conversation.
* Returns the new forked conversation.
*/
forkConversation: handleForkConversation,
};
}