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>
This commit is contained in:
Félix Malfait
2026-04-10 16:34:03 +02:00
committed by GitHub
parent f99c05f0e8
commit 8c4a6cd663
17 changed files with 86 additions and 105 deletions
@@ -29,7 +29,7 @@ export const AIChatTab = () => {
const threadIdCreatedFromDraft = useAtomStateValue(
threadIdCreatedFromDraftState,
);
const draftKey = currentAIChatThread;
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const editorSectionKey =
draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY &&
draftKey === threadIdCreatedFromDraft
@@ -4,7 +4,6 @@ import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { AGENT_CHAT_UNKNOWN_THREAD_ID } from '@/ai/constants/AgentChatUnknownThreadId';
import { agentChatFirstLiveSeqState } from '@/ai/states/agentChatFirstLiveSeqState';
import { agentChatHandleEventCallbackState } from '@/ai/states/agentChatHandleEventCallbackState';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
@@ -30,8 +29,8 @@ export const AgentChatMessagesFetchEffect = () => {
const isNewThread = useMemo(
() =>
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ||
currentAIChatThread === AGENT_CHAT_UNKNOWN_THREAD_ID,
currentAIChatThread === null ||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
[currentAIChatThread],
);
@@ -111,7 +110,7 @@ export const AgentChatMessagesFetchEffect = () => {
const { refetch: refetchAgentChatMessages } = useQueryWithCallbacks(
GetChatMessagesDocument,
{
variables: { threadId: currentAIChatThread },
variables: { threadId: currentAIChatThread ?? '' },
skip: !isDefined(currentAIChatThread) || isNewThread,
onFirstLoad: handleFirstLoad,
onDataLoaded: handleDataLoaded,
@@ -120,8 +119,12 @@ export const AgentChatMessagesFetchEffect = () => {
);
const handleRefetchMessages = useCallback(() => {
if (isNewThread) {
return;
}
refetchAgentChatMessages();
}, [refetchAgentChatMessages]);
}, [refetchAgentChatMessages, isNewThread]);
useListenToBrowserEvent({
eventName: AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME,
@@ -88,7 +88,10 @@ export const AgentChatThreadInitializationEffect = () => {
}, [storeEntry.status, hasAiSettingsPermission, setAgentChatThreadsLoading]);
useEffect(() => {
if (hasInitializedAgentChatThreads || isValidUuid(currentAIChatThread)) {
if (
hasInitializedAgentChatThreads ||
(currentAIChatThread !== null && isValidUuid(currentAIChatThread))
) {
return;
}
@@ -1 +0,0 @@
export const AGENT_CHAT_UNKNOWN_THREAD_ID = 'unknown-thread';
@@ -49,7 +49,7 @@ export const useAIChatEditor = () => {
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const draftKey = currentAIChatThread;
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
const initialContent = textToTiptapContent(initialDraft);
@@ -38,13 +38,14 @@ export const useAIChatThreadClick = (
const handleThreadClick = (thread: AgentChatThread) => {
setThreadIdCreatedFromDraft(null);
const previousDraftKey = currentAIChatThread;
const isSameThread = thread.id === currentAIChatThread;
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[previousDraftKey]: store.get(agentChatInputState.atom),
}));
if (currentAIChatThread !== null) {
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[currentAIChatThread]: store.get(agentChatInputState.atom),
}));
}
setCurrentAIChatThread(thread.id);
if (!isSameThread) {
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react';
import { useEffect } from 'react';
import { readUIMessageStream, type UIMessageChunk } from 'ai';
import { print, type ExecutionResult } from 'graphql';
@@ -18,8 +18,6 @@ import { agentChatFirstLiveSeqState } from '@/ai/states/agentChatFirstLiveSeqSta
import { agentChatHandleEventCallbackState } from '@/ai/states/agentChatHandleEventCallbackState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatStreamWriterState } from '@/ai/states/agentChatStreamWriterState';
import { agentChatSubscriptionDisposeState } from '@/ai/states/agentChatSubscriptionDisposeState';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
@@ -98,47 +96,38 @@ export const useAgentChatSubscription = (threadId: string | null) => {
const store = useStore();
const sseClient = useAtomStateValue(sseClientState);
const cleanup = useCallback(() => {
const writer = store.get(agentChatStreamWriterState.atom);
if (isDefined(writer)) {
writer.close().catch(() => {});
store.set(agentChatStreamWriterState.atom, null);
}
const dispose = store.get(agentChatSubscriptionDisposeState.atom);
if (isDefined(dispose)) {
dispose();
store.set(agentChatSubscriptionDisposeState.atom, null);
}
if (store.get(agentChatIsStreamingState.atom)) {
store.set(agentChatIsStreamingState.atom, false);
}
}, [store]);
useEffect(() => {
if (!isDefined(threadId)) {
cleanup();
return;
}
if (!isDefined(sseClient)) {
if (!isDefined(threadId) || !isDefined(sseClient)) {
return;
}
let bridge: TransformStream<UIMessageChunk> | null = null;
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let latestMessage: ExtendedUIMessage | null = null;
let writer: WritableStreamDefaultWriter<UIMessageChunk> | null = null;
let disposed = false;
store.set(agentChatFirstLiveSeqState.atom, null);
const closeWriter = () => {
if (isDefined(writer)) {
writer.close().catch(() => {});
writer = null;
}
};
const cleanupStream = () => {
closeWriter();
if (store.get(agentChatIsStreamingState.atom)) {
store.set(agentChatIsStreamingState.atom, false);
}
};
const flushToAtom = () => {
const messageToFlush = latestMessage;
if (!isDefined(messageToFlush) || !isDefined(threadId)) {
if (!isDefined(messageToFlush)) {
return;
}
@@ -244,7 +233,9 @@ export const useAgentChatSubscription = (threadId: string | null) => {
}
flushToAtom();
store.set(agentChatIsStreamingState.atom, false);
if (!disposed) {
store.set(agentChatIsStreamingState.atom, false);
}
};
const handleEvent = (event: AgentChatSubscriptionEvent) => {
@@ -261,22 +252,19 @@ export const useAgentChatSubscription = (threadId: string | null) => {
store.set(agentChatIsStreamingState.atom, true);
bridge = new TransformStream<UIMessageChunk>();
store.set(
agentChatStreamWriterState.atom,
bridge.writable.getWriter(),
);
writer = bridge.writable.getWriter();
const adaptedReadable = bridge.readable.pipeThrough(
createMidStreamAdapter(),
);
startReadLoop(adaptedReadable).catch(() => {
store.set(agentChatIsStreamingState.atom, false);
if (!disposed) {
store.set(agentChatIsStreamingState.atom, false);
}
});
}
const writer = store.get(agentChatStreamWriterState.atom);
if (isDefined(writer)) {
writer.write(event.chunk as UIMessageChunk).catch(() => {});
}
@@ -284,13 +272,7 @@ export const useAgentChatSubscription = (threadId: string | null) => {
}
case 'message-persisted': {
const writer = store.get(agentChatStreamWriterState.atom);
if (isDefined(writer)) {
writer.close().catch(() => {});
store.set(agentChatStreamWriterState.atom, null);
}
closeWriter();
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
break;
}
@@ -308,13 +290,7 @@ export const useAgentChatSubscription = (threadId: string | null) => {
streamError.code = event.code;
store.set(agentChatErrorState.atom, streamError);
const writer = store.get(agentChatStreamWriterState.atom);
if (isDefined(writer)) {
writer.close().catch(() => {});
store.set(agentChatStreamWriterState.atom, null);
}
closeWriter();
store.set(agentChatIsStreamingState.atom, false);
break;
}
@@ -340,19 +316,21 @@ export const useAgentChatSubscription = (threadId: string | null) => {
// graphql-sse handles reconnection automatically
},
complete: () => {
cleanup();
if (!disposed) {
cleanupStream();
}
},
},
);
store.set(agentChatSubscriptionDisposeState.atom, () => dispose);
return () => {
disposed = true;
store.set(agentChatHandleEventCallbackState.atom, null);
if (isDefined(throttleTimer)) {
clearTimeout(throttleTimer);
}
cleanup();
cleanupStream();
dispose();
};
}, [threadId, sseClient, store, cleanup]);
}, [threadId, sseClient, store]);
};
@@ -20,7 +20,10 @@ export const useEnsureAgentChatThreadIdForSend = (
> => {
const currentThreadId = store.get(currentAIChatThreadState.atom);
if (currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
if (
currentThreadId !== null &&
currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY
) {
return currentThreadId;
}
@@ -35,15 +35,16 @@ export const useSwitchToNewAIChat = () => {
const switchToNewChat = () => {
setThreadIdCreatedFromDraft(null);
const previousDraftKey = currentAIChatThread;
const newChatDraft =
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '';
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[previousDraftKey]: store.get(agentChatInputState.atom),
}));
if (currentAIChatThread !== null) {
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[currentAIChatThread]: store.get(agentChatInputState.atom),
}));
}
store.set(hasTriggeredCreateForDraftState.atom, false);
setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
setAgentChatInput(newChatDraft);
@@ -1,6 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatDisplayedThreadState = createAtomState<string>({
export const agentChatDisplayedThreadState = createAtomState<string | null>({
key: 'ai/agentChatDisplayedThreadState',
defaultValue: '',
defaultValue: null,
});
@@ -3,7 +3,10 @@ import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatFetchedMessagesComponentFamilyState =
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
createAtomComponentFamilyState<
ExtendedUIMessage[],
{ threadId: string | null }
>({
key: 'agentChatFetchedMessagesComponentFamilyState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
@@ -1,6 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatLastDiffSyncedThreadState = createAtomState<string>({
export const agentChatLastDiffSyncedThreadState = createAtomState<
string | null
>({
key: 'ai/agentChatLastDiffSyncedThreadState',
defaultValue: '',
defaultValue: null,
});
@@ -3,7 +3,10 @@ import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatMessagesComponentFamilyState =
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
createAtomComponentFamilyState<
ExtendedUIMessage[],
{ threadId: string | null }
>({
key: 'agentChatMessagesComponentFamilyState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
@@ -3,7 +3,10 @@ import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatQueuedMessagesComponentFamilyState =
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
createAtomComponentFamilyState<
ExtendedUIMessage[],
{ threadId: string | null }
>({
key: 'agentChatQueuedMessagesComponentFamilyState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
@@ -1,9 +0,0 @@
import { type UIMessageChunk } from 'ai';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatStreamWriterState =
createAtomState<WritableStreamDefaultWriter<UIMessageChunk> | null>({
key: 'agentChatStreamWriterState',
defaultValue: null,
});
@@ -1,8 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatSubscriptionDisposeState = createAtomState<
(() => void) | null
>({
key: 'agentChatSubscriptionDisposeState',
defaultValue: null,
});
@@ -1,7 +1,6 @@
import { AGENT_CHAT_UNKNOWN_THREAD_ID } from '@/ai/constants/AgentChatUnknownThreadId';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const currentAIChatThreadState = createAtomState<string>({
export const currentAIChatThreadState = createAtomState<string | null>({
key: 'ai/currentAIChatThreadState',
defaultValue: AGENT_CHAT_UNKNOWN_THREAD_ID,
defaultValue: null,
});