diff --git a/apps/nextjs/src/components/assistant/assistant-notifications.spec.ts b/apps/nextjs/src/components/assistant/assistant-notifications.spec.ts new file mode 100644 index 000000000..11ca3fd04 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-notifications.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; + +import { initialAssistantNotificationState, updateAssistantNotificationState } from "./assistant-notifications"; + +describe("updateAssistantNotificationState", () => { + test("baselines the current response when notifications initialize", () => { + const update = updateAssistantNotificationState(initialAssistantNotificationState, { + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }); + + expect(update.shouldNotify).toBe(false); + expect(update.state).toEqual({ + initialized: true, + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }); + }); + + test("notifies when a response completes in the active conversation", () => { + const update = updateAssistantNotificationState( + { + initialized: true, + conversationId: "conversation-a", + notificationKey: null, + }, + { + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }, + ); + + expect(update.shouldNotify).toBe(true); + }); + + test("does not count existing history when the selected conversation changes", () => { + const update = updateAssistantNotificationState( + { + initialized: true, + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }, + { + conversationId: "conversation-b", + notificationKey: "response-b:complete", + }, + ); + + expect(update.shouldNotify).toBe(false); + expect(update.state.notificationKey).toBe("response-b:complete"); + }); + + test("notifies for the next completion after switching conversations", () => { + const switched = updateAssistantNotificationState( + { + initialized: true, + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }, + { + conversationId: "conversation-b", + notificationKey: "response-b:complete", + }, + ); + const completed = updateAssistantNotificationState(switched.state, { + conversationId: "conversation-b", + notificationKey: "response-c:complete", + }); + + expect(completed.shouldNotify).toBe(true); + }); + + test("ignores running and repeated response states", () => { + const previous = { + initialized: true, + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }; + + expect( + updateAssistantNotificationState(previous, { + conversationId: "conversation-a", + notificationKey: null, + }).shouldNotify, + ).toBe(false); + expect( + updateAssistantNotificationState(previous, { + conversationId: "conversation-a", + notificationKey: "response-a:complete", + }).shouldNotify, + ).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-notifications.ts b/apps/nextjs/src/components/assistant/assistant-notifications.ts new file mode 100644 index 000000000..953b1581d --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-notifications.ts @@ -0,0 +1,32 @@ +export interface AssistantNotificationState { + initialized: boolean; + conversationId: string | null; + notificationKey: string | null; +} + +export interface AssistantNotificationUpdate { + state: AssistantNotificationState; + shouldNotify: boolean; +} + +export const initialAssistantNotificationState: AssistantNotificationState = { + initialized: false, + conversationId: null, + notificationKey: null, +}; + +export const updateAssistantNotificationState = ( + previous: AssistantNotificationState, + current: Pick, +): AssistantNotificationUpdate => { + const state = { initialized: true, ...current }; + + if (!previous.initialized || previous.conversationId !== current.conversationId) { + return { state, shouldNotify: false }; + } + + return { + state, + shouldNotify: current.notificationKey !== null && current.notificationKey !== previous.notificationKey, + }; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index d77362e6f..7d30b0581 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -39,6 +39,7 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { browserToolContracts } from "./assistant-tool-contracts"; import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool"; import type { AssistantUIMessage } from "./assistant-message-metadata"; +import { initialAssistantNotificationState, updateAssistantNotificationState } from "./assistant-notifications"; import { AssistantBoardWidget } from "./assistant-widget"; interface AssistantContextValue { @@ -478,14 +479,15 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => { const AssistantPreferenceSync = () => { const preferences = useAssistantPreferences(); - const threadId = useAuiState((state) => state.threadListItem.remoteId); + // The local id distinguishes every thread immediately, including threads that do not have a remote id yet. + const conversationId = useAuiState((state) => state.threadListItem.id); const threadModelId = useAuiState((state) => state.threadListItem.custom?.modelId); const previousSyncKeyRef = useRef(null); const modelCatalogKey = preferences.models.map((model) => model.id).join("\0"); useEffect(() => { if (preferences.models.length === 0) return; - const syncKey = `${threadId ?? "new"}:${modelCatalogKey}`; + const syncKey = `${conversationId}:${modelCatalogKey}`; if (previousSyncKeyRef.current === syncKey) return; previousSyncKeyRef.current = syncKey; const storedModelId = typeof threadModelId === "string" ? threadModelId : null; @@ -494,7 +496,7 @@ const AssistantPreferenceSync = () => { ? storedModelId : preferences.defaultModelId; if (nextModelId) preferences.setModelId(nextModelId); - }, [modelCatalogKey, preferences, threadId, threadModelId]); + }, [conversationId, modelCatalogKey, preferences, threadModelId]); return null; }; @@ -547,13 +549,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const pendingAction = getPendingAssistantAction(latestAssistantMessage); const assistantIsRunning = isRunning || queuedPrompt !== null; const notificationKey = getNotificationKey(latestAssistantMessage); - const initializedRef = useRef(false); - const lastNotifiedKeyRef = useRef(null); + const notificationStateRef = useRef(initialAssistantNotificationState); const markRead = useCallback(() => { - lastNotifiedKeyRef.current = notificationKey; + notificationStateRef.current = { + initialized: true, + conversationId, + notificationKey, + }; setUnreadCount(0); - }, [notificationKey]); + }, [conversationId, notificationKey]); const open = useCallback(() => { markRead(); setActivityDismissed(false); @@ -607,25 +612,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { useEffect(() => { if (isLoading) return; - - if (!initializedRef.current) { - initializedRef.current = true; - lastNotifiedKeyRef.current = notificationKey; - return; - } - - if (notificationKey === null || notificationKey === lastNotifiedKeyRef.current) return; + const update = updateAssistantNotificationState(notificationStateRef.current, { + conversationId, + notificationKey, + }); + notificationStateRef.current = update.state; + if (!update.shouldNotify) return; setActivityDismissed(false); if (opened) { - lastNotifiedKeyRef.current = notificationKey; setUnreadCount(0); return; } - lastNotifiedKeyRef.current = notificationKey; setUnreadCount((current) => current + 1); - }, [isLoading, notificationKey, opened]); + }, [conversationId, isLoading, notificationKey, opened]); useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);