Skip to content

Commit 8c4a6cd

Browse files
FelixMalfaitclaude
andauthored
Replace AGENT_CHAT_UNKNOWN_THREAD_ID with null for thread state (#19552)
## Summary This PR refactors the AI chat thread state management to use `null` instead of a sentinel string value (`AGENT_CHAT_UNKNOWN_THREAD_ID`) to represent an uninitialized or new chat thread. This improves type safety and makes the code more idiomatic by using `null` to represent the absence of a value. ## Key Changes - **Removed sentinel constant**: Deleted `AGENT_CHAT_UNKNOWN_THREAD_ID` constant and replaced all usages with `null` - **Updated state types**: Changed `currentAIChatThreadState`, `agentChatLastDiffSyncedThreadState`, and `agentChatDisplayedThreadState` to use `string | null` type with `null` as default value - **Updated component family states**: Modified message-related state families to accept `threadId: string | null` instead of `threadId: string` - **Refined null checks**: Added explicit `null` checks in: - `AgentChatMessagesFetchEffect`: Updated `isNewThread` logic to check for `null` first - `useAIChatThreadClick` and `useSwitchToNewAIChat`: Added guards to only save drafts when `currentAIChatThread !== null` - `AgentChatThreadInitializationEffect`: Added null check before UUID validation - `useEnsureAgentChatThreadIdForSend`: Added null check before comparing with draft key - **Updated fallback logic**: Used nullish coalescing operator (`??`) in `AIChatTab` and `useAIChatEditor` to default to `AGENT_CHAT_NEW_THREAD_DRAFT_KEY` when thread is null - **Enhanced refetch safety**: Added early return in `handleRefetchMessages` to prevent refetching when in new thread state ## Implementation Details - The change maintains backward compatibility by treating `null` the same way the code previously treated `AGENT_CHAT_UNKNOWN_THREAD_ID` - All draft saving operations now safely check for null before attempting to store drafts - The nullish coalescing pattern (`currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY`) ensures proper fallback behavior when accessing draft storage https://claude.ai/code/session_01Pz8KCygSNgBPYsndbMq8f7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f99c05f commit 8c4a6cd

17 files changed

Lines changed: 86 additions & 105 deletions

packages/twenty-front/src/modules/ai/components/AIChatTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const AIChatTab = () => {
2929
const threadIdCreatedFromDraft = useAtomStateValue(
3030
threadIdCreatedFromDraftState,
3131
);
32-
const draftKey = currentAIChatThread;
32+
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
3333
const editorSectionKey =
3434
draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY &&
3535
draftKey === threadIdCreatedFromDraft

packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai';
44
import { isDefined } from 'twenty-shared/utils';
55

66
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
7-
import { AGENT_CHAT_UNKNOWN_THREAD_ID } from '@/ai/constants/AgentChatUnknownThreadId';
87
import { agentChatFirstLiveSeqState } from '@/ai/states/agentChatFirstLiveSeqState';
98
import { agentChatHandleEventCallbackState } from '@/ai/states/agentChatHandleEventCallbackState';
109
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
@@ -30,8 +29,8 @@ export const AgentChatMessagesFetchEffect = () => {
3029

3130
const isNewThread = useMemo(
3231
() =>
33-
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ||
34-
currentAIChatThread === AGENT_CHAT_UNKNOWN_THREAD_ID,
32+
currentAIChatThread === null ||
33+
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
3534
[currentAIChatThread],
3635
);
3736

@@ -111,7 +110,7 @@ export const AgentChatMessagesFetchEffect = () => {
111110
const { refetch: refetchAgentChatMessages } = useQueryWithCallbacks(
112111
GetChatMessagesDocument,
113112
{
114-
variables: { threadId: currentAIChatThread },
113+
variables: { threadId: currentAIChatThread ?? '' },
115114
skip: !isDefined(currentAIChatThread) || isNewThread,
116115
onFirstLoad: handleFirstLoad,
117116
onDataLoaded: handleDataLoaded,
@@ -120,8 +119,12 @@ export const AgentChatMessagesFetchEffect = () => {
120119
);
121120

122121
const handleRefetchMessages = useCallback(() => {
122+
if (isNewThread) {
123+
return;
124+
}
125+
123126
refetchAgentChatMessages();
124-
}, [refetchAgentChatMessages]);
127+
}, [refetchAgentChatMessages, isNewThread]);
125128

126129
useListenToBrowserEvent({
127130
eventName: AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME,

packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ export const AgentChatThreadInitializationEffect = () => {
8888
}, [storeEntry.status, hasAiSettingsPermission, setAgentChatThreadsLoading]);
8989

9090
useEffect(() => {
91-
if (hasInitializedAgentChatThreads || isValidUuid(currentAIChatThread)) {
91+
if (
92+
hasInitializedAgentChatThreads ||
93+
(currentAIChatThread !== null && isValidUuid(currentAIChatThread))
94+
) {
9295
return;
9396
}
9497

packages/twenty-front/src/modules/ai/constants/AgentChatUnknownThreadId.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const useAIChatEditor = () => {
4949
const { removeFocusItemFromFocusStackById } =
5050
useRemoveFocusItemFromFocusStackById();
5151

52-
const draftKey = currentAIChatThread;
52+
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
5353
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
5454
const initialContent = textToTiptapContent(initialDraft);
5555

packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ export const useAIChatThreadClick = (
3838

3939
const handleThreadClick = (thread: AgentChatThread) => {
4040
setThreadIdCreatedFromDraft(null);
41-
const previousDraftKey = currentAIChatThread;
4241
const isSameThread = thread.id === currentAIChatThread;
4342

44-
setAgentChatDraftsByThreadId((prev) => ({
45-
...prev,
46-
[previousDraftKey]: store.get(agentChatInputState.atom),
47-
}));
43+
if (currentAIChatThread !== null) {
44+
setAgentChatDraftsByThreadId((prev) => ({
45+
...prev,
46+
[currentAIChatThread]: store.get(agentChatInputState.atom),
47+
}));
48+
}
4849
setCurrentAIChatThread(thread.id);
4950

5051
if (!isSameThread) {

packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts

Lines changed: 36 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect } from 'react';
1+
import { useEffect } from 'react';
22

33
import { readUIMessageStream, type UIMessageChunk } from 'ai';
44
import { print, type ExecutionResult } from 'graphql';
@@ -18,8 +18,6 @@ import { agentChatFirstLiveSeqState } from '@/ai/states/agentChatFirstLiveSeqSta
1818
import { agentChatHandleEventCallbackState } from '@/ai/states/agentChatHandleEventCallbackState';
1919
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
2020
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
21-
import { agentChatStreamWriterState } from '@/ai/states/agentChatStreamWriterState';
22-
import { agentChatSubscriptionDisposeState } from '@/ai/states/agentChatSubscriptionDisposeState';
2321
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
2422
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
2523
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
@@ -98,47 +96,38 @@ export const useAgentChatSubscription = (threadId: string | null) => {
9896
const store = useStore();
9997
const sseClient = useAtomStateValue(sseClientState);
10098

101-
const cleanup = useCallback(() => {
102-
const writer = store.get(agentChatStreamWriterState.atom);
103-
104-
if (isDefined(writer)) {
105-
writer.close().catch(() => {});
106-
store.set(agentChatStreamWriterState.atom, null);
107-
}
108-
109-
const dispose = store.get(agentChatSubscriptionDisposeState.atom);
110-
111-
if (isDefined(dispose)) {
112-
dispose();
113-
store.set(agentChatSubscriptionDisposeState.atom, null);
114-
}
115-
116-
if (store.get(agentChatIsStreamingState.atom)) {
117-
store.set(agentChatIsStreamingState.atom, false);
118-
}
119-
}, [store]);
120-
12199
useEffect(() => {
122-
if (!isDefined(threadId)) {
123-
cleanup();
124-
125-
return;
126-
}
127-
128-
if (!isDefined(sseClient)) {
100+
if (!isDefined(threadId) || !isDefined(sseClient)) {
129101
return;
130102
}
131103

132104
let bridge: TransformStream<UIMessageChunk> | null = null;
133105
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
134106
let latestMessage: ExtendedUIMessage | null = null;
107+
let writer: WritableStreamDefaultWriter<UIMessageChunk> | null = null;
108+
let disposed = false;
135109

136110
store.set(agentChatFirstLiveSeqState.atom, null);
137111

112+
const closeWriter = () => {
113+
if (isDefined(writer)) {
114+
writer.close().catch(() => {});
115+
writer = null;
116+
}
117+
};
118+
119+
const cleanupStream = () => {
120+
closeWriter();
121+
122+
if (store.get(agentChatIsStreamingState.atom)) {
123+
store.set(agentChatIsStreamingState.atom, false);
124+
}
125+
};
126+
138127
const flushToAtom = () => {
139128
const messageToFlush = latestMessage;
140129

141-
if (!isDefined(messageToFlush) || !isDefined(threadId)) {
130+
if (!isDefined(messageToFlush)) {
142131
return;
143132
}
144133

@@ -244,7 +233,9 @@ export const useAgentChatSubscription = (threadId: string | null) => {
244233
}
245234
flushToAtom();
246235

247-
store.set(agentChatIsStreamingState.atom, false);
236+
if (!disposed) {
237+
store.set(agentChatIsStreamingState.atom, false);
238+
}
248239
};
249240

250241
const handleEvent = (event: AgentChatSubscriptionEvent) => {
@@ -261,36 +252,27 @@ export const useAgentChatSubscription = (threadId: string | null) => {
261252
store.set(agentChatIsStreamingState.atom, true);
262253

263254
bridge = new TransformStream<UIMessageChunk>();
264-
store.set(
265-
agentChatStreamWriterState.atom,
266-
bridge.writable.getWriter(),
267-
);
255+
writer = bridge.writable.getWriter();
268256

269257
const adaptedReadable = bridge.readable.pipeThrough(
270258
createMidStreamAdapter(),
271259
);
272260

273261
startReadLoop(adaptedReadable).catch(() => {
274-
store.set(agentChatIsStreamingState.atom, false);
262+
if (!disposed) {
263+
store.set(agentChatIsStreamingState.atom, false);
264+
}
275265
});
276266
}
277267

278-
const writer = store.get(agentChatStreamWriterState.atom);
279-
280268
if (isDefined(writer)) {
281269
writer.write(event.chunk as UIMessageChunk).catch(() => {});
282270
}
283271
break;
284272
}
285273

286274
case 'message-persisted': {
287-
const writer = store.get(agentChatStreamWriterState.atom);
288-
289-
if (isDefined(writer)) {
290-
writer.close().catch(() => {});
291-
store.set(agentChatStreamWriterState.atom, null);
292-
}
293-
275+
closeWriter();
294276
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
295277
break;
296278
}
@@ -308,13 +290,7 @@ export const useAgentChatSubscription = (threadId: string | null) => {
308290
streamError.code = event.code;
309291
store.set(agentChatErrorState.atom, streamError);
310292

311-
const writer = store.get(agentChatStreamWriterState.atom);
312-
313-
if (isDefined(writer)) {
314-
writer.close().catch(() => {});
315-
store.set(agentChatStreamWriterState.atom, null);
316-
}
317-
293+
closeWriter();
318294
store.set(agentChatIsStreamingState.atom, false);
319295
break;
320296
}
@@ -340,19 +316,21 @@ export const useAgentChatSubscription = (threadId: string | null) => {
340316
// graphql-sse handles reconnection automatically
341317
},
342318
complete: () => {
343-
cleanup();
319+
if (!disposed) {
320+
cleanupStream();
321+
}
344322
},
345323
},
346324
);
347325

348-
store.set(agentChatSubscriptionDisposeState.atom, () => dispose);
349-
350326
return () => {
327+
disposed = true;
351328
store.set(agentChatHandleEventCallbackState.atom, null);
352329
if (isDefined(throttleTimer)) {
353330
clearTimeout(throttleTimer);
354331
}
355-
cleanup();
332+
cleanupStream();
333+
dispose();
356334
};
357-
}, [threadId, sseClient, store, cleanup]);
335+
}, [threadId, sseClient, store]);
358336
};

packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadIdForSend.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ export const useEnsureAgentChatThreadIdForSend = (
2020
> => {
2121
const currentThreadId = store.get(currentAIChatThreadState.atom);
2222

23-
if (currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
23+
if (
24+
currentThreadId !== null &&
25+
currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY
26+
) {
2427
return currentThreadId;
2528
}
2629

packages/twenty-front/src/modules/ai/hooks/useSwitchToNewAIChat.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,16 @@ export const useSwitchToNewAIChat = () => {
3535

3636
const switchToNewChat = () => {
3737
setThreadIdCreatedFromDraft(null);
38-
const previousDraftKey = currentAIChatThread;
3938
const newChatDraft =
4039
store.get(agentChatDraftsByThreadIdState.atom)[
4140
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
4241
] ?? '';
43-
setAgentChatDraftsByThreadId((prev) => ({
44-
...prev,
45-
[previousDraftKey]: store.get(agentChatInputState.atom),
46-
}));
42+
if (currentAIChatThread !== null) {
43+
setAgentChatDraftsByThreadId((prev) => ({
44+
...prev,
45+
[currentAIChatThread]: store.get(agentChatInputState.atom),
46+
}));
47+
}
4748
store.set(hasTriggeredCreateForDraftState.atom, false);
4849
setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
4950
setAgentChatInput(newChatDraft);
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
22

3-
export const agentChatDisplayedThreadState = createAtomState<string>({
3+
export const agentChatDisplayedThreadState = createAtomState<string | null>({
44
key: 'ai/agentChatDisplayedThreadState',
5-
defaultValue: '',
5+
defaultValue: null,
66
});

0 commit comments

Comments
 (0)