fix: isolate assistant conversation state
This commit is contained in:
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 }]]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user