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
@@ -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]);
};