feat(FN-3604): add chat unread indicator to header and mobile nav

Implements a chat unread indicator across the header and mobile nav bar, driven by a new unread-response tracker in the app state, with tests and a docs update for the feature.

Fusion-Task-Id: FN-3604
This commit is contained in:
Fusion
2026-05-06 18:46:45 -07:00
committed by gsxdsm
parent c5ea44a41d
commit d370f305e9
9 changed files with 202 additions and 3 deletions

View File

@@ -539,8 +539,9 @@ beforeEach(() => {
mockNodeContextValue.clearCurrentNode.mockClear();
// Clear node selection from localStorage to avoid cross-test leakage
localStorage.removeItem("fusion-dashboard-current-node");
// Clear onboarding state from localStorage
// Clear onboarding/chat state from localStorage
localStorage.removeItem("kb-onboarding-state");
localStorage.removeItem(scopedKey("kb-chat-active-session", "proj_123"));
// Reset onboarding state mocks
mockIsOnboardingResumable.mockReset();
mockIsOnboardingResumable.mockReturnValue(false);
@@ -688,6 +689,114 @@ describe("App mailbox unread count", () => {
});
});
describe("App chat unread response indicator", () => {
it("shows unread indicator when assistant message arrives for active session after leaving chat", async () => {
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");
render(<App />);
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalled();
});
const chatSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["chat:message:added"] === "function",
);
const subscriptionConfig = chatSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }),
}),
);
});
await waitFor(() => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
});
});
it("does not show unread indicator for non-qualifying chat events", async () => {
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");
render(<App />);
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalled();
});
const chatSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["chat:message:added"] === "function",
);
const subscriptionConfig = chatSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "user", sessionId: "sess-active" }),
}),
);
subscriptionConfig.events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-other" }),
}),
);
});
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
});
it("clears unread indicator when returning to chat and does not mark while in chat", async () => {
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");
render(<App />);
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalled();
});
const chatSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["chat:message:added"] === "function",
);
const subscriptionConfig = chatSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }),
}),
);
});
await waitFor(() => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("header-chat-view-btn"));
await waitFor(() => {
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
});
await act(async () => {
subscriptionConfig.events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }),
}),
);
});
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
});
});
describe("App deep link handling", () => {
const originalLocation = window.location;
const originalReplaceState = window.history.replaceState;