fix: isolate assistant conversation state

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 3faac7a96d
commit 33695753dd
3 files changed
+144 -18

No files matched your search

@@ -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);
});
});
@@ -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<AssistantNotificationState, "conversationId" | "notificationKey">,
): 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,
};
};
@@ -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<string | null>(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<string | null>(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 }]]);